mirror of
https://github.com/mozilla/gecko-dev.git
synced 2025-11-08 20:28:42 +02:00
469 lines
14 KiB
JavaScript
469 lines
14 KiB
JavaScript
/* 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 <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.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 (
|
|
<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.onBlockById(this.state.message.id)}
|
|
onDismiss={this.onDismissById(this.state.message.id)}
|
|
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>
|
|
);
|
|
}
|
|
|
|
renderFirstRun() {
|
|
const { message } = this.state;
|
|
if (FIRST_RUN_TEMPLATES.includes(message.template)) {
|
|
return (
|
|
<ImpressionsWrapper
|
|
id="FIRST_RUN"
|
|
message={this.state.message}
|
|
sendImpression={this.sendImpression}
|
|
shouldSendImpressionOnUpdate={shouldSendImpressionOnUpdate}
|
|
// This helps with testing
|
|
document={this.props.document}
|
|
>
|
|
<FirstRun
|
|
document={this.props.document}
|
|
interruptCleared={this.state.interruptCleared}
|
|
message={message}
|
|
sendUserActionTelemetry={this.sendUserActionTelemetry}
|
|
executeAction={ASRouterUtils.executeAction}
|
|
dispatch={this.props.dispatch}
|
|
onBlockById={ASRouterUtils.blockById}
|
|
onDismiss={this.onDismissById(this.state.message.id)}
|
|
fxaEndpoint={this.props.fxaEndpoint}
|
|
appUpdateChannel={this.props.appUpdateChannel}
|
|
fetchFlowParams={this.fetchFlowParams}
|
|
/>
|
|
</ImpressionsWrapper>
|
|
);
|
|
}
|
|
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;
|
|
<div className="below-search-snippet-wrapper">
|
|
{this.renderSnippets()}
|
|
</div>
|
|
) : (
|
|
// 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 };
|