/* 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 { actionCreators as ac, actionTypes as at, ASRouterActions as ra, } from "common/Actions.jsm"; import { OUTGOING_MESSAGE_NAME as AS_GENERAL_OUTGOING_MESSAGE_NAME } from "content-src/lib/init-store"; 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"; import { FirstRun } from "./templates/FirstRun/FirstRun"; const INCOMING_MESSAGE_NAME = "ASRouter:parent-to-child"; const OUTGOING_MESSAGE_NAME = "ASRouter:child-to-parent"; const TEMPLATES_ABOVE_PAGE = [ "trailhead", "full_page_interrupt", "return_to_amo_overlay", "extended_triplets", ]; const FIRST_RUN_TEMPLATES = TEMPLATES_ABOVE_PAGE; const TEMPLATES_BELOW_SEARCH = ["simple_below_search_snippet"]; export const ASRouterUtils = { addListener(listener) { if (global.RPMAddMessageListener) { global.RPMAddMessageListener(INCOMING_MESSAGE_NAME, listener); } }, removeListener(listener) { if (global.RPMRemoveMessageListener) { global.RPMRemoveMessageListener(INCOMING_MESSAGE_NAME, listener); } }, sendMessage(action) { if (global.RPMSendAsyncMessage) { global.RPMSendAsyncMessage(OUTGOING_MESSAGE_NAME, action); } }, blockById(id, options) { ASRouterUtils.sendMessage({ type: "BLOCK_MESSAGE_BY_ID", data: { id, ...options }, }); }, modifyMessageJson(content) { ASRouterUtils.sendMessage({ type: "MODIFY_MESSAGE_JSON", data: { content }, }); }, dismissById(id) { ASRouterUtils.sendMessage({ type: "DISMISS_MESSAGE_BY_ID", data: { id } }); }, executeAction(button_action) { ASRouterUtils.sendMessage({ type: "USER_ACTION", data: button_action, }); }, unblockById(id) { ASRouterUtils.sendMessage({ type: "UNBLOCK_MESSAGE_BY_ID", data: { id } }); }, blockBundle(bundle) { ASRouterUtils.sendMessage({ type: "BLOCK_BUNDLE", data: { bundle } }); }, unblockBundle(bundle) { ASRouterUtils.sendMessage({ type: "UNBLOCK_BUNDLE", data: { bundle } }); }, overrideMessage(id) { ASRouterUtils.sendMessage({ type: "OVERRIDE_MESSAGE", data: { id } }); }, sendTelemetry(ping) { if (global.RPMSendAsyncMessage) { const payload = ac.ASRouterUserEvent(ping); global.RPMSendAsyncMessage(AS_GENERAL_OUTGOING_MESSAGE_NAME, payload); } }, getPreviewEndpoint() { if (global.location && global.location.href.includes("endpoint")) { const params = new URLSearchParams( global.location.href.slice(global.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.location.href.slice(global.location.href.indexOf("theme")) ).get("theme"); }, getPreviewDir() { return new URLSearchParams( global.location.href.slice(global.location.href.indexOf("dir")) ).get("dir"); }, }; // 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.onMessageFromParent = this.onMessageFromParent.bind(this); 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.state = { message: {}, interruptCleared: false }; if (props.document) { this.headerPortal = props.document.getElementById( "header-asrouter-container" ); this.footerPortal = props.document.getElementById( "footer-asrouter-container" ); } } async fetchFlowParams(params = {}) { let result = {}; const { fxaEndpoint, dispatch } = this.props; if (!fxaEndpoint) { const err = "Tried to fetch flow params before fxaEndpoint pref was ready"; console.error(err); // eslint-disable-line no-console } 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); // eslint-disable-line no-console dispatch( ac.OnlyToMain({ type: at.TELEMETRY_UNDESIRED_EVENT, data: { event: "FXA_METRICS_FETCH_ERROR", value: response.status, }, }) ); } } catch (error) { console.error(error); // eslint-disable-line no-console dispatch( ac.OnlyToMain({ type: at.TELEMETRY_UNDESIRED_EVENT, data: { event: "FXA_METRICS_ERROR" }, }) ); } return result; } sendUserActionTelemetry(extraProps = {}) { const { message } = this.state; const eventType = `${message.provider}_user_event`; ASRouterUtils.sendTelemetry({ message_id: message.id, source: extraProps.id, action: eventType, ...extraProps, }); } sendImpression(extraProps) { if (this.state.message.provider === "preview") { return; } ASRouterUtils.sendMessage({ type: "IMPRESSION", data: this.state.message }); this.sendUserActionTelemetry({ event: "IMPRESSION", ...extraProps }); } // 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 ) { ASRouterUtils.blockById(this.state.message.id); } if (this.state.message.provider !== "preview") { this.sendUserActionTelemetry({ event: "CLICK_BUTTON", ...metric }); } } onBlockById(id) { return options => ASRouterUtils.blockById(id, options); } onDismissById(id) { return () => ASRouterUtils.dismissById(id); } clearMessage(id) { // Request new set of dynamic triplet cards when click on a card CTA clear // message and 'id' matches one of the cards in message bundle if ( this.state.message && this.state.message.bundle && this.state.message.bundle.find(card => card.id === id) ) { this.requestMessage(); } if (id === this.state.message.id) { this.setState({ message: {} }); // Remove any styles related to the RTAMO message document.body.classList.remove("welcome", "hide-main", "amo"); } } onMessageFromParent({ data: action }) { switch (action.type) { case "SET_MESSAGE": this.setState({ message: action.data }); break; case "CLEAR_INTERRUPT": this.setState({ interruptCleared: true }); break; case "CLEAR_MESSAGE": this.clearMessage(action.data.id); break; case "CLEAR_PROVIDER": if (action.data.id === this.state.message.provider) { this.setState({ message: {} }); } break; case "CLEAR_ALL": this.setState({ message: {} }); break; case "AS_ROUTER_TARGETING_UPDATE": action.data.forEach(id => this.clearMessage(id)); break; } } requestMessage(endpoint) { // If we are loading about:welcome we want to trigger the onboarding messages if ( this.props.document && this.props.document.location.href === "about:welcome" ) { ASRouterUtils.sendMessage({ type: "TRIGGER", data: { trigger: { id: "firstRun" } }, }); } else { ASRouterUtils.sendMessage({ type: "NEWTAB_MESSAGE_REQUEST", data: { endpoint }, }); } } 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); } 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 ra.ENABLE_FIREFOX_MONITOR: const url = await this.getMonitorUrl(action.data.args); ASRouterUtils.executeAction({ type: ra.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 } = this.state.message; return ( ); } renderPreviewBanner() { if (this.state.message.provider !== "preview") { return null; } return (
Preview Purposes Only
); } renderFirstRun() { const { message } = this.state; if (FIRST_RUN_TEMPLATES.includes(message.template)) { return ( ); } return null; } render() { const { message } = this.state; if (!message.id) { return null; } const shouldRenderBelowSearch = TEMPLATES_BELOW_SEARCH.includes( message.template ); const shouldRenderInHeader = TEMPLATES_ABOVE_PAGE.includes( message.template ); return shouldRenderBelowSearch ? ( // Render special below search snippets in place;
{this.renderSnippets()}
) : ( // For onboarding, regular snippets etc. we should render // everything in our footer container. ReactDOM.createPortal( <> {this.renderPreviewBanner()} {this.renderFirstRun()} {this.renderSnippets()} , shouldRenderInHeader ? this.headerPortal : this.footerPortal ) ); } } ASRouterUISurface.defaultProps = { document: global.document };