diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 09:22:09 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 09:22:09 +0000 |
commit | 43a97878ce14b72f0981164f87f2e35e14151312 (patch) | |
tree | 620249daf56c0258faa40cbdcf9cfba06de2a846 /browser/components/newtab/content-src/asrouter | |
parent | Initial commit. (diff) | |
download | firefox-43a97878ce14b72f0981164f87f2e35e14151312.tar.xz firefox-43a97878ce14b72f0981164f87f2e35e14151312.zip |
Adding upstream version 110.0.1.upstream/110.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
64 files changed, 9613 insertions, 0 deletions
diff --git a/browser/components/newtab/content-src/asrouter/README.md b/browser/components/newtab/content-src/asrouter/README.md new file mode 100644 index 0000000000..0ee3345630 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/README.md @@ -0,0 +1,34 @@ +# Activity Stream Router + +## Preferences `browser.newtab.activity-stream.asrouter.*` + +Name | Used for | Type | Example value +--- | --- | --- | --- +`allowHosts` | Allow a host in order to fetch messages from its endpoint | `[String]` | `["gist.github.com", "gist.githubusercontent.com", "localhost:8000"]` +`providers.snippets` | Message provider options for snippets | `Object` | [see below](#message-providers) +`providers.cfr` | Message provider options for cfr | `Object` | [see below](#message-providers) +`providers.onboarding` | Message provider options for onboarding | `Object` | [see below](#message-providers) +`useRemoteL10n` | Controls whether to use the remote Fluent files for l10n, default as `true` | `Boolean` | `[true|false]` + +### Message providers examples + +```json +{ + "id" : "snippets", + "type" : "remote", + "enabled": true, + "url" : "https://snippets.cdn.mozilla.net/us-west/bundles/bundle_d6d90fb9098ce8b45e60acf601bcb91b68322309.json", + "updateCycleInMs" : 14400000 +} +``` + +```json +{ + "id" : "onboarding", + "enabled": true, + "type" : "local", + "localProvider" : "OnboardingMessageProvider" +} +``` + +### [Snippet message format documentation](https://github.com/mozilla/activity-stream/blob/master/content-src/asrouter/schemas/message-format.md) diff --git a/browser/components/newtab/content-src/asrouter/asrouter-content.jsx b/browser/components/newtab/content-src/asrouter/asrouter-content.jsx new file mode 100644 index 0000000000..0663bcf674 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/asrouter-content.jsx @@ -0,0 +1,344 @@ +/* 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 "common/ActorConstants.sys.mjs"; +import { actionTypes as at } from "common/Actions.sys.mjs"; +import { ASRouterUtils } from "./asrouter-utils"; +import { generateBundles } from "./rich-text-strings"; +import { ImpressionsWrapper } from "./components/ImpressionsWrapper/ImpressionsWrapper"; +import { LocalizationProvider } from "fluent-react"; +import { NEWTAB_DARK_THEME } from "content-src/lib/constants"; +import React from "react"; +import ReactDOM from "react-dom"; +import { SnippetsTemplates } from "./templates/template-manifest"; + +const TEMPLATES_BELOW_SEARCH = ["simple_below_search_snippet"]; + +// Note: nextProps/prevProps refer to props passed to <ImpressionsWrapper />, not <ASRouterUISurface /> +function shouldSendImpressionOnUpdate(nextProps, prevProps) { + return ( + nextProps.message.id && + (!prevProps.message || prevProps.message.id !== nextProps.message.id) + ); +} + +export class ASRouterUISurface extends React.PureComponent { + constructor(props) { + super(props); + this.sendClick = this.sendClick.bind(this); + this.sendImpression = this.sendImpression.bind(this); + this.sendUserActionTelemetry = this.sendUserActionTelemetry.bind(this); + this.onUserAction = this.onUserAction.bind(this); + this.fetchFlowParams = this.fetchFlowParams.bind(this); + this.onBlockSelected = this.onBlockSelected.bind(this); + this.onBlockById = this.onBlockById.bind(this); + this.onDismiss = this.onDismiss.bind(this); + this.onMessageFromParent = this.onMessageFromParent.bind(this); + + this.state = { message: {} }; + if (props.document) { + this.footerPortal = props.document.getElementById( + "footer-asrouter-container" + ); + } + } + + async fetchFlowParams(params = {}) { + let result = {}; + const { fxaEndpoint } = this.props; + if (!fxaEndpoint) { + const err = + "Tried to fetch flow params before fxaEndpoint pref was ready"; + console.error(err); + } + + try { + const urlObj = new URL(fxaEndpoint); + urlObj.pathname = "metrics-flow"; + Object.keys(params).forEach(key => { + urlObj.searchParams.append(key, params[key]); + }); + const response = await fetch(urlObj.toString(), { credentials: "omit" }); + if (response.status === 200) { + const { deviceId, flowId, flowBeginTime } = await response.json(); + result = { deviceId, flowId, flowBeginTime }; + } else { + console.error("Non-200 response", response); + } + } catch (error) { + console.error(error); + } + return result; + } + + sendUserActionTelemetry(extraProps = {}) { + const { message } = this.state; + const eventType = `${message.provider}_user_event`; + const source = extraProps.id; + delete extraProps.id; + ASRouterUtils.sendTelemetry({ + source, + message_id: message.id, + action: eventType, + ...extraProps, + }); + } + + sendImpression(extraProps) { + if (this.state.message.provider === "preview") { + return Promise.resolve(); + } + + this.sendUserActionTelemetry({ event: "IMPRESSION", ...extraProps }); + return ASRouterUtils.sendMessage({ + type: msg.IMPRESSION, + data: this.state.message, + }); + } + + // If link has a `metric` data attribute send it as part of the `event_context` + // telemetry field which can have arbitrary values. + // Used for router messages with links as part of the content. + sendClick(event) { + const { dataset } = event.target; + const metric = { + event_context: dataset.metric, + // Used for the `source` of the event. Needed to differentiate + // from other snippet or onboarding events that may occur. + id: "NEWTAB_FOOTER_BAR_CONTENT", + }; + const { entrypoint_name, entrypoint_value } = dataset; + // Assign the snippet referral for the action + const entrypoint = entrypoint_name + ? new URLSearchParams([[entrypoint_name, entrypoint_value]]).toString() + : entrypoint_value; + const action = { + type: dataset.action, + data: { + args: dataset.args, + ...(entrypoint && { entrypoint }), + }, + }; + if (action.type) { + ASRouterUtils.executeAction(action); + } + if ( + !this.state.message.content.do_not_autoblock && + !dataset.do_not_autoblock + ) { + this.onBlockById(this.state.message.id); + } + if (this.state.message.provider !== "preview") { + this.sendUserActionTelemetry({ event: "CLICK_BUTTON", ...metric }); + } + } + + onBlockSelected(options) { + return this.onBlockById(this.state.message.id, { + ...options, + campaign: this.state.message.campaign, + }); + } + + onBlockById(id, options) { + return ASRouterUtils.blockById(id, options).then(clearAll => { + if (clearAll) { + this.setState({ message: {} }); + } + }); + } + + onDismiss() { + this.clearMessage(this.state.message.id); + } + + // Blocking a snippet by id blocks the entire campaign + // so when clearing we use the two values interchangeably + clearMessage(idOrCampaign) { + if ( + idOrCampaign === this.state.message.id || + idOrCampaign === this.state.message.campaign + ) { + this.setState({ message: {} }); + } + } + + clearProvider(id) { + if (this.state.message.provider === id) { + this.setState({ message: {} }); + } + } + + onMessageFromParent({ type, data }) { + // These only exists due to onPrefChange events in ASRouter + switch (type) { + case "ClearMessages": { + data.forEach(id => this.clearMessage(id)); + break; + } + case "ClearProviders": { + data.forEach(id => this.clearProvider(id)); + break; + } + case "EnterSnippetsPreviewMode": { + this.props.dispatch({ type: at.SNIPPETS_PREVIEW_MODE }); + break; + } + } + } + + requestMessage(endpoint) { + ASRouterUtils.sendMessage({ + type: "NEWTAB_MESSAGE_REQUEST", + data: { endpoint }, + }).then(state => this.setState(state)); + } + + componentWillMount() { + const endpoint = ASRouterUtils.getPreviewEndpoint(); + if (endpoint && endpoint.theme === "dark") { + global.window.dispatchEvent( + new CustomEvent("LightweightTheme:Set", { + detail: { data: NEWTAB_DARK_THEME }, + }) + ); + } + if (endpoint && endpoint.dir === "rtl") { + //Set `dir = rtl` on the HTML + this.props.document.dir = "rtl"; + } + ASRouterUtils.addListener(this.onMessageFromParent); + this.requestMessage(endpoint); + } + + componentWillUnmount() { + ASRouterUtils.removeListener(this.onMessageFromParent); + } + + componentDidUpdate(prevProps, prevState) { + if ( + prevProps.adminContent && + JSON.stringify(prevProps.adminContent) !== + JSON.stringify(this.props.adminContent) + ) { + this.updateContent(); + } + if (prevState.message.id !== this.state.message.id) { + const main = global.window.document.querySelector("main"); + if (main) { + if (this.state.message.id) { + main.classList.add("has-snippet"); + } else { + main.classList.remove("has-snippet"); + } + } + } + } + + updateContent() { + this.setState({ + ...this.props.adminContent, + }); + } + + async getMonitorUrl({ url, flowRequestParams = {} }) { + const flowValues = await this.fetchFlowParams(flowRequestParams); + + // Note that flowParams are actually added dynamically on the page + const urlObj = new URL(url); + ["deviceId", "flowId", "flowBeginTime"].forEach(key => { + if (key in flowValues) { + urlObj.searchParams.append(key, flowValues[key]); + } + }); + + return urlObj.toString(); + } + + async onUserAction(action) { + switch (action.type) { + // This needs to be handled locally because its + case "ENABLE_FIREFOX_MONITOR": + const url = await this.getMonitorUrl(action.data.args); + ASRouterUtils.executeAction({ type: "OPEN_URL", data: { args: url } }); + break; + default: + ASRouterUtils.executeAction(action); + } + } + + renderSnippets() { + const { message } = this.state; + if (!SnippetsTemplates[message.template]) { + return null; + } + const SnippetComponent = SnippetsTemplates[message.template]; + const { content } = message; + + return ( + <ImpressionsWrapper + id="NEWTAB_FOOTER_BAR" + message={this.state.message} + sendImpression={this.sendImpression} + shouldSendImpressionOnUpdate={shouldSendImpressionOnUpdate} + // This helps with testing + document={this.props.document} + > + <LocalizationProvider bundles={generateBundles(content)}> + <SnippetComponent + {...this.state.message} + UISurface="NEWTAB_FOOTER_BAR" + onBlock={this.onBlockSelected} + onDismiss={this.onDismiss} + onAction={this.onUserAction} + sendClick={this.sendClick} + sendUserActionTelemetry={this.sendUserActionTelemetry} + /> + </LocalizationProvider> + </ImpressionsWrapper> + ); + } + + renderPreviewBanner() { + if (this.state.message.provider !== "preview") { + return null; + } + + return ( + <div className="snippets-preview-banner"> + <span className="icon icon-small-spacer icon-info" /> + <span>Preview Purposes Only</span> + </div> + ); + } + + render() { + const { message } = this.state; + if (!message.id) { + return null; + } + const shouldRenderBelowSearch = TEMPLATES_BELOW_SEARCH.includes( + message.template + ); + + return shouldRenderBelowSearch ? ( + // Render special below search snippets in place; + <div className="below-search-snippet-wrapper"> + {this.renderSnippets()} + </div> + ) : ( + // For regular snippets etc. we should render everything in our footer + // container. + ReactDOM.createPortal( + <> + {this.renderPreviewBanner()} + {this.renderSnippets()} + </>, + this.footerPortal + ) + ); + } +} + +ASRouterUISurface.defaultProps = { document: global.document }; diff --git a/browser/components/newtab/content-src/asrouter/asrouter-utils.js b/browser/components/newtab/content-src/asrouter/asrouter-utils.js new file mode 100644 index 0000000000..9864823a77 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/asrouter-utils.js @@ -0,0 +1,108 @@ +/* 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 "common/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 }, + }); + }, + sendTelemetry(ping) { + return ASRouterUtils.sendMessage(ac.ASRouterUserEvent(ping)); + }, + getPreviewEndpoint() { + if ( + global.document && + global.document.location && + global.document.location.href.includes("endpoint") + ) { + const params = new URLSearchParams( + global.document.location.href.slice( + global.document.location.href.indexOf("endpoint") + ) + ); + try { + const endpoint = new URL(params.get("endpoint")); + return { + url: endpoint.href, + snippetId: params.get("snippetId"), + theme: this.getPreviewTheme(), + dir: this.getPreviewDir(), + }; + } catch (e) {} + } + + return null; + }, + getPreviewTheme() { + return new URLSearchParams( + global.document.location.href.slice( + global.document.location.href.indexOf("theme") + ) + ).get("theme"); + }, + getPreviewDir() { + return new URLSearchParams( + global.document.location.href.slice( + global.document.location.href.indexOf("dir") + ) + ).get("dir"); + }, +}; diff --git a/browser/components/newtab/content-src/asrouter/components/Button/Button.jsx b/browser/components/newtab/content-src/asrouter/components/Button/Button.jsx new file mode 100644 index 0000000000..b3ece86f16 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/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/newtab/content-src/asrouter/components/Button/_Button.scss b/browser/components/newtab/content-src/asrouter/components/Button/_Button.scss new file mode 100644 index 0000000000..35234be4b0 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/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/newtab/content-src/asrouter/components/ConditionalWrapper/ConditionalWrapper.jsx b/browser/components/newtab/content-src/asrouter/components/ConditionalWrapper/ConditionalWrapper.jsx new file mode 100644 index 0000000000..e4b0812f26 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/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/newtab/content-src/asrouter/components/ImpressionsWrapper/ImpressionsWrapper.jsx b/browser/components/newtab/content-src/asrouter/components/ImpressionsWrapper/ImpressionsWrapper.jsx new file mode 100644 index 0000000000..8498bde03b --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/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/newtab/content-src/asrouter/components/ModalOverlay/ModalOverlay.jsx b/browser/components/newtab/content-src/asrouter/components/ModalOverlay/ModalOverlay.jsx new file mode 100644 index 0000000000..fdfdf22db2 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/components/ModalOverlay/ModalOverlay.jsx @@ -0,0 +1,56 @@ +/* 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 ModalOverlayWrapper extends React.PureComponent { + constructor(props) { + super(props); + this.onKeyDown = this.onKeyDown.bind(this); + } + + // The intended behaviour is to listen for an escape key + // but not for a click; see Bug 1582242 + onKeyDown(event) { + if (event.key === "Escape") { + this.props.onClose(event); + } + } + + componentWillMount() { + this.props.document.addEventListener("keydown", this.onKeyDown); + this.props.document.body.classList.add("modal-open"); + } + + componentWillUnmount() { + this.props.document.removeEventListener("keydown", this.onKeyDown); + this.props.document.body.classList.remove("modal-open"); + } + + render() { + const { props } = this; + let className = props.unstyled ? "" : "modalOverlayInner active"; + if (props.innerClassName) { + className += ` ${props.innerClassName}`; + } + return ( + <div + className="modalOverlayOuter active" + onKeyDown={this.onKeyDown} + role="presentation" + > + <div + className={className} + aria-labelledby={props.headerId} + id={props.id} + role="dialog" + > + {props.children} + </div> + </div> + ); + } +} + +ModalOverlayWrapper.defaultProps = { document: global.document }; diff --git a/browser/components/newtab/content-src/asrouter/components/ModalOverlay/_ModalOverlay.scss b/browser/components/newtab/content-src/asrouter/components/ModalOverlay/_ModalOverlay.scss new file mode 100644 index 0000000000..a1006c9437 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/components/ModalOverlay/_ModalOverlay.scss @@ -0,0 +1,103 @@ +// Variable for the about:welcome modal scrollbars +$modal-scrollbar-z-index: 1100; + +.activity-stream { + &.modal-open { + overflow: hidden; + } +} + +.modalOverlayOuter { + background: var(--newtab-overlay-color); + height: 100%; + position: fixed; + top: 0; + left: 0; + width: 100%; + display: none; + z-index: $modal-scrollbar-z-index; + overflow: auto; + + &.active { + display: flex; + } +} + +.modalOverlayInner { + min-width: min-content; + width: 100%; + max-width: 960px; + position: relative; + margin: auto; + background: var(--newtab-background-color-secondary); + box-shadow: $shadow-large; + border-radius: 4px; + display: none; + z-index: $modal-scrollbar-z-index; + + // modal takes over entire screen + @media(max-width: 960px) { + height: 100%; + top: 0; + left: 0; + box-shadow: none; + border-radius: 0; + } + + &.active { + display: block; + } + + h2 { + color: var(--newtab-text-primary-color); + text-align: center; + font-weight: 200; + margin-top: 30px; + font-size: 28px; + line-height: 37px; + letter-spacing: -0.13px; + + @media(max-width: 960px) { + margin-top: 100px; + } + + @media(max-width: 850px) { + margin-top: 30px; + } + } + + .footer { + border-top: $border-secondary; + border-radius: 4px; + height: 70px; + width: 100%; + position: absolute; + bottom: 0; + text-align: center; + background-color: $white; + + // if modal is short enough, footer becomes sticky + @media(max-width: 850px) and (max-height: 730px) { + position: sticky; + } + + // if modal is narrow enough, footer becomes sticky + @media(max-width: 650px) and (max-height: 600px) { + position: sticky; + } + + .modalButton { + margin-top: 20px; + min-width: 150px; + height: 30px; + padding: 4px 30px 6px; + font-size: 15px; + + &:focus, + &.active, + &:hover { + @include fade-in-card; + } + } + } +} diff --git a/browser/components/newtab/content-src/asrouter/components/RichText/RichText.jsx b/browser/components/newtab/content-src/asrouter/components/RichText/RichText.jsx new file mode 100644 index 0000000000..21c568ac68 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/components/RichText/RichText.jsx @@ -0,0 +1,84 @@ +/* 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 { Localized } from "fluent-react"; +import React from "react"; +import { RICH_TEXT_KEYS } from "../../rich-text-strings"; +import { safeURI } from "../../template-utils"; + +// Elements allowed in snippet content +const ALLOWED_TAGS = { + b: <b />, + i: <i />, + u: <u />, + strong: <strong />, + em: <em />, + br: <br />, +}; + +/** + * Transform an object (tag name: {url}) into (tag name: anchor) where the url + * is used as href, in order to render links inside a Fluent.Localized component. + */ +export function convertLinks( + links, + sendClick, + doNotAutoBlock, + openNewWindow = false +) { + if (links) { + return Object.keys(links).reduce((acc, linkTag) => { + const { action } = links[linkTag]; + // Setting the value to false will not include the attribute in the anchor + const url = action ? false : safeURI(links[linkTag].url); + + acc[linkTag] = ( + // eslint was getting a false positive caused by the dynamic injection + // of content. + // eslint-disable-next-line jsx-a11y/anchor-has-content + <a + href={url} + target={openNewWindow ? "_blank" : ""} + data-metric={links[linkTag].metric} + data-action={action} + data-args={links[linkTag].args} + data-do_not_autoblock={doNotAutoBlock} + data-entrypoint_name={links[linkTag].entrypoint_name} + data-entrypoint_value={links[linkTag].entrypoint_value} + rel="noreferrer" + onClick={sendClick} + /> + ); + return acc; + }, {}); + } + + return null; +} + +/** + * Message wrapper used to sanitize markup and render HTML. + */ +export function RichText(props) { + if (!RICH_TEXT_KEYS.includes(props.localization_id)) { + throw new Error( + `ASRouter: ${props.localization_id} is not a valid rich text property. If you want it to be processed, you need to add it to asrouter/rich-text-strings.js` + ); + } + return ( + <Localized + id={props.localization_id} + {...ALLOWED_TAGS} + {...props.customElements} + {...convertLinks( + props.links, + props.sendClick, + props.doNotAutoBlock, + props.openNewWindow + )} + > + <span>{props.text}</span> + </Localized> + ); +} diff --git a/browser/components/newtab/content-src/asrouter/components/SnippetBase/SnippetBase.jsx b/browser/components/newtab/content-src/asrouter/components/SnippetBase/SnippetBase.jsx new file mode 100644 index 0000000000..fd25337fbf --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/components/SnippetBase/SnippetBase.jsx @@ -0,0 +1,121 @@ +/* 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 SnippetBase extends React.PureComponent { + constructor(props) { + super(props); + this.onBlockClicked = this.onBlockClicked.bind(this); + this.onDismissClicked = this.onDismissClicked.bind(this); + this.setBlockButtonRef = this.setBlockButtonRef.bind(this); + this.onBlockButtonMouseEnter = this.onBlockButtonMouseEnter.bind(this); + this.onBlockButtonMouseLeave = this.onBlockButtonMouseLeave.bind(this); + this.state = { blockButtonHover: false }; + } + + componentDidMount() { + if (this.blockButtonRef) { + this.blockButtonRef.addEventListener( + "mouseenter", + this.onBlockButtonMouseEnter + ); + this.blockButtonRef.addEventListener( + "mouseleave", + this.onBlockButtonMouseLeave + ); + } + } + + componentWillUnmount() { + if (this.blockButtonRef) { + this.blockButtonRef.removeEventListener( + "mouseenter", + this.onBlockButtonMouseEnter + ); + this.blockButtonRef.removeEventListener( + "mouseleave", + this.onBlockButtonMouseLeave + ); + } + } + + setBlockButtonRef(element) { + this.blockButtonRef = element; + } + + onBlockButtonMouseEnter() { + this.setState({ blockButtonHover: true }); + } + + onBlockButtonMouseLeave() { + this.setState({ blockButtonHover: false }); + } + + onBlockClicked() { + if (this.props.provider !== "preview") { + this.props.sendUserActionTelemetry({ + event: "BLOCK", + id: this.props.UISurface, + }); + } + + this.props.onBlock(); + } + + onDismissClicked() { + if (this.props.provider !== "preview") { + this.props.sendUserActionTelemetry({ + event: "DISMISS", + id: this.props.UISurface, + }); + } + + this.props.onDismiss(); + } + + renderDismissButton() { + if (this.props.footerDismiss) { + return ( + <div className="footer"> + <div className="footer-content"> + <button + className="ASRouterButton secondary" + onClick={this.onDismissClicked} + > + {this.props.content.scene2_dismiss_button_text} + </button> + </div> + </div> + ); + } + + const label = this.props.content.block_button_text || "Remove this"; + return ( + <button + className="blockButton" + title={label} + aria-label={label} + onClick={this.onBlockClicked} + ref={this.setBlockButtonRef} + /> + ); + } + + render() { + const { props } = this; + const { blockButtonHover } = this.state; + + const containerClassName = `SnippetBaseContainer${ + props.className ? ` ${props.className}` : "" + }${blockButtonHover ? " active" : ""}`; + + return ( + <div className={containerClassName} style={this.props.textStyle}> + <div className="innerWrapper">{props.children}</div> + {this.renderDismissButton()} + </div> + ); + } +} diff --git a/browser/components/newtab/content-src/asrouter/components/SnippetBase/_SnippetBase.scss b/browser/components/newtab/content-src/asrouter/components/SnippetBase/_SnippetBase.scss new file mode 100644 index 0000000000..2bb1147abc --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/components/SnippetBase/_SnippetBase.scss @@ -0,0 +1,117 @@ +.SnippetBaseContainer { + position: fixed; + z-index: 2; + bottom: 0; + left: 0; + right: 0; + background-color: var(--newtab-background-color-secondary); + color: var(--newtab-text-primary-color); + font-size: 14px; + line-height: 20px; + border-top: 1px solid transparent; + box-shadow: $shadow-secondary; + display: flex; + align-items: center; + + a { + cursor: pointer; + color: var(--newtab-primary-action-background); + + &:hover { + text-decoration: underline; + } + + [lwt-newtab-brighttext] & { + font-weight: bold; + } + } + + input { + &[type='checkbox'] { + margin-inline-start: 0; + } + } + + .innerWrapper { + margin: 0 auto; + display: flex; + align-items: center; + padding: 12px $section-horizontal-padding; + + // This is to account for the block button on smaller screens + padding-inline-end: 36px; + @media (min-width: $break-point-large) { + padding-inline-end: $section-horizontal-padding; + } + + max-width: $wrapper-max-width-large + ($section-horizontal-padding * 2); + @media (min-width: $break-point-widest) { + max-width: $wrapper-max-width-widest + ($section-horizontal-padding * 2); + } + } + + .blockButton { + display: none; + background: none; + border: 0; + position: absolute; + top: 20px; + inset-inline-end: 12px; + height: 16px; + width: 16px; + background-image: url('chrome://global/skin/icons/close.svg'); + -moz-context-properties: fill; + color: inherit; + fill: currentColor; + opacity: 0.5; + margin-top: -8px; + padding: 0; + cursor: pointer; + } + + &:hover .blockButton { + display: block; + } + + .icon { + height: 42px; + width: 42px; + margin-inline-end: 12px; + flex-shrink: 0; + } +} + +.snippets-preview-banner { + font-size: 15px; + line-height: 42px; + color: var(--newtab-text-primary-color); + background: var(--newtab-background-color-secondary); + text-align: center; + position: absolute; + top: 0; + width: 100%; + + span { + vertical-align: middle; + } +} + +// We show snippet icons for both themes and conditionally hide +// based on which theme is currently active +body { + &:not([lwt-newtab-brighttext]) { + .icon-dark-theme, + .icon.icon-dark-theme, + .scene2Icon .icon-dark-theme { + display: none; + } + } + + &[lwt-newtab-brighttext] { + .icon-light-theme, + .icon.icon-light-theme, + .scene2Icon .icon-light-theme { + display: none; + } + } +} diff --git a/browser/components/newtab/content-src/asrouter/docs/cfr_doorhanger_screenshot.png b/browser/components/newtab/content-src/asrouter/docs/cfr_doorhanger_screenshot.png Binary files differnew file mode 100644 index 0000000000..aee3bcf3bd --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/docs/cfr_doorhanger_screenshot.png diff --git a/browser/components/newtab/content-src/asrouter/docs/debugging-docs.md b/browser/components/newtab/content-src/asrouter/docs/debugging-docs.md new file mode 100644 index 0000000000..fedeeced5a --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/docs/debugging-docs.md @@ -0,0 +1,63 @@ +# Using ASRouter Devtools + +## How to enable ASRouter devtools +- In `about:config`, set `browser.newtabpage.activity-stream.asrouter.devtoolsEnabled` to `true` +- Visit `about:newtab#asrouter` to see the devtools. + +## Overview of ASRouter devtools + +![Devtools image](./debugging-guide.png) + +## How to enable/disable a provider + +To enable a provider such as `snippets`, Look at the list of "Message Providers" at the top of the page. Make sure the checkbox is checked next to the provider you want to enable. + +To disable it, uncheck the checkbox. You should see a red label indicating the provider is now disabled. + +## How to see all messages from a provider + +In order to see all active messages for a current provider such as `snippets`, use the drop down selector under the "Messages" section. Select the name of the provider you are interested in. + +The messages on the page should now be filtered to include only the provider you selected. + +## How to test telemetry pings + +To test telemetry pings, complete the the following steps: + +- In about:config, set: + - `browser.newtabpage.activity-stream.telemetry` to `true` + - `browser.ping-centre.log` to `true` +- To view additional debug logs for messaging system or about:welcome, set: + - `messaging-system.log` to `debug` + - `browser.aboutwelcome.log` to `debug` +- Open the Browser Toolbox devtools (Tools > Web Developer > Browser Toolbox) and switch to the console tab. Add a filter for for `activity-stream` to only display relevant pings: + +![Devtools telemetry ping](./telemetry-screenshot.png) + +You should now see pings show up as you view/interact with ASR messages/templates. + +## Snippets debugging + +### How to view preview URLs + +Follow these steps to view preview URLs (e.g. `about:newtab?endpoint=https://gist.githubusercontent.com/piatra/d193ca7e0f513cc19fc6a1d396c214f7/raw/8bcaf9548212e4c613577e839198cc14e7317630/newsletter_snippet.json&theme=dark`) + +You can preview in the two different themes (light and dark) by adding `&theme=dark` or `&theme=light` at the end of the url. + +#### IMPORTANT NOTES +- Links to URLs starting with `about:newtab` cannot be clicked on directly. They must be copy and pasted into the address bar. +- Previews should only be tested in `Firefox 64` and later. +- The endpoint must be HTTPS, the host must be allowed (see testing instructions below) +- Errors are surfaced in the `Console` tab of the `Browser Toolbox` + +#### Testing instructions +- If your endpoint URL has a host name of `snippets-admin.mozilla.org`, you can paste the URL into the address bar view it without any further steps. +- If your endpoint URL starts with some other host name, it must be **allowed**. Open the Browser Toolbox devtools (Tools > Developer > Browser Toolbox) and paste the following code (where `gist.githubusercontent.com` is the hostname of your endpoint URL): +```js +Services.prefs.setStringPref( + "browser.newtab.activity-stream.asrouter.allowHosts", + "[\"gist.githubusercontent.com\"]" +); +``` +- Restart the browser +- You should now be able to paste the URL into the address bar and view it. diff --git a/browser/components/newtab/content-src/asrouter/docs/debugging-guide.png b/browser/components/newtab/content-src/asrouter/docs/debugging-guide.png Binary files differnew file mode 100644 index 0000000000..8616a29ab3 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/docs/debugging-guide.png diff --git a/browser/components/newtab/content-src/asrouter/docs/first-run.md b/browser/components/newtab/content-src/asrouter/docs/first-run.md new file mode 100644 index 0000000000..92c4ed3e43 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/docs/first-run.md @@ -0,0 +1,68 @@ +# Onboarding flow + +Onboarding flow comprises of entire flow users have after Firefox has successfully been installed or upgraded. + +For new users, the first instance of new tab shows relevant messaging on about:welcome. For existing users, an upgrade dialog with release highlights is shown on major release upgrades. + + +## New User Onboarding + +A full-page multistep experience that shows up on first run since Fx80 with `browser.aboutwelcome.enabled` pref as `true`. Setting `browser.aboutwelcome.enabled` to `false` takes user to about:newtab and hides about:welcome. + +### Default values + +Multistage proton onboarding experience is live since Fx89 and its major variations are: + +#### Zero onboarding + +No about:welcome experience is shown (users see about:newtab during first run). + +Testing instructions: Set `browser.aboutwelcome.enabled` to `false` in about:config + +#### Proton + +A full-page multistep experience that shows a large splash screen and several subsequent screens. See [Default experience variations](#default-experience-variations) for more information. + +#### Return to AMO (RTAMO) + +Special custom onboarding experience shown to users when they try to download an addon from addons.mozilla.org but don’t have Firefox installed. This experience allows them to install the addon they were trying to install directly from a button on RTAMO. + +Note that this uses [attribution data](https://docs.google.com/document/d/1zB5zwiyNVOiTD4I3aZ-Wm8KFai9nnWuRHsPg-NW4tcc/edit#heading=h.szk066tfte4n) added to the browser during the download process, which is only currently implemented for Windows. + +Testing instructions: +- Set pref browser.newtabpage.activity-stream.asrouter.devtoolsEnabled as true +- Open about:newtab#devtools +- Click Targeting -> Attribution -> Force Attribution +- Open about:welcome, should display RTAMO page + +### General capabilities +- Run experiments and roll-outs through Nimbus (see [FeatureManifests](https://searchfox.org/mozilla-central/rev/5e955a47c4af398e2a859b34056017764e7a2252/toolkit/components/nimbus/FeatureManifest.js#56)), only windows is supported. FeatureConfig (from prefs or experiments) has higher precedence to defaults. See [Default experience variations](#default-experience-variations) +- AboutWelcomeDefaults methods [getDefaults](https://searchfox.org/mozilla-central/rev/81c32a2ea5605c5cb22bd02d28c362c140b5cfb4/browser/components/newtab/aboutwelcome/lib/AboutWelcomeDefaults.jsm#539) and [prepareContentForReact](https://searchfox.org/mozilla-central/rev/81c32a2ea5605c5cb22bd02d28c362c140b5cfb4/browser/components/newtab/aboutwelcome/lib/AboutWelcomeDefaults.jsm#566) have dynamic rules which are applied to both experiments and default UI before content is shown to user. +- about:welcome only shows up for users who download Firefox Beta or release (currently not enabled on Nightly) +- [Enterprise builds](https://searchfox.org/mozilla-central/rev/5e955a47c4af398e2a859b34056017764e7a2252/browser/components/enterprisepolicies/Policies.jsm#1385) can turn off about:welcome by setting the browser.aboutwelcome.enabled preference to false. + +### Default experience variations +In order of precedence: +- Has AMO attribution + - Return to AMO +- Experiments +- Defaults + - Proton default content with below screens + - Welcome Screen with option to 'Pin Firefox', 'Set default' or 'Get Started' + - Import screen allows user to import password, bookmarks and browsing history from previous browser. + - Set a theme lets users personalize Firefox with a theme. + +## Upgrade Dialog +Upgrade Dialog was first introduced in Fx89 with MR1 release. It replaces whatsnew tab with an upgrade modal explaining proton changes, setting Firefox as default and/or pinning, and allowing theme change. + +### Feature Details: +- Hides whatsnew tab on release channel when Upgrade Modal is shown +- Modal dialog appears on major version upgrade to 89 for MR1 + - It’s a window modal preventing access to tabs and other toolbar UI +- Support desired content and actions on each screen. For MR1 initial screen explains proton changes, highlight option to set Firefox as default and pin. Subsequent screen allows theme changes. + +### Testing Instructions: +- In about:config, set: + - `browser.startup.homepage_override.mstone` to `88.0` . The dialog only shows after it detects a major upgrade and need to set to 88 to trigger MR1 upgrade dialog. + - Ensure pref `browser.startup.upgradeDialog.version` is empty. After the dialog shows, `browser.startup.upgradeDialog.version` remembers what version of the dialog to avoid reshowing. +- Restart Firefox diff --git a/browser/components/newtab/content-src/asrouter/docs/index.rst b/browser/components/newtab/content-src/asrouter/docs/index.rst new file mode 100644 index 0000000000..58241ef0d7 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/docs/index.rst @@ -0,0 +1,104 @@ +================ +Messaging System +================ + +Vision +------ +Firefox must be an opinionated user agent that keeps folks safe, informed and +effective while browsing the Web. In order to have an opinion, Firefox must +have a voice. + +That voice will **respect the user’s attention** while surfacing contextually +relevant and timely information tailored to their individual needs and choices. + +What does Messaging System support? +----------------------------------- +There are several key windows of opportunity, such as the first-run activation +phase or coordinated feature releases, where Firefox engages with users. + +The Firefox Messaging System supports this engagement by targeting messages +exactly to the users who need to see them and enables the development of new +user messages that can be easily tested and deployed. It offers standard +mechanisms to measure user engagement and to perform user messaging experiments +with reduced effort across engineering teams and a faster delivery cycle from +ideation to analysis of results. + +This translates to **users seeing fewer and more relevant in-product +messages**, while supporting fast delivery, experimentation, and protection of +our users time and attention. + +Messaging System Overview +------------------------- +At the core of the Firefox Messaging System is the Messaging System Router +(called ASRouter for historical reasons). The router is a generalized Firefox +component and set of conventions that provides: + +* Flexible and configurable routing of local or remote Messages to UI + Templates. This allows new message campaigns to be started and controlled + on or off-trains +* Traffic Cop message sequencing and intermediation to prevent multiple + messages being concurrently shown +* Programmable message targeting language to show the right message to the + right user at the right time +* A template library of reusable Message and Notification UIs +* Full compatibility with Normandy pref-flip experiments +* Generalized and privacy conscious event telemetry +* Flexible Frequency Capping to mitigate user message fatigue +* Localized off train Messages +* Powerful development/debugging/QA tools on about:newtab#devtools + +Message Routing +--------------- +.. image:: ./message-routing-overview.png + :align: center + :alt: Message Routing Overview + +The Firefox Messaging System implements a separation-of-concerns pattern for +Messages, UI Templates, and Timing/Targeting mechanisms. This allows us to +maintain a high standard of security and quality while still allowing for +maximum flexibility around content creation. + + +UI Templates +------------ +We have built a library of reusable Notification and Message interfaces which +land in the Firefox codebase and ride the trains. These templates have a +defined schema according to the available design components (e.g. titles, text, +icons) and access to a set of enhanced user actions such as triggering URLs, +launching menus, or installing addons, which can be attached to interactive +elements (such as buttons). + +Current templates include\: + +* Moments Page - appears on start-up as a full content page +* Contextual Feature Recommendation - highlighted word in the Location Bar + that, if clicked, drops down a panel with information about a feature + relevant to that user at that time +* About Welcome - shown on startup for new users in about:welcome content page +* Snippets - short messages that appear on New Tab Page to highlight products, + features and initiatives +* Infobars - Shown at the top of browser content area these can be per tab + (switching tabs hides it) or global (persistent across tabs) +* Spotlight - This is a window level modal, all other interactions are prevented, + the user is given a primary and a secondary button to interact with the modal. +* PrivateBrowsing - Message shown inside about:PrivateBrowsing content page + +Detailed Docs +------------- + +* Read more about `trigger listeners and user action schemas`__. + +.. __: /toolkit/components/messaging-system/docs + +.. In theory, we ought to be able to use the :glob: directive here to +.. automatically generate the list below. For unknown reasons, however, +.. `mach doc` _sometimes_ gets confused and refuses to find patterns like +.. `*.md`. +.. toctree:: + :maxdepth: 2 + + simple-cfr-template + debugging-docs + first-run + targeting-attributes + targeting-guide diff --git a/browser/components/newtab/content-src/asrouter/docs/message-routing-overview.png b/browser/components/newtab/content-src/asrouter/docs/message-routing-overview.png Binary files differnew file mode 100644 index 0000000000..0ec2ec3c14 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/docs/message-routing-overview.png diff --git a/browser/components/newtab/content-src/asrouter/docs/simple-cfr-template.rst b/browser/components/newtab/content-src/asrouter/docs/simple-cfr-template.rst new file mode 100644 index 0000000000..a1edf4cc8a --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/docs/simple-cfr-template.rst @@ -0,0 +1,37 @@ +Simple CFR Template +-------------------- + +The “Simple CFR Template” is a two-stage UI (a chiclet notification and a door-hanger) +that shows up on a configurable `trigger condition`__, such as when the user visits a +particular web page. + +.. __: /toolkit/components/messaging-system/docs/TriggerActionSchemas + +Warning! Before reading, you should consider whether a `Messaging Experiment is relevant for your needs`__. + +.. __: https://docs.google.com/document/d/1S45a_nFn8QRM8gvsxCM6HHROrIQlQQl6fUlJ2j63PGI/edit + +.. image:: ./cfr_doorhanger_screenshot.png + :align: center + :alt: Simple CFR Template 2 stage + +Doorhanger Configuration +========================= + +Stage 1 – Chiclet +++++++++++++++++++ + +* **chiclet_label**: The text that shows up in the chiclet. 20 characters max. +* **chiclet_color**: The background color of the chiclet as a HEX code. + + +Stage 2 – Door-hanger +++++++++++++++++++++++ + +* **title**: Title text at the top of the door hanger. +* **body**: A longer paragraph of text. +* **icon**: An image (please provide a URL or the image file up to 96x96px). +* **primary_button_label**: The label of the button. +* **primary_button_action**: The special action triggered by clicking on the button. Choose any of the available `button actions`__. Common examples include opening a section of about:preferences, or opening a URL. + +.. __: /toolkit/components/messaging-system/docs/SpecialMessageActionSchemas diff --git a/browser/components/newtab/content-src/asrouter/docs/targeting-attributes.md b/browser/components/newtab/content-src/asrouter/docs/targeting-attributes.md new file mode 100644 index 0000000000..ecce07477f --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/docs/targeting-attributes.md @@ -0,0 +1,887 @@ +# Targeting attributes + +When you create ASRouter messages such as snippets, contextual feature recommendations, or onboarding cards, you may choose to include **targeting information** with those messages. + +Targeting information must be captured in [an expression](./targeting-guide.md) that has access to the following attributes. You may combine and compare any of these attributes as needed. + +Please note that some targeting attributes require stricter controls on the telemetry than can be colleted, so when in doubt, ask for review. + +## Available attributes + +* [addonsInfo](#addonsinfo) +* [attributionData](#attributiondata) +* [browserSettings](#browsersettings) +* [currentDate](#currentdate) +* [devToolsOpenedCount](#devtoolsopenedcount) +* [isDefaultBrowser](#isdefaultbrowser) +* [firefoxVersion](#firefoxversion) +* [locale](#locale) +* [localeLanguageCode](#localelanguagecode) +* [needsUpdate](#needsupdate) +* [pinnedSites](#pinnedsites) +* [previousSessionEnd](#previoussessionend) +* [profileAgeCreated](#profileagecreated) +* [profileAgeReset](#profileagereset) +* [providerCohorts](#providercohorts) +* [region](#region) +* [searchEngines](#searchengines) +* [sync](#sync) +* [topFrecentSites](#topfrecentsites) +* [totalBookmarksCount](#totalbookmarkscount) +* [usesFirefoxSync](#usesfirefoxsync) +* [isFxAEnabled](#isFxAEnabled) +* [isFxASignedIn](#isFxASignedIn) +* [xpinstallEnabled](#xpinstallEnabled) +* [hasPinnedTabs](#haspinnedtabs) +* [hasAccessedFxAPanel](#hasaccessedfxapanel) +* [isWhatsNewPanelEnabled](#iswhatsnewpanelenabled) +* [totalBlockedCount](#totalblockedcount) +* [recentBookmarks](#recentbookmarks) +* [userPrefs](#userprefs) +* [attachedFxAOAuthClients](#attachedfxaoauthclients) +* [platformName](#platformname) +* [messageImpressions](#messageimpressions) +* [blockedCountByType](#blockedcountbytype) +* [isChinaRepack](#ischinarepack) +* [userId](#userid) +* [profileRestartCount](#profilerestartcount) +* [homePageSettings](#homepagesettings) +* [newtabSettings](#newtabsettings) +* [isFissionExperimentEnabled](#isfissionexperimentenabled) +* [activeNotifications](#activenotifications) +* [isMajorUpgrade](#ismajorupgrade) +* [hasActiveEnterprisePolicies](#hasactiveenterprisepolicies) +* [userMonthlyActivity](#usermonthlyactivity) +* [doesAppNeedPin](#doesappneedpin) +* [doesAppNeedPrivatePin](#doesappneedprivatepin) +* [isBackgroundTaskMode](#isbackgroundtaskmode) +* [backgroundTaskName](#backgroundtaskname) +* [userPrefersReducedMotion](#userPrefersReducedMotion) +* [colorwaysActive](#colorwaysActive) +* [userEnabledActiveColorway](#userEnabledActiveColorway) +* [inMr2022Holdback](#inMr2022Holdback) +* [distributionId](#distributionId) +* [fxViewButtonAreaType](#fxViewButtonAreaType) + +## Detailed usage + +### `addonsInfo` +Provides information about the add-ons the user has installed. + +Note that the `name`, `userDisabled`, and `installDate` is only available if `isFullData` is `true` (this is usually not the case right at start-up). + +**Due to an existing bug, `userDisabled` is not currently available** + +#### Examples +* Has the user installed the unicorn addon? +```java +addonsInfo.addons["unicornaddon@mozilla.org"] +``` + +* Has the user installed and disabled the unicorn addon? +```java +addonsInfo.isFullData && addonsInfo.addons["unicornaddon@mozilla.org"].userDisabled +``` + +#### Definition +```ts +declare const addonsInfo: Promise<AddonsInfoResponse>; +interface AddonsInfoResponse { + // Does this include extra information requiring I/O? + isFullData: boolean; + // addonId should be something like activity-stream@mozilla.org + [addonId: string]: { + // Version of the add-on + version: string; + // (string) e.g. "extension" + type: AddonType; + // Version of the add-on + isSystem: boolean; + // Is the add-on a webextension? + isWebExtension: boolean; + // The name of the add-on + name: string; + // Is the add-on disabled? + // CURRENTLY UNAVAILABLE due to an outstanding bug + userDisabled: boolean; + // When was it installed? e.g. "2018-03-10T03:41:06.000Z" + installDate: string; + }; +} +``` +### `attributionData` + +An object containing information on exactly how Firefox was downloaded + +#### Examples +* Was the browser installed via the `"back_to_school"` campaign? +```java +attributionData && attributionData.campaign == "back_to_school" +``` + +#### Definition +```ts +declare const attributionData: AttributionCode; +interface AttributionCode { + // Descriptor for where the download started from + campaign: string, + // A source, like addons.mozilla.org, or google.com + source: string, + // The medium for the download, like if this was referral + medium: string, + // Additional content, like an addonID for instance + content: string +} +``` + +### `browserSettings` + +* `update`, which has information about Firefox update channel + +#### Examples + +* Is updating enabled? +```java +browserSettings.update.enabled +``` + +* Is beta channel? +```js +browserSettings.update.channel == 'beta' +``` + +#### Definition + +```ts +declare const browserSettings: { + attribution: undefined | { + // Referring partner domain, when install happens via a known partner + // e.g. google.com + source: string; + // category of the source, such as "organic" for a search engine + // e.g. organic + medium: string; + // identifier of the particular campaign that led to the download of the product + // e.g. back_to_school + campaign: string; + // identifier to indicate the particular link within a campaign + // e.g. https://mozilla.org/some-page + content: string; + }, + update: { + // Is auto-downloading enabled? + autoDownload: boolean; + // What release channel, e.g. "nightly" + channel: string; + // Is updating enabled? + enabled: boolean; + } +} +``` + +### `currentDate` + +The current date at the moment message targeting is checked. + +#### Examples +* Is the current date after Oct 3, 2018? +```java +currentDate > "Wed Oct 03 2018 00:00:00"|date +``` + +#### Definition + +```ts +declare const currentDate; ECMA262DateString; +// ECMA262DateString = Date.toString() +type ECMA262DateString = string; +``` + +### `devToolsOpenedCount` +Number of usages of the web console. + +#### Examples +* Has the user opened the web console more than 10 times? +```java +devToolsOpenedCount > 10 +``` + +#### Definition +```ts +declare const devToolsOpenedCount: number; +``` + +### `isDefaultBrowser` + +Is Firefox the user's default browser? + +#### Definition + +```ts +declare const isDefaultBrowser: boolean; +``` + +### `firefoxVersion` + +The major Firefox version of the browser + +#### Examples +* Is the version of the browser greater than 63? +```java +firefoxVersion > 63 +``` + +#### Definition + +```ts +declare const firefoxVersion: number; +``` + +### `locale` +The current locale of the browser including country code, e.g. `en-US`. + +#### Examples +* Is the locale of the browser either English (US) or German (Germany)? +```java +locale in ["en-US", "de-DE"] +``` + +#### Definition +```ts +declare const locale: string; +``` + +### `localeLanguageCode` +The current locale of the browser NOT including country code, e.g. `en`. +This is useful for matching all countries of a particular language. + +#### Examples +* Is the locale of the browser any English locale? +```java +localeLanguageCode == "en" +``` + +#### Definition +```ts +declare const localeLanguageCode: string; +``` + +### `needsUpdate` + +Does the client have the latest available version installed + +```ts +declare const needsUpdate: boolean; +``` + +### `pinnedSites` +The sites (including search shortcuts) that are pinned on a user's new tab page. + +#### Examples +* Has the user pinned any site on `foo.com`? +```java +"foo.com" in pinnedSites|mapToProperty("host") +``` + +* Does the user have a pinned `duckduckgo.com` search shortcut? +```java +"duckduckgo.com" in pinnedSites[.searchTopSite == true]|mapToProperty("host") +``` + +#### Definition +```ts +interface PinnedSite { + // e.g. https://foo.mozilla.com/foo/bar + url: string; + // e.g. foo.mozilla.com + host: string; + // is the pin a search shortcut? + searchTopSite: boolean; +} +declare const pinnedSites: Array<PinnedSite> +``` + +### `previousSessionEnd` + +Timestamp of the previously closed session. + +#### Definition +```ts +declare const previousSessionEnd: UnixEpochNumber; +// UnixEpochNumber is UNIX Epoch timestamp, e.g. 1522843725924 +type UnixEpochNumber = number; +``` + +### `profileAgeCreated` + +The date the profile was created as a UNIX Epoch timestamp. + +#### Definition + +```ts +declare const profileAgeCreated: UnixEpochNumber; +// UnixEpochNumber is UNIX Epoch timestamp, e.g. 1522843725924 +type UnixEpochNumber = number; +``` + +### `profileAgeReset` + +The date the profile was reset as a UNIX Epoch timestamp (if it was reset). + +#### Examples +* Was the profile never reset? +```java +!profileAgeReset +``` + +#### Definition +```ts +// profileAgeReset can be undefined if the profile was never reset +// UnixEpochNumber is number, e.g. 1522843725924 +declare const profileAgeReset: undefined | UnixEpochNumber; +// UnixEpochNumber is UNIX Epoch timestamp, e.g. 1522843725924 +type UnixEpochNumber = number; +``` + +### `providerCohorts` + +Information about cohort settings (from prefs, including shield studies) for each provider. + +#### Examples +* Is the user in the "foo_test" cohort for snippets? +```java +providerCohorts.snippets == "foo_test" +``` + +#### Definition + +```ts +declare const providerCohorts: { + [providerId: string]: string; +} +``` + +### `region` + +Country code retrieved from `location.services.mozilla.com`. Can be `""` if request did not finish or encountered an error. + +#### Examples +* Is the user in Canada? +```java +region == "CA" +``` + +#### Definition + +```ts +declare const region: string; +``` + +### `searchEngines` + +Information about the current and available search engines. + +#### Examples +* Is the current default search engine set to google? +```java +searchEngines.current == "google" +``` + +#### Definition + +```ts +declare const searchEngines: Promise<SearchEnginesResponse>; +interface SearchEnginesResponse: { + current: SearchEngineId; + installed: Array<SearchEngineId>; +} +// This is an identifier for a search engine such as "google" or "amazondotcom" +type SearchEngineId = string; +``` + +### `sync` + +Information about synced devices. + +#### Examples +* Is at least 1 mobile device synced to this profile? +```java +sync.mobileDevices > 0 +``` + +#### Definition + +```ts +declare const sync: { + desktopDevices: number; + mobileDevices: number; + totalDevices: number; +} +``` + +### `topFrecentSites` + +Information about the browser's top 25 frecent sites. + +**Please note this is a restricted targeting property that influences what telemetry is allowed to be collected may not be used without review** + + +#### Examples +* Is mozilla.com in the user's top frecent sites with a frececy greater than 400? +```java +"mozilla.com" in topFrecentSites[.frecency >= 400]|mapToProperty("host") +``` + +#### Definition +```ts +declare const topFrecentSites: Promise<Array<TopSite>> +interface TopSite { + // e.g. https://foo.mozilla.com/foo/bar + url: string; + // e.g. foo.mozilla.com + host: string; + frecency: number; + lastVisitDate: UnixEpochNumber; +} +// UnixEpochNumber is UNIX Epoch timestamp, e.g. 1522843725924 +type UnixEpochNumber = number; +``` + +### `totalBookmarksCount` + +Total number of bookmarks. + +#### Definition + +```ts +declare const totalBookmarksCount: number; +``` + +### `usesFirefoxSync` + +Does the user use Firefox sync? + +#### Definition + +```ts +declare const usesFirefoxSync: boolean; +``` + +### `isFxAEnabled` + +Does the user have Firefox sync enabled? The service could potentially be turned off [for enterprise builds](https://searchfox.org/mozilla-central/rev/b59a99943de4dd314bae4e44ab43ce7687ccbbec/browser/components/enterprisepolicies/Policies.jsm#327). + +#### Definition + +```ts +declare const isFxAEnabled: boolean; +``` + +### `isFxASignedIn` + +Is the user signed in to a Firefox Account? + +#### Definition + +```ts +declare const isFxASignedIn: Promise<boolean> +``` + +### `xpinstallEnabled` + +Pref used by system administrators to disallow add-ons from installed altogether. + +#### Definition + +```ts +declare const xpinstallEnabled: boolean; +``` + +### `hasPinnedTabs` + +Does the user have any pinned tabs in any windows. + +#### Definition + +```ts +declare const hasPinnedTabs: boolean; +``` + +### `hasAccessedFxAPanel` + +Boolean pref that gets set the first time the user opens the FxA toolbar panel + +#### Definition + +```ts +declare const hasAccessedFxAPanel: boolean; +``` + +### `isWhatsNewPanelEnabled` + +Boolean pref that controls if the What's New panel feature is enabled + +#### Definition + +```ts +declare const isWhatsNewPanelEnabled: boolean; +``` + +### `totalBlockedCount` + +Total number of events from the content blocking database + +#### Definition + +```ts +declare const totalBlockedCount: number; +``` + +### `recentBookmarks` + +An array of GUIDs of recent bookmarks as provided by [`NewTabUtils.getRecentBookmarks`](https://searchfox.org/mozilla-central/rev/c5c002f81f08a73e04868e0c2bf0eb113f200b03/toolkit/modules/NewTabUtils.sys.mjs#1059) + +#### Definition + +```ts +interface Bookmark { + bookmarkGuid: string; + url: string; + title: string; + ... +} +declare const recentBookmarks: Array<Bookmark> +``` + +### `userPrefs` + +Information about user facing prefs configurable from `about:preferences`. + +#### Examples +```java +userPrefs.cfrFeatures == false +``` + +#### Definition + +```ts +declare const userPrefs: { + cfrFeatures: boolean; + cfrAddons: boolean; + snippets: boolean; +} +``` + +### `attachedFxAOAuthClients` + +Information about connected services associated with the FxA Account. +Return an empty array if no account is found or an error occurs. + +#### Definition + +``` +interface OAuthClient { + // OAuth client_id of the service + // https://docs.telemetry.mozilla.org/datasets/fxa_metrics/attribution.html#service-attribution + id: string; + lastAccessedDaysAgo: number; +} + +declare const attachedFxAOAuthClients: Promise<OAuthClient[]> +``` + +#### Examples +```javascript +{ + id: "7377719276ad44ee", + name: "Pocket", + lastAccessTime: 1513599164000 +} +``` + +### `platformName` + +[Platform information](https://searchfox.org/mozilla-central/rev/c5c002f81f08a73e04868e0c2bf0eb113f200b03/toolkit/modules/AppConstants.sys.mjs#153). + +#### Definition + +``` +declare const platformName = "linux" | "win" | "macosx" | "android" | "other"; +``` + +### `messageImpressions` + +Dictionary that maps message ids to impression timestamps. Timestamps are stored in +consecutive order. Can be used to detect first impression of a message, number of +impressions. Can be used in targeting to show a message if another message has been +seen. +Impressions are used for frequency capping so we only store them if the message has +`frequency` configured. +Impressions for badges might not work as expected: we add a badge for every opened +window so the number of impressions stored might be higher than expected. Additionally +not all badges have `frequency` cap so `messageImpressions` might not be defined. +Badge impressions should not be used for targeting. + +#### Definition + +``` +declare const messageImpressions: { [key: string]: Array<UnixEpochNumber> }; +``` + +### `blockedCountByType` + +Returns a breakdown by category of all blocked resources in the past 42 days. + +#### Definition + +``` +declare const messageImpressions: { [key: string]: number }; +``` + +#### Examples + +```javascript +Object { + trackerCount: 0, + cookieCount: 34, + cryptominerCount: 0, + fingerprinterCount: 3, + socialCount: 2 +} +``` + +### `isChinaRepack` + +Does the user use [the partner repack distributed by Mozilla Online](https://github.com/mozilla-partners/mozillaonline), +a wholly owned subsidiary of the Mozilla Corporation that operates in China. + +#### Definition + +```ts +declare const isChinaRepack: boolean; +``` + +### `userId` + +A unique user id generated by Normandy (note that this is not clientId). + +#### Definition + +```ts +declare const userId: string; +``` + +### `profileRestartCount` + +A session counter that shows how many times the browser was started. +More info about the details in [the telemetry docs](https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/concepts/sessions.html). + +#### Definition + +```ts +declare const profileRestartCount: number; +``` + +### `homePageSettings` + +An object reflecting the current settings of the browser home page (about:home) + +#### Definition + +```ts +declare const homePageSettings: { + isDefault: boolean; + isLocked: boolean; + isWebExt: boolean; + isCustomUrl: boolean; + urls: Array<URL>; +} + +interface URL { + url: string; + host: string; +} +``` + +#### Examples + +* Default about:home +```javascript +Object { + isDefault: true, + isLocked: false, + isCustomUrl: false, + isWebExt: false, + urls: [ + { url: "about:home", host: "" } + ], +} +``` + +* Default about:home with locked preference +```javascript +Object { + isDefault: true, + isLocked: true, + isCustomUrl: false, + isWebExt: false, + urls: [ + { url: "about:home", host: "" } + ], +} +``` + +* Custom URL +```javascript +Object { + isDefault: false, + isLocked: false, + isCustomUrl: true, + isWebExt: false, + urls: [ + { url: "https://www.google.com", host: "google.com" } + ], +} +``` + +* Custom URLs +```javascript +Object { + isDefault: false, + isLocked: false, + isCustomUrl: true, + isWebExt: false, + urls: [ + { url: "https://www.google.com", host: "google.com" }, + { url: "https://www.youtube.com", host: "youtube.com" } + ], +} +``` + +* Web extension +```javascript +Object { + isDefault: false, + isLocked: false, + isCustomUrl: false, + isWebExt: true, + urls: [ + { url: "moz-extension://123dsa43213acklncd/home.html", host: "" } + ], +} +``` + +### `newtabSettings` + +An object reflecting the current settings of the browser newtab page (about:newtab) + +#### Definition + +```ts +declare const newtabSettings: { + isDefault: boolean; + isWebExt: boolean; + isCustomUrl: boolean; + url: string; + host: string; +} +``` + +#### Examples + +* Default about:newtab +```javascript +Object { + isDefault: true, + isCustomUrl: false, + isWebExt: false, + url: "about:newtab", + host: "", +} +``` + +* Custom URL +```javascript +Object { + isDefault: false, + isCustomUrl: true, + isWebExt: false, + url: "https://www.google.com", + host: "google.com", +} +``` + +* Web extension +```javascript +Object { + isDefault: false, + isCustomUrl: false, + isWebExt: true, + url: "moz-extension://123dsa43213acklncd/home.html", + host: "", +} +``` + +### `isFissionExperimentEnabled` + +A boolean. `true` if we're running Fission experiment, `false` otherwise. + +### `activeNotifications` + +True when an infobar style message is displayed or when the awesomebar is +expanded to show a message (for example onboarding tips). + +### `isMajorUpgrade` + +A boolean. `true` if the browser just updated to a new major version. + +### `hasActiveEnterprisePolicies` + +A boolean. `true` if any Enterprise Policies are active. + +### `userMonthlyActivity` + +Returns an array of entries in the form `[int, unixTimestamp]` for each day of +user activity where the first entry is the total urls visited for that day. + +### `doesAppNeedPin` + +Checks if Firefox app can be and isn't pinned to OS taskbar/dock. + +### `doesAppNeedPrivatePin` + +Checks if Firefox Private Browsing Mode can be and isn't pinned to OS taskbar/dock. Currently this only works on certain Windows versions. + +### `isBackgroundTaskMode` + +Checks if this invocation is running in background task mode. + +### `backgroundTaskName` + +A non-empty string task name if this invocation is running in background task +mode, or `null` if this invocation is not running in background task mode. + +### `userPrefersReducedMotion` + +Checks if user prefers reduced motion as indicated by the value of a media query for `prefers-reduced-motion`. + +### `colorwaysActive` + +A boolean. `true` when there are Colorways available. + +### `userEnabledActiveColorway` + +A boolean. `true` when user has an active Colorway theme enabled. + +### `inMr2022Holdback` + +A boolean. `true` when the user is in the Major Release 2022 holdback study. + +### `distributionId` + +A string containing the id of the distribution, or the empty string if there +is no distribution associated with the build. + +### `fxViewButtonAreaType` + +A string of the name of the container where the Firefox View button is shown, null if the button has been removed. diff --git a/browser/components/newtab/content-src/asrouter/docs/targeting-guide.md b/browser/components/newtab/content-src/asrouter/docs/targeting-guide.md new file mode 100644 index 0000000000..3172cece81 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/docs/targeting-guide.md @@ -0,0 +1,37 @@ +# Guide to targeting with JEXL + +For a more in-depth explanation of JEXL syntax you can read the [Normady project docs](https://mozilla.github.io/normandy/user/filters.html?highlight=jexl). + +## How to write JEXL targeting expressions +A message needs to contain the `targeting` property (JEXL string) which is evaluated against the provided attributes. +Examples: + +```javascript +{ + "id": "7864", + "content": {...}, + // simple equality check + "targeting": "usesFirefoxSync == true" +} + +{ + "id": "7865", + "content": {...}, + // using JEXL transforms and combining two attributes + "targeting": "usesFirefoxSync == true && profileAgeCreated > '2018-01-07'|date" +} + +{ + "id": "7866", + "content": {...}, + // targeting addon information + "targeting": "addonsInfo.addons['activity-stream@mozilla.org'].name == 'Activity Stream'" +} + +{ + "id": "7866", + "content": {...}, + // targeting based on time + "targeting": "currentDate > '2018-08-08'|date" +} +``` diff --git a/browser/components/newtab/content-src/asrouter/docs/telemetry-screenshot.png b/browser/components/newtab/content-src/asrouter/docs/telemetry-screenshot.png Binary files differnew file mode 100644 index 0000000000..b27b4ab958 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/docs/telemetry-screenshot.png diff --git a/browser/components/newtab/content-src/asrouter/rich-text-strings.js b/browser/components/newtab/content-src/asrouter/rich-text-strings.js new file mode 100644 index 0000000000..6a52732ad1 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/rich-text-strings.js @@ -0,0 +1,44 @@ +/* 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 { FluentBundle } from "fluent"; + +/** + * Properties that allow rich text MUST be added to this list. + * key: the localization_id that should be used + * value: a property or array of properties on the message.content object + */ +const RICH_TEXT_CONFIG = { + text: ["text", "scene1_text"], + success_text: "success_text", + error_text: "error_text", + scene2_text: "scene2_text", + amo_html: "amo_html", + privacy_html: "scene2_privacy_html", + disclaimer_html: "scene2_disclaimer_html", +}; + +export const RICH_TEXT_KEYS = Object.keys(RICH_TEXT_CONFIG); + +/** + * Generates an array of messages suitable for fluent's localization provider + * including all needed strings for rich text. + * @param {object} content A .content object from an ASR message (i.e. message.content) + * @returns {FluentBundle[]} A array containing the fluent message context + */ +export function generateBundles(content) { + const bundle = new FluentBundle("en-US"); + + RICH_TEXT_KEYS.forEach(key => { + const attrs = RICH_TEXT_CONFIG[key]; + const attrsToTry = Array.isArray(attrs) ? [...attrs] : [attrs]; + let string = ""; + while (!string && attrsToTry.length) { + const attr = attrsToTry.pop(); + string = content[attr]; + } + bundle.addMessages(`${key} = ${string}`); + }); + return [bundle]; +} diff --git a/browser/components/newtab/content-src/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json b/browser/components/newtab/content-src/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json new file mode 100644 index 0000000000..9b5e2991d2 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json @@ -0,0 +1,274 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "resource://activity-stream/schemas/BackgroundTaskMessagingExperiment.schema.json", + "title": "Messaging Experiment", + "description": "A Firefox Messaging System message.", + "oneOf": [ + { + "description": "An empty FxMS message.", + "type": "object", + "additionalProperties": false + }, + { + "allOf": [ + { + "$ref": "resource://activity-stream/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/Message" + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": [ + "toast_notification" + ] + } + }, + "required": [ + "template" + ] + }, + "then": { + "$ref": "resource://activity-stream/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/ToastNotification" + } + } + ] + } + ], + "$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": "resource://activity-stream/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/Message" + } + ], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "title": { + "$ref": "resource://activity-stream/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of toast notification title" + }, + "body": { + "$ref": "resource://activity-stream/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" + }, + "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": "resource://activity-stream/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`." + } + }, + "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, + "required": [ + "id", + "content", + "template" + ] + }, + "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": "resource://activity-stream/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/localizedText" + } + ] + } + } +} diff --git a/browser/components/newtab/content-src/asrouter/schemas/FxMSCommon.schema.json b/browser/components/newtab/content-src/asrouter/schemas/FxMSCommon.schema.json new file mode 100644 index 0000000000..0351f0bd98 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/schemas/FxMSCommon.schema.json @@ -0,0 +1,136 @@ +{ + "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, + "required": [ + "id", + "content", + "template" + ] + }, + "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/newtab/content-src/asrouter/schemas/MessagingExperiment.schema.json b/browser/components/newtab/content-src/asrouter/schemas/MessagingExperiment.schema.json new file mode 100644 index 0000000000..4c4c0dc9b0 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/schemas/MessagingExperiment.schema.json @@ -0,0 +1,1733 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "resource://activity-stream/schemas/MessagingExperiment.schema.json", + "title": "Messaging Experiment", + "description": "A Firefox Messaging System message.", + "oneOf": [ + { + "description": "An empty FxMS message.", + "type": "object", + "additionalProperties": false + }, + { + "allOf": [ + { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/Message" + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": [ + "cfr_urlbar_chiclet" + ] + } + }, + "required": [ + "template" + ] + }, + "then": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/CFRUrlbarChiclet" + } + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": [ + "cfr_doorhanger", + "milestone_message" + ] + } + }, + "required": [ + "template" + ] + }, + "then": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/ExtensionDoorhanger" + } + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": [ + "infobar" + ] + } + }, + "required": [ + "template" + ] + }, + "then": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/InfoBar" + } + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": [ + "pb_newtab" + ] + } + }, + "required": [ + "template" + ] + }, + "then": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/NewtabPromoMessage" + } + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": [ + "protections_panel" + ] + } + }, + "required": [ + "template" + ] + }, + "then": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/ProtectionsPanelMessage" + } + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": [ + "spotlight", + "feature_callout" + ] + } + }, + "required": [ + "template" + ] + }, + "then": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/Spotlight" + } + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": [ + "toast_notification" + ] + } + }, + "required": [ + "template" + ] + }, + "then": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/ToastNotification" + } + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": [ + "toolbar_badge" + ] + } + }, + "required": [ + "template" + ] + }, + "then": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/ToolbarBadgeMessage" + } + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": [ + "update_action" + ] + } + }, + "required": [ + "template" + ] + }, + "then": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/UpdateAction" + } + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": [ + "whatsnew_panel_message" + ] + } + }, + "required": [ + "template" + ] + }, + "then": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/WhatsNewMessage" + } + } + ] + } + ], + "$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": "resource://activity-stream/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": "resource://activity-stream/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": "resource://activity-stream/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." + }, + "notification_text": { + "$ref": "resource://activity-stream/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": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "Text for button tooltip used to provide information about the doorhanger." + } + }, + "required": [ + "tooltiptext" + ] + } + }, + "required": [ + "attributes" + ] + }, + { + "$ref": "resource://activity-stream/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": "resource://activity-stream/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": "number", + "minimum": 0, + "maximum": 5, + "description": "Star rating" + }, + "users": { + "type": "integer", + "minimum": 0, + "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": "resource://activity-stream/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": "resource://activity-stream/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": "resource://activity-stream/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.jsm", + "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": "resource://activity-stream/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": "resource://activity-stream/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": "resource://activity-stream/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": "resource://activity-stream/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", + "RALLY", + "VPN", + "PIN", + "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" + ] + }, + "ProtectionsPanelMessage": { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///ProtectionsPanelMessage.schema.json", + "title": "ProtectionsPanelMessage", + "description": "A message shown in the protections panel.", + "allOf": [ + { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/Message" + } + ], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "title": { + "description": "The message title.", + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizableText" + }, + "body": { + "description": "The body of the message.", + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizableText" + }, + "link_text": { + "description": "The text of the call to action link.", + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizableText" + }, + "cta_type": { + "description": "The type of URL open action.", + "type": "string", + "enum": [ + "OPEN_URL", + "OPEN_PROTECTION_REPORT", + "OPEN_ABOUT_PAGE" + ] + }, + "cta_url": { + "description": "The URL to open when the call to action is clicked", + "type": "string", + "format": "moz-url-format" + }, + "cta_where": { + "description": "How to open the cta.", + "type": "string", + "enum": [ + "current", + "tabshifted", + "tab", + "save", + "window" + ] + } + }, + "dependantSchemas": { + "link_text": [ + "cta_type", + "cta_url" + ], + "cta_type": [ + "link_text" + ], + "cta_url": [ + "link_text" + ], + "cta_where": [ + "link_text" + ] + }, + "additionalProperties": false, + "required": [ + "title", + "body" + ] + }, + "template": { + "type": "string", + "const": "protections_panel" + }, + "trigger": { + "description": "An action to trigger potentially showing the message. The action ID `protectionsPanelOpen` is required.", + "const": { + "id": "protectionsPanelOpen" + } + } + }, + "required": [ + "content", + "template", + "trigger" + ], + "additionalProperties": true + }, + "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": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/Message" + } + ], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "template": { + "type": "string", + "description": "Specify the layout template for the Spotlight", + "enum": [ + "logo-and-content", + "multistage" + ] + }, + "backdrop": { + "type": "string", + "description": "Background css behind modal content" + }, + "logoImageURL": { + "type": "string", + "format": "uri", + "description": "(Deprecated by logo.imageURL)" + }, + "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 + }, + "body": { + "type": "object", + "properties": { + "title": { + "type": "object", + "properties": { + "label": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "The title shown in the Spotlight message" + } + }, + "required": [ + "label" + ] + }, + "text": { + "type": "object", + "properties": { + "label": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "The content shown in the Spotlight message" + } + }, + "required": [ + "label" + ] + }, + "primary": { + "type": "object", + "properties": { + "label": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "The label for the primary button" + }, + "action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Action dispatched by the button." + }, + "data": { + "type": "object" + } + }, + "required": [ + "type" + ], + "additionalProperties": true + } + }, + "required": [ + "label", + "action" + ] + }, + "secondary": { + "type": "object", + "properties": { + "label": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "The label for the secondary button" + } + }, + "required": [ + "label", + "action" + ] + } + }, + "additionalProperties": true, + "required": [ + "title", + "text", + "primary", + "secondary" + ] + }, + "extra": { + "type": "object", + "properties": { + "expanded": { + "type": "object", + "properties": { + "label": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "The label for the secondary button" + } + }, + "required": [ + "label" + ] + } + }, + "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, + "if": { + "properties": { + "logoImageURL": { + "type": "null" + } + } + }, + "then": { + "properties": { + "logo": { + "oneOf": [ + { + "required": [ + "imageURL" + ] + }, + { + "required": [ + "imageId" + ] + } + ] + } + } + }, + "required": [ + "template" + ] + }, + "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": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/Message" + } + ], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "title": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of toast notification title" + }, + "body": { + "$ref": "resource://activity-stream/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" + }, + "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": "resource://activity-stream/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`." + } + }, + "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": "resource://activity-stream/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": "resource://activity-stream/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": "resource://activity-stream/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": "resource://activity-stream/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": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of What's New message title" + }, + "subtitle": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of What's New message subtitle" + }, + "body": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of What's New message body" + }, + "link_text": { + "$ref": "resource://activity-stream/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": "resource://activity-stream/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", + "protections_panel", + "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, + "required": [ + "id", + "content", + "template" + ] + }, + "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": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizedText" + } + ] + } + } +} diff --git a/browser/components/newtab/content-src/asrouter/schemas/extract-test-corpus.js b/browser/components/newtab/content-src/asrouter/schemas/extract-test-corpus.js new file mode 100644 index 0000000000..123a3b13e2 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/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.import( + "resource://activity-stream/lib/CFRMessageProvider.jsm" +); +const { OnboardingMessageProvider } = ChromeUtils.import( + "resource://activity-stream/lib/OnboardingMessageProvider.jsm" +); +const { PanelTestProvider } = ChromeUtils.import( + "resource://activity-stream/lib/PanelTestProvider.jsm" +); + +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/newtab/content-src/asrouter/schemas/make-schemas.py b/browser/components/newtab/content-src/asrouter/schemas/make-schemas.py new file mode 100755 index 0000000000..91e6c2a9db --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/schemas/make-schemas.py @@ -0,0 +1,456 @@ +#!/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="resource://activity-stream/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" + ), + "ProtectionsPanelMessage": ( + SCHEMA_DIR / "OnboardingMessage" / "ProtectionsPanelMessage.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, + # These are generated via extract-test-corpus.js + test_corpus={ + "CFRMessageProvider": Path("corpus", "CFRMessageProvider.messages.json"), + "OnboardingMessageProvider": Path( + "corpus", "OnboardingMessageProvider.messages.json" + ), + "PanelTestProvider": Path("corpus", "PanelTestProvider.messages.json"), + }, + ), + SchemaDefinition( + schema_id=( + "resource://activity-stream/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 + + # 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 empty message (i.e., a completely empty object), which is the + # equivalent of an experiment branch not providing a message; or + # - An object that contains a template field + "oneOf": [ + { + "description": "An empty FxMS message.", + "type": "object", + "additionalProperties": False, + }, + { + "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": 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) + + 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` ?" + ) + raise new_exc from e + + raise e + + for message in messages: + template = message["template"] + msg_id = message["id"] + + 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/newtab/content-src/asrouter/schemas/message-format.md b/browser/components/newtab/content-src/asrouter/schemas/message-format.md new file mode 100644 index 0000000000..debcce0572 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/schemas/message-format.md @@ -0,0 +1,101 @@ +## 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 | [See example](https://github.com/mozilla/activity-stream/blob/33669c67c2269078a6d3d6d324fb48175d98f634/system-addon/content-src/message-center/templates/SimpleSnippet.jsx) +`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 +{ + id: "ONBOARDING_1", + template: "simple_snippet", + 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: "usesFirefoxSync && !addonsInfo.addons['activity-stream@mozilla.org']", + frequency: { + lifetime: 20, + custom: [{period: 86400000, cap: 5}, {period: 3600000, cap: 1}] + } +} +``` + +### 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 the snippet: `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/newtab/content-src/asrouter/schemas/message-group.schema.json b/browser/components/newtab/content-src/asrouter/schemas/message-group.schema.json new file mode 100644 index 0000000000..421acf159a --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/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/newtab/content-src/asrouter/schemas/provider-response.schema.json b/browser/components/newtab/content-src/asrouter/schemas/provider-response.schema.json new file mode 100644 index 0000000000..f0a92705be --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/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": ["simple_snippet"] + }, + "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/newtab/content-src/asrouter/template-utils.js b/browser/components/newtab/content-src/asrouter/template-utils.js new file mode 100644 index 0000000000..004234f45f --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/template-utils.js @@ -0,0 +1,22 @@ +/* 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/. */ + +export function safeURI(url) { + if (!url) { + return ""; + } + const { protocol } = new URL(url); + const isAllowed = [ + "http:", + "https:", + "data:", + "resource:", + "chrome:", + ].includes(protocol); + if (!isAllowed) { + // eslint-disable-next-line no-console + console.warn(`The protocol ${protocol} is not allowed for template URLs.`); + } + return isAllowed ? url : ""; +} diff --git a/browser/components/newtab/content-src/asrouter/templates/CFR/templates/CFRUrlbarChiclet.schema.json b/browser/components/newtab/content-src/asrouter/templates/CFR/templates/CFRUrlbarChiclet.schema.json new file mode 100644 index 0000000000..da8e30f21a --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/CFR/templates/CFRUrlbarChiclet.schema.json @@ -0,0 +1,69 @@ +{ + "$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/newtab/content-src/asrouter/templates/CFR/templates/ExtensionDoorhanger.schema.json b/browser/components/newtab/content-src/asrouter/templates/CFR/templates/ExtensionDoorhanger.schema.json new file mode 100644 index 0000000000..ae7eebb8b7 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/CFR/templates/ExtensionDoorhanger.schema.json @@ -0,0 +1,330 @@ +{ + "$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." + }, + "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": "number", + "minimum": 0, + "maximum": 5, + "description": "Star rating" + }, + "users": { + "type": "integer", + "minimum": 0, + "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.jsm", + "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/newtab/content-src/asrouter/templates/CFR/templates/InfoBar.schema.json b/browser/components/newtab/content-src/asrouter/templates/CFR/templates/InfoBar.schema.json new file mode 100644 index 0000000000..25dd8ed7aa --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/CFR/templates/InfoBar.schema.json @@ -0,0 +1,91 @@ +{ + "$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/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.jsx new file mode 100644 index 0000000000..f324a69853 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.jsx @@ -0,0 +1,153 @@ +/* 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"; +import { SimpleSnippet } from "../SimpleSnippet/SimpleSnippet"; + +class EOYSnippetBase extends React.PureComponent { + constructor(props) { + super(props); + this.handleSubmit = this.handleSubmit.bind(this); + } + + /** + * setFrequencyValue - `frequency` form parameter value should be `monthly` + * if `monthly-checkbox` is selected or `single` otherwise + */ + setFrequencyValue() { + const frequencyCheckbox = this.refs.form.querySelector("#monthly-checkbox"); + if (frequencyCheckbox.checked) { + this.refs.form.querySelector("[name='frequency']").value = "monthly"; + } + } + + handleSubmit(event) { + event.preventDefault(); + this.props.sendClick(event); + this.setFrequencyValue(); + if (!this.props.content.do_not_autoblock) { + this.props.onBlock(); + } + this.refs.form.submit(); + } + + renderDonations() { + const fieldNames = ["first", "second", "third", "fourth"]; + const numberFormat = new Intl.NumberFormat( + this.props.content.locale || navigator.language, + { + style: "currency", + currency: this.props.content.currency_code, + minimumFractionDigits: 0, + } + ); + // Default to `second` button + const { selected_button } = this.props.content; + const btnStyle = { + color: this.props.content.button_color, + backgroundColor: this.props.content.button_background_color, + }; + const donationURLParams = []; + const paramsStartIndex = this.props.content.donation_form_url.indexOf("?"); + for (const entry of new URLSearchParams( + this.props.content.donation_form_url.slice(paramsStartIndex) + ).entries()) { + donationURLParams.push(entry); + } + + return ( + <form + className="EOYSnippetForm" + action={this.props.content.donation_form_url} + method={this.props.form_method} + onSubmit={this.handleSubmit} + data-metric="EOYSnippetForm" + ref="form" + > + {donationURLParams.map(([key, value], idx) => ( + <input type="hidden" name={key} value={value} key={idx} /> + ))} + {fieldNames.map((field, idx) => { + const button_name = `donation_amount_${field}`; + const amount = this.props.content[button_name]; + return ( + <React.Fragment key={idx}> + <input + type="radio" + name="amount" + value={amount} + id={field} + defaultChecked={button_name === selected_button} + /> + <label htmlFor={field} className="donation-amount"> + {numberFormat.format(amount)} + </label> + </React.Fragment> + ); + })} + + <div className="monthly-checkbox-container"> + <input id="monthly-checkbox" type="checkbox" /> + <label htmlFor="monthly-checkbox"> + {this.props.content.monthly_checkbox_label_text} + </label> + </div> + + <input type="hidden" name="frequency" value="single" /> + <input + type="hidden" + name="currency" + value={this.props.content.currency_code} + /> + <input + type="hidden" + name="presets" + value={fieldNames.map( + field => this.props.content[`donation_amount_${field}`] + )} + /> + <button + style={btnStyle} + type="submit" + className="ASRouterButton primary donation-form-url" + > + {this.props.content.button_label} + </button> + </form> + ); + } + + render() { + const textStyle = { + color: this.props.content.text_color, + backgroundColor: this.props.content.background_color, + }; + const customElement = ( + <em style={{ backgroundColor: this.props.content.highlight_color }} /> + ); + return ( + <SimpleSnippet + {...this.props} + className={this.props.content.test} + customElements={{ em: customElement }} + textStyle={textStyle} + extraContent={this.renderDonations()} + /> + ); + } +} + +export const EOYSnippet = props => { + const extendedContent = { + monthly_checkbox_label_text: "Make my donation monthly", + locale: "en-US", + currency_code: "usd", + selected_button: "donation_amount_second", + ...props.content, + }; + + return ( + <EOYSnippetBase {...props} content={extendedContent} form_method="GET" /> + ); +}; diff --git a/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json new file mode 100644 index 0000000000..a82de98e09 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json @@ -0,0 +1,159 @@ +{ + "title": "EOYSnippet", + "description": "Fundraising Snippet", + "version": "1.1.0", + "type": "object", + "definitions": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "richText": { + "description": "Text with HTML subset allowed: i, b, u, strong, em, br", + "type": "string" + }, + "link_url": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + }, + "properties": { + "donation_form_url": { + "type": "string", + "description": "Url to the donation form." + }, + "currency_code": { + "type": "string", + "description": "The code for the currency. Examle gbp, cad, usd.", + "default": "usd" + }, + "locale": { + "type": "string", + "description": "String for the locale code.", + "default": "en-US" + }, + "text": { + "allOf": [ + {"$ref": "#/definitions/richText"}, + {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"} + ] + }, + "text_color": { + "type": "string", + "description": "Modify the text message color" + }, + "background_color": { + "type": "string", + "description": "Snippet background color." + }, + "highlight_color": { + "type": "string", + "description": "Paragraph em highlight color." + }, + "donation_amount_first": { + "type": "number", + "description": "First button amount." + }, + "donation_amount_second": { + "type": "number", + "description": "Second button amount." + }, + "donation_amount_third": { + "type": "number", + "description": "Third button amount." + }, + "donation_amount_fourth": { + "type": "number", + "description": "Fourth button amount." + }, + "selected_button": { + "type": "string", + "description": "Default donation_amount_second. Donation amount button that's selected by default.", + "default": "donation_amount_second" + }, + "icon": { + "type": "string", + "description": "Snippet icon. 64x64px. SVG or PNG preferred." + }, + "icon_dark_theme": { + "type": "string", + "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred." + }, + "icon_alt_text": { + "type": "string", + "description": "Alt text for accessibility", + "default": "" + }, + "title": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Snippet title displayed before snippet text"} + ] + }, + "title_icon": { + "type": "string", + "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale." + }, + "title_icon_dark_theme": { + "type": "string", + "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale." + }, + "button_label": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Text for a button next to main snippet text that links to button_url. Requires button_url."} + ] + }, + "button_color": { + "type": "string", + "description": "The text color of the button. Valid CSS color." + }, + "button_background_color": { + "type": "string", + "description": "The background color of the button. Valid CSS color." + }, + "block_button_text": { + "type": "string", + "description": "Tooltip text used for dismiss button." + }, + "monthly_checkbox_label_text": { + "type": "string", + "description": "Label text for monthly checkbox.", + "default": "Make my donation monthly" + }, + "test": { + "type": "string", + "description": "Different styles for the snippet. Options are bold and takeover." + }, + "do_not_autoblock": { + "type": "boolean", + "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked" + }, + "links": { + "additionalProperties": { + "url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "The url where the link points to."} + ] + }, + "metric": { + "type": "string", + "description": "Custom event name sent with telemetry event." + }, + "args": { + "type": "string", + "description": "Additional parameters for link action, example which specific menu the button should open" + } + } + } + }, + "additionalProperties": false, + "required": ["text", "donation_form_url", "donation_amount_first", "donation_amount_second", "donation_amount_third", "donation_amount_fourth", "button_label", "currency_code"], + "dependencies": { + "button_color": ["button_label"], + "button_background_color": ["button_label"] + } +} + diff --git a/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/_EOYSnippet.scss b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/_EOYSnippet.scss new file mode 100644 index 0000000000..d9911ff02c --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/_EOYSnippet.scss @@ -0,0 +1,55 @@ +.EOYSnippetForm { + margin: 10px 0 8px; + align-self: start; + font-size: 14px; + display: flex; + align-items: center; + + .donation-amount, + .donation-form-url { + white-space: nowrap; + font-size: 14px; + padding: 8px 20px; + border-radius: 2px; + } + + .donation-amount { + color: var(--newtab-text-primary-color); + margin-inline-end: 18px; + border: $input-border; + padding: 5px 14px; + background: var(--newtab-background-color-secondary); + cursor: pointer; + } + + input { + &[type='radio'] { + opacity: 0; + margin-inline-end: -18px; + + &:checked + .donation-amount { + // Use a text color for the background to achieve an inverted look. + background: var(--newtab-text-secondary-color); + color: var(--newtab-background-color-secondary); + border: $border-secondary; + } + + // accessibility + &:checked:focus + .donation-amount, + &:not(:checked):focus + .donation-amount { + border: 1px dotted var(--newtab-primary-action-background); + } + } + } + + .monthly-checkbox-container { + display: flex; + width: 100%; + } + + .donation-form-url { + margin-inline-start: 18px; + align-self: flex-end; + display: flex; + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.jsx new file mode 100644 index 0000000000..1d8197d675 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.jsx @@ -0,0 +1,38 @@ +/* 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"; +import { SubmitFormSnippet } from "../SubmitFormSnippet/SubmitFormSnippet.jsx"; + +export const FXASignupSnippet = props => { + const userAgent = window.navigator.userAgent.match(/Firefox\/([0-9]+)\./); + const firefox_version = userAgent ? parseInt(userAgent[1], 10) : 0; + const extendedContent = { + scene1_button_label: "Learn more", + retry_button_label: "Try again", + scene2_email_placeholder_text: "Your email here", + scene2_button_label: "Sign me up", + scene2_dismiss_button_text: "Dismiss", + ...props.content, + hidden_inputs: { + action: "email", + context: "fx_desktop_v3", + entrypoint: "snippets", + utm_source: "snippet", + utm_content: firefox_version, + utm_campaign: props.content.utm_campaign, + utm_term: props.content.utm_term, + ...props.content.hidden_inputs, + }, + }; + + return ( + <SubmitFormSnippet + {...props} + content={extendedContent} + form_action={"https://accounts.firefox.com/"} + form_method="GET" + /> + ); +}; diff --git a/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.schema.json new file mode 100644 index 0000000000..d7d3e37bbc --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.schema.json @@ -0,0 +1,187 @@ +{ + "title": "FXASignupSnippet", + "description": "A snippet template for FxA sign up/sign in", + "version": "1.2.0", + "type": "object", + "definitions": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "richText": { + "description": "Text with HTML subset allowed: i, b, u, strong, em, br", + "type": "string" + }, + "link_url": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + }, + "properties": { + "scene1_title": { + "allof": [ + {"$ref": "#/definitions/plainText"}, + {"description": "snippet title displayed before snippet text"} + ] + }, + "scene1_text": { + "allOf": [ + {"$ref": "#/definitions/richText"}, + {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"} + ] + }, + "scene1_section_title_icon": { + "type": "string", + "description": "Section title icon for scene 1. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display." + }, + "scene1_section_title_icon_dark_theme": { + "type": "string", + "description": "Section title icon for scene 1, dark theme variant. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display." + }, + "scene1_section_title_text": { + "type": "string", + "description": "Section title text for scene 1. scene1_section_title_icon must also be specified to display." + }, + "scene1_section_title_url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "A url, scene1_section_title_text links to this"} + ] + }, + "scene2_title": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Title displayed before text in scene 2. Should be plain text."} + ] + }, + "scene2_text": { + "allOf": [ + {"$ref": "#/definitions/richText"}, + {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"} + ] + }, + "scene1_icon": { + "type": "string", + "description": "Snippet icon. 64x64px. SVG or PNG preferred." + }, + "scene1_icon_dark_theme": { + "type": "string", + "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred." + }, + "scene1_title_icon": { + "type": "string", + "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale." + }, + "scene1_title_icon_dark_theme": { + "type": "string", + "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale." + }, + "scene2_email_placeholder_text": { + "type": "string", + "description": "Value to show while input is empty.", + "default": "Your email here" + }, + "scene2_button_label": { + "type": "string", + "description": "Label for form submit button", + "default": "Sign me up" + }, + "scene2_dismiss_button_text": { + "type": "string", + "description": "Label for the dismiss button when the sign-up form is expanded.", + "default": "Dismiss" + }, + "hidden_inputs": { + "type": "object", + "description": "Each entry represents a hidden input, key is used as value for the name property.", + "properties": { + "action": { + "type": "string", + "enum": ["email"] + }, + "context": { + "type": "string", + "enum": ["fx_desktop_v3"] + }, + "entrypoint": { + "type": "string", + "enum": ["snippets"] + }, + "utm_content": { + "type": "number", + "description": "Firefox version number" + }, + "utm_source": { + "type": "string", + "enum": ["snippet"] + }, + "utm_campaign": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_campaign." + }, + "utm_term": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_term." + }, + "additionalProperties": false + } + }, + "scene1_button_label": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Text for a button next to main snippet text that links to button_url. Requires button_url."} + ], + "default": "Learn more" + }, + "scene1_button_color": { + "type": "string", + "description": "The text color of the button. Valid CSS color." + }, + "scene1_button_background_color": { + "type": "string", + "description": "The background color of the button. Valid CSS color." + }, + "retry_button_label": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Text for the button in the event of a submission error/failure."} + ], + "default": "Try again" + }, + "do_not_autoblock": { + "type": "boolean", + "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked", + "default": false + }, + "utm_campaign": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_campaign." + }, + "utm_term": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_term." + }, + "links": { + "additionalProperties": { + "url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "The url where the link points to."} + ] + }, + "metric": { + "type": "string", + "description": "Custom event name sent with telemetry event." + } + } + } + }, + "additionalProperties": false, + "required": ["scene1_text", "scene2_text", "scene1_button_label"], + "dependencies": { + "scene1_button_color": ["scene1_button_label"], + "scene1_button_background_color": ["scene1_button_label"] + } +} + diff --git a/browser/components/newtab/content-src/asrouter/templates/FirstRun/addUtmParams.js b/browser/components/newtab/content-src/asrouter/templates/FirstRun/addUtmParams.js new file mode 100644 index 0000000000..cb29f66d6e --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/FirstRun/addUtmParams.js @@ -0,0 +1,30 @@ +/* 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/. */ + +/** + * BASE_PARAMS keys/values can be modified from outside this file + */ +export const BASE_PARAMS = { + utm_source: "activity-stream", + utm_campaign: "firstrun", + utm_medium: "referral", +}; + +/** + * Takes in a url as a string or URL object and returns a URL object with the + * utm_* parameters added to it. If a URL object is passed in, the paraemeters + * are added to it (the return value can be ignored in that case as it's the + * same object). + */ +export function addUtmParams(url, utmTerm) { + let returnUrl = url; + if (typeof returnUrl === "string") { + returnUrl = new URL(url); + } + Object.keys(BASE_PARAMS).forEach(key => { + returnUrl.searchParams.append(key, BASE_PARAMS[key]); + }); + returnUrl.searchParams.append("utm_term", utmTerm); + return returnUrl; +} diff --git a/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.jsx new file mode 100644 index 0000000000..27c1684762 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.jsx @@ -0,0 +1,34 @@ +/* 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"; +import { SubmitFormSnippet } from "../SubmitFormSnippet/SubmitFormSnippet.jsx"; + +export const NewsletterSnippet = props => { + const extendedContent = { + scene1_button_label: "Learn more", + retry_button_label: "Try again", + scene2_email_placeholder_text: "Your email here", + scene2_button_label: "Sign me up", + scene2_dismiss_button_text: "Dismiss", + scene2_newsletter: "mozilla-foundation", + ...props.content, + hidden_inputs: { + newsletters: props.content.scene2_newsletter || "mozilla-foundation", + fmt: "H", + lang: props.content.locale || "en-US", + source_url: `https://snippets.mozilla.com/show/${props.id}`, + ...props.content.hidden_inputs, + }, + }; + + return ( + <SubmitFormSnippet + {...props} + content={extendedContent} + form_action={"https://basket.mozilla.org/subscribe.json"} + form_method="POST" + /> + ); +}; diff --git a/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.schema.json new file mode 100644 index 0000000000..eeb63554ed --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.schema.json @@ -0,0 +1,177 @@ +{ + "title": "NewsletterSnippet", + "description": "A snippet template for send to device mobile download", + "version": "1.2.0", + "type": "object", + "definitions": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "richText": { + "description": "Text with HTML subset allowed: i, b, u, strong, em, br", + "type": "string" + }, + "link_url": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + }, + "properties": { + "locale": { + "type": "string", + "description": "Two to five character string for the locale code", + "default": "en-US" + }, + "scene1_title": { + "allof": [ + {"$ref": "#/definitions/plainText"}, + {"description": "snippet title displayed before snippet text"} + ] + }, + "scene1_text": { + "allOf": [ + {"$ref": "#/definitions/richText"}, + {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"} + ] + }, + "scene1_section_title_icon": { + "type": "string", + "description": "Section title icon for scene 1. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display." + }, + "scene1_section_title_icon_dark_theme": { + "type": "string", + "description": "Section title icon for scene 1, dark theme variant. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display." + }, + "scene1_section_title_text": { + "type": "string", + "description": "Section title text for scene 1. scene1_section_title_icon must also be specified to display." + }, + "scene1_section_title_url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "A url, scene1_section_title_text links to this"} + ] + }, + "scene2_title": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Title displayed before text in scene 2. Should be plain text."} + ] + }, + "scene2_text": { + "allOf": [ + {"$ref": "#/definitions/richText"}, + {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"} + ] + }, + "scene1_icon": { + "type": "string", + "description": "Snippet icon. 64x64px. SVG or PNG preferred." + }, + "scene1_icon_dark_theme": { + "type": "string", + "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred." + }, + "scene1_title_icon": { + "type": "string", + "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale." + }, + "scene1_title_icon_dark_theme": { + "type": "string", + "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale." + }, + "scene2_email_placeholder_text": { + "type": "string", + "description": "Value to show while input is empty.", + "default": "Your email here" + }, + "scene2_button_label": { + "type": "string", + "description": "Label for form submit button", + "default": "Sign me up" + }, + "scene2_privacy_html": { + "type": "string", + "description": "(send to device) Html for disclaimer and link underneath input box." + }, + "scene2_dismiss_button_text": { + "type": "string", + "description": "Label for the dismiss button when the sign-up form is expanded.", + "default": "Dismiss" + }, + "hidden_inputs": { + "type": "object", + "description": "Each entry represents a hidden input, key is used as value for the name property.", + "properties": { + "fmt": { + "type": "string", + "description": "", + "default": "H" + } + } + }, + "scene1_button_label": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Text for a button next to main snippet text that links to button_url. Requires button_url."} + ], + "default": "Learn more" + }, + "scene1_button_color": { + "type": "string", + "description": "The text color of the button. Valid CSS color." + }, + "scene1_button_background_color": { + "type": "string", + "description": "The background color of the button. Valid CSS color." + }, + "retry_button_label": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Text for the button in the event of a submission error/failure."} + ], + "default": "Try again" + }, + "do_not_autoblock": { + "type": "boolean", + "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked", + "default": false + }, + "success_text": { + "type": "string", + "description": "Message shown on successful registration." + }, + "error_text": { + "type": "string", + "description": "Message shown if registration failed." + }, + "scene2_newsletter": { + "type": "string", + "description": "Newsletter/basket id user is subscribing to.", + "default": "mozilla-foundation" + }, + "links": { + "additionalProperties": { + "url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "The url where the link points to."} + ] + }, + "metric": { + "type": "string", + "description": "Custom event name sent with telemetry event." + } + } + } + }, + "additionalProperties": false, + "required": ["scene1_text", "scene2_text", "scene1_button_label"], + "dependencies": { + "scene1_button_color": ["scene1_button_label"], + "scene1_button_background_color": ["scene1_button_label"] + } +} + diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ProtectionsPanelMessage.schema.json b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ProtectionsPanelMessage.schema.json new file mode 100644 index 0000000000..77885847d7 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ProtectionsPanelMessage.schema.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///ProtectionsPanelMessage.schema.json", + "title": "ProtectionsPanelMessage", + "description": "A message shown in the protections panel.", + "allOf": [ + { "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" } + ], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "title": { + "description": "The message title.", + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText" + }, + "body": { + "description": "The body of the message.", + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText" + }, + "link_text": { + "description": "The text of the call to action link.", + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText" + }, + "cta_type": { + "description": "The type of URL open action.", + "type": "string", + "enum": ["OPEN_URL", "OPEN_PROTECTION_REPORT", "OPEN_ABOUT_PAGE"] + }, + "cta_url": { + "description": "The URL to open when the call to action is clicked", + "type": "string", + "format": "moz-url-format" + }, + "cta_where": { + "description": "How to open the cta.", + "type": "string", + "enum": [ + "current", + "tabshifted", + "tab", + "save", + "window" + ] + } + }, + "dependantSchemas": { + "link_text": ["cta_type", "cta_url"], + "cta_type": ["link_text"], + "cta_url": ["link_text"], + "cta_where": ["link_text"] + }, + "additionalProperties": false, + "required": [ + "title", + "body" + ] + }, + "template": { + "type": "string", + "const": "protections_panel" + }, + "trigger": { + "description": "An action to trigger potentially showing the message. The action ID `protectionsPanelOpen` is required.", + "const": { + "id": "protectionsPanelOpen" + } + } + }, + "required": ["content", "template", "trigger"], + "additionalProperties": true +} diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/Spotlight.schema.json b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/Spotlight.schema.json new file mode 100644 index 0000000000..bb79e46f7f --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/Spotlight.schema.json @@ -0,0 +1,170 @@ +{ + "$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", + "enum": ["logo-and-content", "multistage"] + }, + "backdrop": { + "type": "string", + "description": "Background css behind modal content" + }, + "logoImageURL": { + "type": "string", + "format": "uri", + "description": "(Deprecated by logo.imageURL)" + }, + "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 + }, + "body": { + "type": "object", + "properties": { + "title": { + "type": "object", + "properties": { + "label": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "The title shown in the Spotlight message" + } + }, + "required": ["label"] + }, + "text": { + "type": "object", + "properties": { + "label": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "The content shown in the Spotlight message" + } + }, + "required": ["label"] + }, + "primary": { + "type": "object", + "properties": { + "label": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "The label for the primary button" + }, + "action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Action dispatched by the button." + }, + "data": { + "type": "object" + } + }, + "required": ["type"], + "additionalProperties": true + } + }, + "required": ["label", "action"] + }, + "secondary": { + "type": "object", + "properties": { + "label": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "The label for the secondary button" + } + }, + "required": ["label", "action"] + } + }, + "additionalProperties": true, + "required": ["title", "text", "primary", "secondary"] + }, + "extra": { + "type": "object", + "properties": { + "expanded": { + "type": "object", + "properties": { + "label": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "The label for the secondary button" + } + }, + "required": ["label"] + } + }, + "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, + "if": { + "properties": { + "logoImageURL": { "type": "null" } + } + }, + "then": { + "properties": { + "logo": { + "oneOf": [ + { + "required": ["imageURL"] + }, + { + "required": ["imageId"] + } + ] + } + } + }, + "required": ["template"] + }, + "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/newtab/content-src/asrouter/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json new file mode 100644 index 0000000000..c8c01f4c76 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json @@ -0,0 +1,49 @@ +{ + "$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/newtab/content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json new file mode 100644 index 0000000000..9ed72dc532 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json @@ -0,0 +1,49 @@ +{ + "$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/newtab/content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json new file mode 100644 index 0000000000..565b78adc6 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json @@ -0,0 +1,93 @@ +{ + "$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/newtab/content-src/asrouter/templates/PBNewtab/NewtabPromoMessage.schema.json b/browser/components/newtab/content-src/asrouter/templates/PBNewtab/NewtabPromoMessage.schema.json new file mode 100644 index 0000000000..e4751c9e3d --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/PBNewtab/NewtabPromoMessage.schema.json @@ -0,0 +1,168 @@ +{ + "$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", + "RALLY", + "VPN", + "PIN", + "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/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.jsx new file mode 100644 index 0000000000..0929b8f711 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.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 { isEmailOrPhoneNumber } from "./isEmailOrPhoneNumber"; +import React from "react"; +import { SubmitFormSnippet } from "../SubmitFormSnippet/SubmitFormSnippet.jsx"; + +function validateInput(value, content) { + const type = isEmailOrPhoneNumber(value, content); + return type ? "" : "Must be an email or a phone number."; +} + +function processFormData(input, message) { + const { content } = message; + const type = content.include_sms + ? isEmailOrPhoneNumber(input.value, content) + : "email"; + const formData = new FormData(); + let url; + if (type === "phone") { + url = "https://basket.mozilla.org/news/subscribe_sms/"; + formData.append("mobile_number", input.value); + formData.append("msg_name", content.message_id_sms); + formData.append("country", content.country); + } else if (type === "email") { + url = "https://basket.mozilla.org/news/subscribe/"; + formData.append("email", input.value); + formData.append("newsletters", content.message_id_email); + formData.append( + "source_url", + encodeURIComponent(`https://snippets.mozilla.com/show/${message.id}`) + ); + } + formData.append("lang", content.locale); + return { formData, url }; +} + +function addDefaultValues(props) { + return { + ...props, + content: { + scene1_button_label: "Learn more", + retry_button_label: "Try again", + scene2_dismiss_button_text: "Dismiss", + scene2_button_label: "Send", + scene2_input_placeholder: "Your email here", + locale: "en-US", + country: "us", + message_id_email: "", + include_sms: false, + ...props.content, + }, + }; +} + +export const SendToDeviceSnippet = props => { + const propsWithDefaults = addDefaultValues(props); + + return ( + <SubmitFormSnippet + {...propsWithDefaults} + form_method="POST" + className="send_to_device_snippet" + inputType={propsWithDefaults.content.include_sms ? "text" : "email"} + validateInput={ + propsWithDefaults.content.include_sms ? validateInput : null + } + processFormData={processFormData} + /> + ); +}; + +export const SendToDeviceScene2Snippet = props => { + return <SendToDeviceSnippet expandedAlt={true} {...props} />; +}; diff --git a/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.schema.json new file mode 100644 index 0000000000..238840234a --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.schema.json @@ -0,0 +1,234 @@ +{ + "title": "SendToDeviceSnippet", + "description": "A snippet template for send to device mobile download", + "version": "1.2.0", + "type": "object", + "definitions": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "richText": { + "description": "Text with HTML subset allowed: i, b, u, strong, em, br", + "type": "string" + }, + "link_url": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + }, + "properties": { + "locale": { + "type": "string", + "description": "Two to five character string for the locale code", + "default": "en-US" + }, + "country": { + "type": "string", + "description": "Two character string for the country code (used for SMS)", + "default": "us" + }, + "scene1_title": { + "allof": [ + {"$ref": "#/definitions/plainText"}, + {"description": "snippet title displayed before snippet text"} + ] + }, + "scene1_text": { + "allOf": [ + {"$ref": "#/definitions/richText"}, + {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"} + ] + }, + "scene1_section_title_icon": { + "type": "string", + "description": "Section title icon for scene 1. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display." + }, + "scene1_section_title_icon_dark_theme": { + "type": "string", + "description": "Section title icon for scene 1, dark theme variant. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display." + }, + "scene1_section_title_text": { + "type": "string", + "description": "Section title text for scene 1. scene1_section_title_icon must also be specified to display." + }, + "scene1_section_title_url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "A url, scene1_section_title_text links to this"} + ] + }, + "scene2_title": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Title displayed before text in scene 2. Should be plain text."} + ] + }, + "scene2_text": { + "allOf": [ + {"$ref": "#/definitions/richText"}, + {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"} + ] + }, + "scene1_icon": { + "type": "string", + "description": "Snippet icon. 64x64px. SVG or PNG preferred." + }, + "scene1_icon_dark_theme": { + "type": "string", + "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred." + }, + "scene2_icon": { + "type": "string", + "description": "(send to device) Image to display above the form. Dark theme variant. 98x98px. SVG or PNG preferred." + }, + "scene2_icon_dark_theme": { + "type": "string", + "description": "(send to device) Image to display above the form. 98x98px. SVG or PNG preferred." + }, + "scene1_title_icon": { + "type": "string", + "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale." + }, + "scene1_title_icon_dark_theme": { + "type": "string", + "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale." + }, + "scene2_button_label": { + "type": "string", + "description": "Label for form submit button", + "default": "Send" + }, + "scene2_input_placeholder": { + "type": "string", + "description": "(send to device) Value to show while input is empty.", + "default": "Your email here" + }, + "scene2_disclaimer_html": { + "type": "string", + "description": "(send to device) Html for disclaimer and link underneath input box." + }, + "scene2_dismiss_button_text": { + "type": "string", + "description": "Label for the dismiss button when the sign-up form is expanded.", + "default": "Dismiss" + }, + "hidden_inputs": { + "type": "object", + "description": "Each entry represents a hidden input, key is used as value for the name property.", + "properties": { + "action": { + "type": "string", + "enum": ["email"] + }, + "context": { + "type": "string", + "enum": ["fx_desktop_v3"] + }, + "entrypoint": { + "type": "string", + "enum": ["snippets"] + }, + "utm_content": { + "type": "string", + "description": "Firefox version number" + }, + "utm_source": { + "type": "string", + "enum": ["snippet"] + }, + "utm_campaign": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_campaign." + }, + "utm_term": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_term." + }, + "additionalProperties": false + } + }, + "scene1_button_label": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Text for a button next to main snippet text that links to button_url. Requires button_url."} + ], + "default": "Learn more" + }, + "scene1_button_color": { + "type": "string", + "description": "The text color of the button. Valid CSS color." + }, + "scene1_button_background_color": { + "type": "string", + "description": "The background color of the button. Valid CSS color." + }, + "retry_button_label": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Text for the button in the event of a submission error/failure."} + ], + "default": "Try again" + }, + "do_not_autoblock": { + "type": "boolean", + "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked", + "default": false + }, + "success_title": { + "type": "string", + "description": "(send to device) Title shown before text on successful registration." + }, + "success_text": { + "type": "string", + "description": "Message shown on successful registration." + }, + "error_text": { + "type": "string", + "description": "Message shown if registration failed." + }, + "include_sms": { + "type": "boolean", + "description": "(send to device) Allow users to send an SMS message with the form?", + "default": false + }, + "message_id_sms": { + "type": "string", + "description": "(send to device) Newsletter/basket id representing the SMS message to be sent." + }, + "message_id_email": { + "type": "string", + "description": "(send to device) Newsletter/basket id representing the email message to be sent. Must be a value from the 'Slug' column here: https://basket.mozilla.org/news/." + }, + "utm_campaign": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_campaign." + }, + "utm_term": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_term." + }, + "links": { + "additionalProperties": { + "url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "The url where the link points to."} + ] + }, + "metric": { + "type": "string", + "description": "Custom event name sent with telemetry event." + } + } + } + }, + "additionalProperties": false, + "required": ["scene1_text", "scene2_text", "scene1_button_label"], + "dependencies": { + "scene1_button_color": ["scene1_button_label"], + "scene1_button_background_color": ["scene1_button_label"] + } +} + diff --git a/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/isEmailOrPhoneNumber.js b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/isEmailOrPhoneNumber.js new file mode 100644 index 0000000000..29addb688d --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/isEmailOrPhoneNumber.js @@ -0,0 +1,38 @@ +/* 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/. */ + +/** + * Checks if a given string is an email or phone number or neither + * @param {string} val The user input + * @param {ASRMessageContent} content .content property on ASR message + * @returns {"email"|"phone"|""} The type of the input + */ +export function isEmailOrPhoneNumber(val, content) { + const { locale } = content; + // http://emailregex.com/ + const email_re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + const check_email = email_re.test(val); + let check_phone; // depends on locale + switch (locale) { + case "en-US": + case "en-CA": + // allow 10-11 digits in case user wants to enter country code + check_phone = val.length >= 10 && val.length <= 11 && !isNaN(val); + break; + case "de": + // allow between 2 and 12 digits for german phone numbers + check_phone = val.length >= 2 && val.length <= 12 && !isNaN(val); + break; + // this case should never be hit, but good to have a fallback just in case + default: + check_phone = !isNaN(val); + break; + } + if (check_email) { + return "email"; + } else if (check_phone) { + return "phone"; + } + return ""; +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.jsx new file mode 100644 index 0000000000..2641d51e86 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.jsx @@ -0,0 +1,133 @@ +/* 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"; +import { Button } from "../../components/Button/Button"; +import { RichText } from "../../components/RichText/RichText"; +import { safeURI } from "../../template-utils"; +import { SnippetBase } from "../../components/SnippetBase/SnippetBase"; + +const DEFAULT_ICON_PATH = "chrome://branding/content/icon64.png"; +// Alt text placeholder in case the prop from the server isn't available +const ICON_ALT_TEXT = ""; + +export class SimpleBelowSearchSnippet extends React.PureComponent { + constructor(props) { + super(props); + this.onButtonClick = this.onButtonClick.bind(this); + } + + renderText() { + const { props } = this; + return props.content.text ? ( + <RichText + text={props.content.text} + customElements={this.props.customElements} + localization_id="text" + links={props.content.links} + sendClick={props.sendClick} + /> + ) : null; + } + + renderTitle() { + const { title } = this.props.content; + return title ? ( + <h3 className={"title title-inline"}> + {title} + <br /> + </h3> + ) : null; + } + + async onButtonClick() { + if (this.props.provider !== "preview") { + this.props.sendUserActionTelemetry({ + event: "CLICK_BUTTON", + id: this.props.UISurface, + }); + } + const { button_url } = this.props.content; + // If button_url is defined handle it as OPEN_URL action + const type = this.props.content.button_action || (button_url && "OPEN_URL"); + await this.props.onAction({ + type, + data: { args: this.props.content.button_action_args || button_url }, + }); + if (!this.props.content.do_not_autoblock) { + this.props.onBlock(); + } + } + + _shouldRenderButton() { + return ( + this.props.content.button_action || + this.props.onButtonClick || + this.props.content.button_url + ); + } + + renderButton() { + const { props } = this; + if (!this._shouldRenderButton()) { + return null; + } + + return ( + <Button + onClick={props.onButtonClick || this.onButtonClick} + color={props.content.button_color} + backgroundColor={props.content.button_background_color} + > + {props.content.button_label} + </Button> + ); + } + + render() { + const { props } = this; + let className = "SimpleBelowSearchSnippet"; + let containerName = "below-search-snippet"; + + if (props.className) { + className += ` ${props.className}`; + } + if (this._shouldRenderButton()) { + className += " withButton"; + containerName += " withButton"; + } + + return ( + <div className={containerName}> + <div className="snippet-hover-wrapper"> + <SnippetBase + {...props} + className={className} + textStyle={this.props.textStyle} + > + <img + src={safeURI(props.content.icon) || DEFAULT_ICON_PATH} + className="icon icon-light-theme" + alt={props.content.icon_alt_text || ICON_ALT_TEXT} + /> + <img + src={ + safeURI(props.content.icon_dark_theme || props.content.icon) || + DEFAULT_ICON_PATH + } + className="icon icon-dark-theme" + alt={props.content.icon_alt_text || ICON_ALT_TEXT} + /> + <div className="textContainer"> + {this.renderTitle()} + <p className="body">{this.renderText()}</p> + {this.props.extraContent} + </div> + {<div className="buttonContainer">{this.renderButton()}</div>} + </SnippetBase> + </div> + </div> + ); + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json new file mode 100644 index 0000000000..049f66ef6b --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json @@ -0,0 +1,110 @@ +{ + "title": "SimpleBelowSearchSnippet", + "description": "A simple template with an icon, rich text and an optional button. It gets inserted below the Activity Stream search box.", + "version": "1.2.0", + "type": "object", + "definitions": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "richText": { + "description": "Text with HTML subset allowed: i, b, u, strong, em, br", + "type": "string" + }, + "link_url": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + }, + "properties": { + "title": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Snippet title displayed before snippet text"} + ] + }, + "text": { + "allOf": [ + {"$ref": "#/definitions/richText"}, + {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"} + ] + }, + "icon": { + "type": "string", + "description": "Snippet icon. 64x64px. SVG or PNG preferred." + }, + "icon_dark_theme": { + "type": "string", + "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred." + }, + "icon_alt_text": { + "type": "string", + "description": "Alt text describing icon for screen readers", + "default": "" + }, + "block_button_text": { + "type": "string", + "description": "Tooltip text used for dismiss button.", + "default": "Remove this" + }, + "button_action": { + "type": "string", + "description": "The type of action the button should trigger." + }, + "button_url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "A url, button_label links to this"} + ] + }, + "button_action_args": { + "description": "Additional parameters for button action, example which specific menu the button should open" + }, + "button_label": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Text for a button next to main snippet text that links to button_url. Requires button_url."} + ] + }, + "button_color": { + "type": "string", + "description": "The text color of the button. Valid CSS color." + }, + "button_background_color": { + "type": "string", + "description": "The background color of the button. Valid CSS color." + }, + "do_not_autoblock": { + "type": "boolean", + "description": "Used to prevent blocking the snippet after the CTA link has been clicked" + }, + "links": { + "additionalProperties": { + "url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "The url where the link points to."} + ] + }, + "metric": { + "type": "string", + "description": "Custom event name sent with telemetry event." + }, + "args": { + "type": "string", + "description": "Additional parameters for link action, example which specific menu the button should open" + } + } + } + }, + "additionalProperties": false, + "required": ["text"], + "dependencies": { + "button_action": ["button_label"], + "button_url": ["button_label"], + "button_color": ["button_label"], + "button_background_color": ["button_label"] + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/_SimpleBelowSearchSnippet.scss b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/_SimpleBelowSearchSnippet.scss new file mode 100644 index 0000000000..5bee0af7d0 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/_SimpleBelowSearchSnippet.scss @@ -0,0 +1,198 @@ + +.below-search-snippet { + margin: 0 auto 16px; + + &.withButton { + margin: auto; + min-height: 60px; + background-color: transparent; + + .snippet-hover-wrapper { + min-height: 60px; + border-radius: 4px; + + &:hover { + background-color: var(--newtab-element-hover-color); + + .blockButton { + display: block; + opacity: 1; + } + } + } + } +} + +.SimpleBelowSearchSnippet { + background-color: transparent; + border: 0; + box-shadow: none; + position: relative; + margin: auto; + z-index: auto; + + @media (min-width: $break-point-large) { + width: 736px; + } + + &.active { + background-color: var(--newtab-element-hover-color); + border-radius: 4px; + } + + .innerWrapper { + align-items: center; + background-color: transparent; + border-radius: 4px; + box-shadow: $shadow-card; + flex-direction: column; + padding: 16px; + text-align: center; + width: 100%; + + @mixin full-width-styles { + align-items: flex-start; + background-color: transparent; + border-radius: 4px; + box-shadow: none; + flex-direction: row; + padding: 0; + text-align: inherit; + width: 696px; + } + + @media (min-width: $break-point-medium) { + @include full-width-styles; + } + + @media (max-width: 865px) { + margin-inline-start: 0; + } + + // There is an off-by-one gap between breakpoints; this is to prevent weirdness at exactly 610px. + @media (max-width: $break-point-medium - 1px) { + margin: auto; + } + } + + .blockButton { + display: block; + inset-inline-end: 10px; + opacity: 1; + top: 50%; + + &:focus { + box-shadow: $shadow-primary; + border-radius: 2px; + } + } + + .title { + font-size: inherit; + margin: 0; + } + + .title-inline { + display: inline; + } + + .textContainer { + margin: 10px; + margin-inline-start: 0; + padding-inline-end: 20px; + } + + .icon { + margin-top: 8px; + margin-inline-start: 12px; + height: 32px; + width: 32px; + + @mixin full-width-styles { + height: 24px; + width: 24px; + } + + @media (min-width: $break-point-medium) { + @include full-width-styles; + } + + @media (max-width: $break-point-medium) { + margin: auto; + } + } + + &.withButton { + line-height: 20px; + margin-bottom: 10px; + min-height: 60px; + background-color: transparent; + + .innerWrapper { + // There is an off-by-one gap between breakpoints; this is to prevent weirdness at exactly 1121px. + @media (max-width: $break-point-widest + 1px) { + margin: 0 40px; + } + } + + .blockButton { + display: block; + inset-inline-end: -10%; + opacity: 0; + margin: auto; + top: unset; + + &:focus { + opacity: 1; + box-shadow: none; + } + + // There is an off-by-one gap between breakpoints; this is to prevent weirdness at exactly 1121px. + @media (max-width: $break-point-widest + 1px) { + inset-inline-end: 2%; + } + } + + .icon { + width: 42px; + height: 42px; + flex-shrink: 0; + margin: auto 0; + margin-inline-end: 10px; + + @media (max-width: $break-point-medium) { + margin: auto; + } + } + + .buttonContainer { + margin: auto; + margin-inline-end: 0; + + @media (max-width: $break-point-medium) { + margin: auto; + } + } + } + + button { + @media (max-width: $break-point-medium) { + margin: auto; + } + } + + .body { + display: inline; + position: sticky; + transform: translateY(-50%); + margin: 8px 0 0; + + @media (min-width: $break-point-medium) { + margin: 12px 0; + } + + a { + font-weight: 600; + } + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx new file mode 100644 index 0000000000..8d7b8c1f7b --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx @@ -0,0 +1,225 @@ +/* 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 { Button } from "../../components/Button/Button"; +import ConditionalWrapper from "../../components/ConditionalWrapper/ConditionalWrapper"; +import React from "react"; +import { RichText } from "../../components/RichText/RichText"; +import { safeURI } from "../../template-utils"; +import { SnippetBase } from "../../components/SnippetBase/SnippetBase"; + +const DEFAULT_ICON_PATH = "chrome://branding/content/icon64.png"; +// Alt text placeholder in case the prop from the server isn't available +const ICON_ALT_TEXT = ""; + +export class SimpleSnippet extends React.PureComponent { + constructor(props) { + super(props); + this.onButtonClick = this.onButtonClick.bind(this); + } + + onButtonClick() { + if (this.props.provider !== "preview") { + this.props.sendUserActionTelemetry({ + event: "CLICK_BUTTON", + id: this.props.UISurface, + }); + } + const { + button_url, + button_entrypoint_value, + button_entrypoint_name, + } = this.props.content; + // If button_url is defined handle it as OPEN_URL action + const type = this.props.content.button_action || (button_url && "OPEN_URL"); + // Assign the snippet referral for the action + const entrypoint = button_entrypoint_name + ? new URLSearchParams([ + [button_entrypoint_name, button_entrypoint_value], + ]).toString() + : button_entrypoint_value; + this.props.onAction({ + type, + data: { + args: this.props.content.button_action_args || button_url, + ...(entrypoint && { entrypoint }), + }, + }); + if (!this.props.content.do_not_autoblock) { + this.props.onBlock(); + } + } + + _shouldRenderButton() { + return ( + this.props.content.button_action || + this.props.onButtonClick || + this.props.content.button_url + ); + } + + renderTitle() { + const { title } = this.props.content; + return title ? ( + <h3 + className={`title ${this._shouldRenderButton() ? "title-inline" : ""}`} + > + {this.renderTitleIcon()} {title} + </h3> + ) : null; + } + + renderTitleIcon() { + const titleIconLight = safeURI(this.props.content.title_icon); + const titleIconDark = safeURI( + this.props.content.title_icon_dark_theme || this.props.content.title_icon + ); + if (!titleIconLight) { + return null; + } + + return ( + <React.Fragment> + <span + className="titleIcon icon-light-theme" + style={{ backgroundImage: `url("${titleIconLight}")` }} + /> + <span + className="titleIcon icon-dark-theme" + style={{ backgroundImage: `url("${titleIconDark}")` }} + /> + </React.Fragment> + ); + } + + renderButton() { + const { props } = this; + if (!this._shouldRenderButton()) { + return null; + } + + return ( + <Button + onClick={props.onButtonClick || this.onButtonClick} + color={props.content.button_color} + backgroundColor={props.content.button_background_color} + > + {props.content.button_label} + </Button> + ); + } + + renderText() { + const { props } = this; + return ( + <RichText + text={props.content.text} + customElements={this.props.customElements} + localization_id="text" + links={props.content.links} + sendClick={props.sendClick} + /> + ); + } + + wrapSectionHeader(url) { + return function(children) { + return <a href={url}>{children}</a>; + }; + } + + wrapSnippetContent(children) { + return <div className="innerContentWrapper">{children}</div>; + } + + renderSectionHeader() { + const { props } = this; + + // an icon and text must be specified to render the section header + if (props.content.section_title_icon && props.content.section_title_text) { + const sectionTitleIconLight = safeURI(props.content.section_title_icon); + const sectionTitleIconDark = safeURI( + props.content.section_title_icon_dark_theme || + props.content.section_title_icon + ); + const sectionTitleURL = props.content.section_title_url; + + return ( + <div className="section-header"> + <h3 className="section-title"> + <ConditionalWrapper + condition={sectionTitleURL} + wrap={this.wrapSectionHeader(sectionTitleURL)} + > + <span + className="icon icon-small-spacer icon-light-theme" + style={{ backgroundImage: `url("${sectionTitleIconLight}")` }} + /> + <span + className="icon icon-small-spacer icon-dark-theme" + style={{ backgroundImage: `url("${sectionTitleIconDark}")` }} + /> + <span className="section-title-text"> + {props.content.section_title_text} + </span> + </ConditionalWrapper> + </h3> + </div> + ); + } + + return null; + } + + render() { + const { props } = this; + const sectionHeader = this.renderSectionHeader(); + let className = "SimpleSnippet"; + + if (props.className) { + className += ` ${props.className}`; + } + if (props.content.tall) { + className += " tall"; + } + if (sectionHeader) { + className += " has-section-header"; + } + + return ( + <div className="snippet-hover-wrapper"> + <SnippetBase + {...props} + className={className} + textStyle={this.props.textStyle} + > + {sectionHeader} + <ConditionalWrapper + condition={sectionHeader} + wrap={this.wrapSnippetContent} + > + <img + src={safeURI(props.content.icon) || DEFAULT_ICON_PATH} + className="icon icon-light-theme" + alt={props.content.icon_alt_text || ICON_ALT_TEXT} + /> + <img + src={ + safeURI(props.content.icon_dark_theme || props.content.icon) || + DEFAULT_ICON_PATH + } + className="icon icon-dark-theme" + alt={props.content.icon_alt_text || ICON_ALT_TEXT} + /> + <div> + {this.renderTitle()} <p className="body">{this.renderText()}</p> + {this.props.extraContent} + </div> + {<div>{this.renderButton()}</div>} + </ConditionalWrapper> + </SnippetBase> + </div> + ); + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json new file mode 100644 index 0000000000..1229700d67 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json @@ -0,0 +1,155 @@ +{ + "title": "SimpleSnippet", + "description": "A simple template with an icon, text, and optional button.", + "version": "1.1.2", + "type": "object", + "definitions": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "richText": { + "description": "Text with HTML subset allowed: i, b, u, strong, em, br", + "type": "string" + }, + "link_url": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + }, + "properties": { + "title": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Snippet title displayed before snippet text"} + ] + }, + "text": { + "allOf": [ + {"$ref": "#/definitions/richText"}, + {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"} + ] + }, + "icon": { + "type": "string", + "description": "Snippet icon. 64x64px. SVG or PNG preferred." + }, + "icon_dark_theme": { + "type": "string", + "description": "Snippet icon, dark theme variant. 64x64px. SVG or PNG preferred." + }, + "icon_alt_text": { + "type": "string", + "description": "Alt text describing icon for screen readers", + "default": "" + }, + "title_icon": { + "type": "string", + "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale." + }, + "title_icon_dark_theme": { + "type": "string", + "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale." + }, + "title_icon_alt_text": { + "type": "string", + "description": "Alt text describing title icon for screen readers", + "default": "" + }, + "button_action": { + "type": "string", + "description": "The type of action the button should trigger." + }, + "button_url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "A url, button_label links to this"} + ] + }, + "button_action_args": { + "description": "Additional parameters for button action, example which specific menu the button should open" + }, + "button_entrypoint_value": { + "description": "String used for telemetry attribution of clicks", + "type": "string" + }, + "button_entrypoint_name": { + "description": "String used for telemetry attribution of clicks", + "type": "string" + }, + "button_label": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Text for a button next to main snippet text that links to button_url. Requires button_url."} + ] + }, + "button_color": { + "type": "string", + "description": "The text color of the button. Valid CSS color." + }, + "button_background_color": { + "type": "string", + "description": "The background color of the button. Valid CSS color." + }, + "block_button_text": { + "type": "string", + "description": "Tooltip text used for dismiss button.", + "default": "Remove this" + }, + "tall": { + "type": "boolean", + "description": "To be used by fundraising only, increases height to roughly 120px. Defaults to false." + }, + "do_not_autoblock": { + "type": "boolean", + "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked" + }, + "links": { + "additionalProperties": { + "url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "The url where the link points to."} + ] + }, + "metric": { + "type": "string", + "description": "Custom event name sent with telemetry event." + }, + "args": { + "type": "string", + "description": "Additional parameters for link action, example which specific menu the button should open" + } + } + }, + "section_title_icon": { + "type": "string", + "description": "Section title icon. 16x16px. SVG or PNG preferred. section_title_text must also be specified to display." + }, + "section_title_icon_dark_theme": { + "type": "string", + "description": "Section title icon, dark theme variant. 16x16px. SVG or PNG preferred. section_title_text must also be specified to display." + }, + "section_title_text": { + "type": "string", + "description": "Section title text. section_title_icon must also be specified to display." + }, + "section_title_url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "A url, section_title_text links to this"} + ] + } + }, + "additionalProperties": false, + "required": ["text"], + "dependencies": { + "button_action": ["button_label"], + "button_url": ["button_label"], + "button_color": ["button_label"], + "button_background_color": ["button_label"], + "section_title_url": ["section_title_text"], + "button_entrypoint_name": ["button_entrypoint_value"] + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/_SimpleSnippet.scss b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/_SimpleSnippet.scss new file mode 100644 index 0000000000..66bb8f9e35 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/_SimpleSnippet.scss @@ -0,0 +1,135 @@ +$section-header-height: 30px; +$icon-width: 54px; // width of primary icon + margin + +.SimpleSnippet { + &.tall { + padding: 27px 0; + } + + p em { + color: var(--newtab-text-emphasis-text-color); + font-style: normal; + background: var(--newtab-text-emphasis-background); + } + + &.bold, + &.takeover { + .donation-form-url, + .donation-amount { + padding-top: 8px; + padding-bottom: 8px; + } + } + + &.bold { + height: 176px; + + .body { + font-size: 14px; + line-height: 20px; + margin-bottom: 20px; + } + + .icon { + width: 71px; + height: 71px; + } + } + + &.takeover { + height: 344px; + + .body { + font-size: 16px; + line-height: 24px; + margin-bottom: 35px; + } + + .icon { + width: 79px; + height: 79px; + } + } + + .title { + font-size: inherit; + margin: 0; + } + + .title-inline { + display: inline; + } + + .titleIcon { + background-repeat: no-repeat; + background-size: 14px; + background-position: center; + height: 16px; + width: 16px; + margin-top: 2px; + margin-inline-end: 2px; + display: inline-block; + vertical-align: top; + } + + .body { + display: inline; + margin: 0; + } + + &.tall .icon { + margin-inline-end: 20px; + } + + &.takeover, + &.bold { + .icon { + margin-inline-end: 20px; + } + } + + .icon { + align-self: flex-start; + } + + &.has-section-header .innerWrapper { + // account for section header being 100% width + flex-wrap: wrap; + padding-top: 7px; + } + + // wrapper div added if section-header is displayed that allows icon/text/button + // to squish instead of wrapping. this is effectively replicating layout behavior + // when section-header is *not* present. + .innerContentWrapper { + align-items: center; + display: flex; + } + + .section-header { + flex: 0 0 100%; + margin-bottom: 10px; + } + + .section-title { + // color should match that of 'Recommended by Pocket' and 'Highlights' in newtab page + color: var(--newtab-text-primary-color); + display: inline-block; + font-size: 13px; + font-weight: bold; + margin: 0; + + a { + color: var(--newtab-text-primary-color); + font-weight: inherit; + text-decoration: none; + } + + .icon { + height: 16px; + margin-inline-end: 6px; + margin-top: -2px; + width: 16px; + } + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormScene2Snippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormScene2Snippet.schema.json new file mode 100644 index 0000000000..f3dcde11af --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormScene2Snippet.schema.json @@ -0,0 +1,163 @@ +{ + "title": "SubmitFormSnippet", + "description": "A template with two states: a SimpleSnippet and another that contains a form", + "version": "1.2.0", + "type": "object", + "definitions": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "richText": { + "description": "Text with HTML subset allowed: i, b, u, strong, em, br", + "type": "string" + }, + "link_url": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + }, + "properties": { + "locale": { + "type": "string", + "description": "Two to five character string for the locale code" + }, + "country": { + "type": "string", + "description": "Two character string for the country code (used for SMS)" + }, + "section_title_icon": { + "type": "string", + "description": "Section title icon. 16x16px. SVG or PNG preferred. section_title_text must also be specified to display." + }, + "section_title_icon_dark_theme": { + "type": "string", + "description": "Section title icon, dark theme variant. 16x16px. SVG or PNG preferred. section_title_text must also be specified to display." + }, + "section_title_text": { + "type": "string", + "description": "Section title text. section_title_icon must also be specified to display." + }, + "section_title_url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "A url, section_title_text links to this"} + ] + }, + "scene2_text": { + "allOf": [ + {"$ref": "#/definitions/richText"}, + {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"} + ] + }, + "form_action": { + "type": "string", + "description": "Endpoint to submit form data." + }, + "success_title": { + "type": "string", + "description": "(send to device) Title shown before text on successful registration." + }, + "success_text": { + "type": "string", + "description": "Message shown on successful registration." + }, + "error_text": { + "type": "string", + "description": "Message shown if registration failed." + }, + "scene2_email_placeholder_text": { + "type": "string", + "description": "Value to show while input is empty." + }, + "scene2_input_placeholder": { + "type": "string", + "description": "(send to device) Value to show while input is empty." + }, + "scene2_button_label": { + "type": "string", + "description": "Label for form submit button" + }, + "scene2_privacy_html": { + "type": "string", + "description": "Information about how the form data is used." + }, + "scene2_disclaimer_html": { + "type": "string", + "description": "(send to device) Html for disclaimer and link underneath input box." + }, + "scene2_icon": { + "type": "string", + "description": "(send to device) Image to display above the form. 98x98px. SVG or PNG preferred." + }, + "scene2_icon_dark_theme": { + "type": "string", + "description": "(send to device) Image to display above the form. Dark theme variant. 98x98px. SVG or PNG preferred." + }, + "scene2_icon_alt_text": { + "type": "string", + "description": "Alt text describing scene2 icon for screen readers", + "default": "" + }, + "scene2_newsletter": { + "type": "string", + "description": "Newsletter/basket id user is subscribing to. Must be a value from the 'Slug' column here: https://basket.mozilla.org/news/. Default 'mozilla-foundation'." + }, + "hidden_inputs": { + "type": "object", + "description": "Each entry represents a hidden input, key is used as value for the name property." + }, + "retry_button_label": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Text for the button in the event of a submission error/failure."} + ], + "default": "Try again" + }, + "do_not_autoblock": { + "type": "boolean", + "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked" + }, + "include_sms": { + "type": "boolean", + "description": "(send to device) Allow users to send an SMS message with the form?" + }, + "message_id_sms": { + "type": "string", + "description": "(send to device) Newsletter/basket id representing the SMS message to be sent." + }, + "message_id_email": { + "type": "string", + "description": "(send to device) Newsletter/basket id representing the email message to be sent. Must be a value from the 'Slug' column here: https://basket.mozilla.org/news/." + }, + "utm_campaign": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_campaign." + }, + "utm_term": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_term." + }, + "links": { + "additionalProperties": { + "url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "The url where the link points to."} + ] + }, + "metric": { + "type": "string", + "description": "Custom event name sent with telemetry event." + } + } + } + }, + "additionalProperties": false, + "required": ["scene2_text"], + "dependencies": { + "section_title_icon": ["section_title_text"], + "section_title_icon_dark_theme": ["section_title_text"] + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.jsx new file mode 100644 index 0000000000..deabbaeb09 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.jsx @@ -0,0 +1,408 @@ +/* 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 { Button } from "../../components/Button/Button"; +import React from "react"; +import { RichText } from "../../components/RichText/RichText"; +import { safeURI } from "../../template-utils"; +import { SimpleSnippet } from "../SimpleSnippet/SimpleSnippet"; +import { SnippetBase } from "../../components/SnippetBase/SnippetBase"; +import ConditionalWrapper from "../../components/ConditionalWrapper/ConditionalWrapper"; + +// Alt text placeholder in case the prop from the server isn't available +const ICON_ALT_TEXT = ""; + +export class SubmitFormSnippet extends React.PureComponent { + constructor(props) { + super(props); + this.expandSnippet = this.expandSnippet.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + this.handleSubmitAttempt = this.handleSubmitAttempt.bind(this); + this.onInputChange = this.onInputChange.bind(this); + this.state = { + expanded: false, + submitAttempted: false, + signupSubmitted: false, + signupSuccess: false, + disableForm: false, + }; + } + + handleSubmitAttempt() { + if (!this.state.submitAttempted) { + this.setState({ submitAttempted: true }); + } + } + + async handleSubmit(event) { + let json; + + if (this.state.disableForm) { + return; + } + + event.preventDefault(); + this.setState({ disableForm: true }); + this.props.sendUserActionTelemetry({ + event: "CLICK_BUTTON", + event_context: "conversion-subscribe-activation", + id: "NEWTAB_FOOTER_BAR_CONTENT", + }); + + if (this.props.form_method.toUpperCase() === "GET") { + this.props.onBlock({ preventDismiss: true }); + this.refs.form.submit(); + return; + } + + const { url, formData } = this.props.processFormData + ? this.props.processFormData(this.refs.mainInput, this.props) + : { url: this.refs.form.action, formData: new FormData(this.refs.form) }; + + try { + const fetchRequest = new Request(url, { + body: formData, + method: "POST", + credentials: "omit", + }); + const response = await fetch(fetchRequest); // eslint-disable-line fetch-options/no-fetch-credentials + json = await response.json(); + } catch (err) { + console.error(err); + } + + if (json && json.status === "ok") { + this.setState({ signupSuccess: true, signupSubmitted: true }); + if (!this.props.content.do_not_autoblock) { + this.props.onBlock({ preventDismiss: true }); + } + this.props.sendUserActionTelemetry({ + event: "CLICK_BUTTON", + event_context: "subscribe-success", + id: "NEWTAB_FOOTER_BAR_CONTENT", + }); + } else { + console.error( + "There was a problem submitting the form", + json || "[No JSON response]" + ); + this.setState({ signupSuccess: false, signupSubmitted: true }); + this.props.sendUserActionTelemetry({ + event: "CLICK_BUTTON", + event_context: "subscribe-error", + id: "NEWTAB_FOOTER_BAR_CONTENT", + }); + } + + this.setState({ disableForm: false }); + } + + expandSnippet() { + this.props.sendUserActionTelemetry({ + event: "CLICK_BUTTON", + event_context: "scene1-button-learn-more", + id: this.props.UISurface, + }); + + this.setState({ + expanded: true, + signupSuccess: false, + signupSubmitted: false, + }); + } + + renderHiddenFormInputs() { + const { hidden_inputs } = this.props.content; + + if (!hidden_inputs) { + return null; + } + + return Object.keys(hidden_inputs).map((key, idx) => ( + <input key={idx} type="hidden" name={key} value={hidden_inputs[key]} /> + )); + } + + renderDisclaimer() { + const { content } = this.props; + if (!content.scene2_disclaimer_html) { + return null; + } + return ( + <p className="disclaimerText"> + <RichText + text={content.scene2_disclaimer_html} + localization_id="disclaimer_html" + links={content.links} + doNotAutoBlock={true} + openNewWindow={true} + sendClick={this.props.sendClick} + /> + </p> + ); + } + + renderFormPrivacyNotice() { + const { content } = this.props; + if (!content.scene2_privacy_html) { + return null; + } + return ( + <p className="privacyNotice"> + <input + type="checkbox" + id="id_privacy" + name="privacy" + required="required" + /> + <label htmlFor="id_privacy"> + <RichText + text={content.scene2_privacy_html} + localization_id="privacy_html" + links={content.links} + doNotAutoBlock={true} + openNewWindow={true} + sendClick={this.props.sendClick} + /> + </label> + </p> + ); + } + + renderSignupSubmitted() { + const { content } = this.props; + const isSuccess = this.state.signupSuccess; + const successTitle = isSuccess && content.success_title; + const bodyText = isSuccess + ? { success_text: content.success_text } + : { error_text: content.error_text }; + const retryButtonText = content.retry_button_label; + return ( + <SnippetBase {...this.props}> + <div className="submissionStatus"> + {successTitle ? ( + <h2 className="submitStatusTitle">{successTitle}</h2> + ) : null} + <p> + <RichText + {...bodyText} + localization_id={isSuccess ? "success_text" : "error_text"} + /> + {isSuccess ? null : ( + <Button onClick={this.expandSnippet}>{retryButtonText}</Button> + )} + </p> + </div> + </SnippetBase> + ); + } + + onInputChange(event) { + if (!this.props.validateInput) { + return; + } + const hasError = this.props.validateInput( + event.target.value, + this.props.content + ); + event.target.setCustomValidity(hasError); + } + + wrapSectionHeader(url) { + return function(children) { + return <a href={url}>{children}</a>; + }; + } + + renderInput() { + const placholder = + this.props.content.scene2_email_placeholder_text || + this.props.content.scene2_input_placeholder; + return ( + <input + ref="mainInput" + type={this.props.inputType || "email"} + className={`mainInput${this.state.submitAttempted ? "" : " clean"}`} + name="email" + required={true} + placeholder={placholder} + onChange={this.props.validateInput ? this.onInputChange : null} + /> + ); + } + + renderForm() { + return ( + <form + action={this.props.form_action} + method={this.props.form_method} + onSubmit={this.handleSubmit} + ref="form" + > + {this.renderHiddenFormInputs()} + <div> + {this.renderInput()} + <button + type="submit" + className="ASRouterButton primary" + onClick={this.handleSubmitAttempt} + ref="formSubmitBtn" + > + {this.props.content.scene2_button_label} + </button> + </div> + {this.renderFormPrivacyNotice() || this.renderDisclaimer()} + </form> + ); + } + + renderScene2Icon() { + const { content } = this.props; + if (!content.scene2_icon) { + return null; + } + + return ( + <div className="scene2Icon"> + <img + src={safeURI(content.scene2_icon)} + className="icon-light-theme" + alt={content.scene2_icon_alt_text || ICON_ALT_TEXT} + /> + <img + src={safeURI(content.scene2_icon_dark_theme || content.scene2_icon)} + className="icon-dark-theme" + alt={content.scene2_icon_alt_text || ICON_ALT_TEXT} + /> + </div> + ); + } + + renderSignupView() { + const { content } = this.props; + const containerClass = `SubmitFormSnippet ${this.props.className}`; + return ( + <SnippetBase + {...this.props} + className={containerClass} + footerDismiss={true} + > + {this.renderScene2Icon()} + <div className="message"> + <p> + {content.scene2_title && ( + <h3 className="scene2Title">{content.scene2_title}</h3> + )}{" "} + {content.scene2_text && ( + <RichText + scene2_text={content.scene2_text} + localization_id="scene2_text" + /> + )} + </p> + </div> + {this.renderForm()} + </SnippetBase> + ); + } + + renderSectionHeader() { + const { props } = this; + + // an icon and text must be specified to render the section header + if (props.content.section_title_icon && props.content.section_title_text) { + const sectionTitleIconLight = safeURI(props.content.section_title_icon); + const sectionTitleIconDark = safeURI( + props.content.section_title_icon_dark_theme || + props.content.section_title_icon + ); + const sectionTitleURL = props.content.section_title_url; + + return ( + <div className="section-header"> + <h3 className="section-title"> + <ConditionalWrapper + wrap={this.wrapSectionHeader(sectionTitleURL)} + condition={sectionTitleURL} + > + <span + className="icon icon-small-spacer icon-light-theme" + style={{ backgroundImage: `url("${sectionTitleIconLight}")` }} + /> + <span + className="icon icon-small-spacer icon-dark-theme" + style={{ backgroundImage: `url("${sectionTitleIconDark}")` }} + /> + <span className="section-title-text"> + {props.content.section_title_text} + </span> + </ConditionalWrapper> + </h3> + </div> + ); + } + + return null; + } + + renderSignupViewAlt() { + const { content } = this.props; + const containerClass = `SubmitFormSnippet ${this.props.className} scene2Alt`; + return ( + <SnippetBase + {...this.props} + className={containerClass} + // Don't show bottom dismiss button + footerDismiss={false} + > + {this.renderSectionHeader()} + {this.renderScene2Icon()} + <div className="message"> + <p> + {content.scene2_text && ( + <RichText + scene2_text={content.scene2_text} + localization_id="scene2_text" + /> + )} + </p> + {this.renderForm()} + </div> + </SnippetBase> + ); + } + + getFirstSceneContent() { + return Object.keys(this.props.content) + .filter(key => key.includes("scene1")) + .reduce((acc, key) => { + acc[key.substr(7)] = this.props.content[key]; + return acc; + }, {}); + } + + render() { + const content = { ...this.props.content, ...this.getFirstSceneContent() }; + + if (this.state.signupSubmitted) { + return this.renderSignupSubmitted(); + } + // Render only scene 2 (signup view). Must check before `renderSignupView` + // to catch the Failure/Try again scenario where we want to return and render + // the scene again. + if (this.props.expandedAlt) { + return this.renderSignupViewAlt(); + } + if (this.state.expanded) { + return this.renderSignupView(); + } + return ( + <SimpleSnippet + {...this.props} + content={content} + onButtonClick={this.expandSnippet} + /> + ); + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json new file mode 100644 index 0000000000..0fc3128d1c --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json @@ -0,0 +1,225 @@ +{ + "title": "SubmitFormSnippet", + "description": "A template with two states: a SimpleSnippet and another that contains a form", + "version": "1.2.0", + "type": "object", + "definitions": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "richText": { + "description": "Text with HTML subset allowed: i, b, u, strong, em, br", + "type": "string" + }, + "link_url": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + }, + "properties": { + "locale": { + "type": "string", + "description": "Two to five character string for the locale code" + }, + "country": { + "type": "string", + "description": "Two character string for the country code (used for SMS)" + }, + "scene1_title": { + "allof": [ + {"$ref": "#/definitions/plainText"}, + {"description": "snippet title displayed before snippet text"} + ] + }, + "scene1_text": { + "allOf": [ + {"$ref": "#/definitions/richText"}, + {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"} + ] + }, + "scene1_section_title_icon": { + "type": "string", + "description": "Section title icon for scene 1. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display." + }, + "scene1_section_title_icon_dark_theme": { + "type": "string", + "description": "Section title icon for scene 1, dark theme variant. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display." + }, + "scene1_section_title_text": { + "type": "string", + "description": "Section title text for scene 1. scene1_section_title_icon must also be specified to display." + }, + "scene1_section_title_url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "A url, scene1_section_title_text links to this"} + ] + }, + "scene2_title": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Title displayed before text in scene 2. Should be plain text."} + ] + }, + "scene2_text": { + "allOf": [ + {"$ref": "#/definitions/richText"}, + {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"} + ] + }, + "scene1_icon": { + "type": "string", + "description": "Snippet icon. 64x64px. SVG or PNG preferred." + }, + "scene1_icon_dark_theme": { + "type": "string", + "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred." + }, + "scene1_icon_alt_text": { + "type": "string", + "description": "Alt text describing scene1 icon for screen readers", + "default": "" + }, + "scene1_title_icon": { + "type": "string", + "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale." + }, + "scene1_title_icon_dark_theme": { + "type": "string", + "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale." + }, + "scene1_title_icon_alt_text": { + "type": "string", + "description": "Alt text describing scene1 title icon for screen readers", + "default": "" + }, + "form_action": { + "type": "string", + "description": "Endpoint to submit form data." + }, + "success_title": { + "type": "string", + "description": "(send to device) Title shown before text on successful registration." + }, + "success_text": { + "type": "string", + "description": "Message shown on successful registration." + }, + "error_text": { + "type": "string", + "description": "Message shown if registration failed." + }, + "scene2_email_placeholder_text": { + "type": "string", + "description": "Value to show while input is empty." + }, + "scene2_input_placeholder": { + "type": "string", + "description": "(send to device) Value to show while input is empty." + }, + "scene2_button_label": { + "type": "string", + "description": "Label for form submit button" + }, + "scene2_privacy_html": { + "type": "string", + "description": "Information about how the form data is used." + }, + "scene2_disclaimer_html": { + "type": "string", + "description": "(send to device) Html for disclaimer and link underneath input box." + }, + "scene2_dismiss_button_text": { + "type": "string", + "description": "Label for the dismiss button when the sign-up form is expanded." + }, + "scene2_icon": { + "type": "string", + "description": "(send to device) Image to display above the form. 98x98px. SVG or PNG preferred." + }, + "scene2_icon_dark_theme": { + "type": "string", + "description": "(send to device) Image to display above the form. Dark theme variant. 98x98px. SVG or PNG preferred." + }, + "scene2_icon_alt_text": { + "type": "string", + "description": "Alt text describing scene2 icon for screen readers", + "default": "" + }, + "scene2_newsletter": { + "type": "string", + "description": "Newsletter/basket id user is subscribing to. Must be a value from the 'Slug' column here: https://basket.mozilla.org/news/. Default 'mozilla-foundation'." + }, + "hidden_inputs": { + "type": "object", + "description": "Each entry represents a hidden input, key is used as value for the name property." + }, + "scene1_button_label": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Text for a button next to main snippet text that links to button_url. Requires button_url."} + ] + }, + "scene1_button_color": { + "type": "string", + "description": "The text color of the button. Valid CSS color." + }, + "scene1_button_background_color": { + "type": "string", + "description": "The background color of the button. Valid CSS color." + }, + "retry_button_label": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Text for the button in the event of a submission error/failure."} + ], + "default": "Try again" + }, + "do_not_autoblock": { + "type": "boolean", + "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked" + }, + "include_sms": { + "type": "boolean", + "description": "(send to device) Allow users to send an SMS message with the form?" + }, + "message_id_sms": { + "type": "string", + "description": "(send to device) Newsletter/basket id representing the SMS message to be sent." + }, + "message_id_email": { + "type": "string", + "description": "(send to device) Newsletter/basket id representing the email message to be sent. Must be a value from the 'Slug' column here: https://basket.mozilla.org/news/." + }, + "utm_campaign": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_campaign." + }, + "utm_term": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_term." + }, + "links": { + "additionalProperties": { + "url": { + "allOf": [ + {"$ref": "#/definitions/link_url"}, + {"description": "The url where the link points to."} + ] + }, + "metric": { + "type": "string", + "description": "Custom event name sent with telemetry event." + } + } + } + }, + "additionalProperties": false, + "required": ["scene1_text", "scene2_text", "scene1_button_label"], + "dependencies": { + "scene1_button_color": ["scene1_button_label"], + "scene1_button_background_color": ["scene1_button_label"] + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/_SubmitFormSnippet.scss b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/_SubmitFormSnippet.scss new file mode 100644 index 0000000000..3c1738aef0 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/_SubmitFormSnippet.scss @@ -0,0 +1,176 @@ +.SubmitFormSnippet { + flex-direction: column; + flex: 1 1 100%; + width: 100%; + + .disclaimerText { + margin: 5px 0 0; + font-size: 12px; + color: var(--newtab-text-secondary-color); + } + + p { + margin: 0; + } + + &.send_to_device_snippet { + text-align: center; + + .message { + font-size: 16px; + margin-bottom: 20px; + } + + .scene2Title { + font-size: 24px; + display: block; + } + } + + .ASRouterButton { + &.primary { + flex: 1 1 0; + } + } + + .scene2Icon { + width: 100%; + margin-bottom: 20px; + + img { + width: 98px; + display: inline-block; + } + } + + .scene2Title { + font-size: inherit; + margin: 0 0 10px; + font-weight: bold; + display: inline; + } + + form { + display: flex; + flex-direction: column; + width: 100%; + } + + .message { + font-size: 14px; + align-self: stretch; + flex: 0 0 100%; + margin-bottom: 10px; + } + + .privacyNotice { + font-size: 12px; + color: var(--newtab-text-secondary-color); + margin-top: 10px; + display: flex; + flex: 0 0 100%; + } + + .innerWrapper { + // https://github.com/mozmeao/snippets/blob/2054899350590adcb3c0b0a341c782b0e2f81d0b/activity-stream/newsletter-subscribe.html#L46 + max-width: 736px; + flex-wrap: wrap; + justify-items: center; + padding-top: 40px; + padding-bottom: 40px; + } + + .footer { + width: 100%; + margin: 0 auto; + text-align: right; + background-color: var(--newtab-background-color); + padding: 10px 0; + + .footer-content { + margin: 0 auto; + max-width: 768px; + width: 100%; + text-align: right; + + [dir='rtl'] & { + text-align: left; + } + } + } + + input { + &.mainInput { + border-radius: 2px; + background-color: var(--newtab-background-color-secondary); + border: $input-border; + padding: 0 8px; + height: 100%; + font-size: 14px; + width: 50%; + + &.clean { + &:invalid, + &:required { + box-shadow: none; + } + } + + &:focus { + border: $input-border-active; + box-shadow: var(--newtab-textbox-focus-boxshadow); + } + } + } + + &.scene2Alt { + text-align: start; + + .scene2Icon { + flex: 1; + margin-bottom: 0; + } + + .message { + flex: 5; + margin-bottom: 0; + + p { + margin-bottom: 10px; + } + } + + .section-header { + width: 100%; + + .icon { + width: 16px; + height: 16px; + } + } + + .section-title { + font-size: 13px; + } + + .section-title a { + color: var(--newtab-text-primary-color); + font-weight: inherit; + text-decoration: none; + } + + .innerWrapper { + padding: 0 0 16px; + } + } +} + +.submissionStatus { + text-align: center; + font-size: 14px; + padding: 20px 0; + + .submitStatusTitle { + font-size: 20px; + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/ToastNotification/ToastNotification.schema.json b/browser/components/newtab/content-src/asrouter/templates/ToastNotification/ToastNotification.schema.json new file mode 100644 index 0000000000..e254080311 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/ToastNotification/ToastNotification.schema.json @@ -0,0 +1,95 @@ +{ + "$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" + }, + "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`." + } + }, + "required": ["action", "title"], + "additionalProperties": true + } + } + }, + "additionalProperties": true, + "required": [ + "title", + "body" + ] + }, + "template": { + "type": "string", + "const": "toast_notification" + } + }, + "required": [ + "content", + "targeting", + "template", + "trigger" + ], + "additionalProperties": true +} diff --git a/browser/components/newtab/content-src/asrouter/templates/template-manifest.jsx b/browser/components/newtab/content-src/asrouter/templates/template-manifest.jsx new file mode 100644 index 0000000000..57f8afa6f5 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/template-manifest.jsx @@ -0,0 +1,24 @@ +/* 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 { EOYSnippet } from "./EOYSnippet/EOYSnippet"; +import { FXASignupSnippet } from "./FXASignupSnippet/FXASignupSnippet"; +import { NewsletterSnippet } from "./NewsletterSnippet/NewsletterSnippet"; +import { + SendToDeviceSnippet, + SendToDeviceScene2Snippet, +} from "./SendToDeviceSnippet/SendToDeviceSnippet"; +import { SimpleBelowSearchSnippet } from "./SimpleBelowSearchSnippet/SimpleBelowSearchSnippet"; +import { SimpleSnippet } from "./SimpleSnippet/SimpleSnippet"; + +// Key names matching schema name of templates +export const SnippetsTemplates = { + simple_snippet: SimpleSnippet, + newsletter_snippet: NewsletterSnippet, + fxa_signup_snippet: FXASignupSnippet, + send_to_device_snippet: SendToDeviceSnippet, + send_to_device_scene2_snippet: SendToDeviceScene2Snippet, + eoy_snippet: EOYSnippet, + simple_below_search_snippet: SimpleBelowSearchSnippet, +}; |