/* 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 , not 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 ( ); } renderPreviewBanner() { if (this.state.message.provider !== "preview") { return null; } return (
Preview Purposes Only
); } 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;
{this.renderSnippets()}
) : ( // 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 };