274 lines
8.5 KiB
JavaScript
274 lines
8.5 KiB
JavaScript
import {actionCreators as ac, ASRouterActions as ra} from "common/Actions.jsm";
|
|
import {LocalizationProvider, Localized} from "fluent-react";
|
|
import {OUTGOING_MESSAGE_NAME as AS_GENERAL_OUTGOING_MESSAGE_NAME} from "content-src/lib/init-store";
|
|
import {ImpressionsWrapper} from "./components/ImpressionsWrapper/ImpressionsWrapper";
|
|
import {MessageContext} from "fluent";
|
|
import {OnboardingMessage} from "./templates/OnboardingMessage/OnboardingMessage";
|
|
import React from "react";
|
|
import ReactDOM from "react-dom";
|
|
import {safeURI} from "./template-utils";
|
|
import {SimpleSnippet} from "./templates/SimpleSnippet/SimpleSnippet";
|
|
|
|
const INCOMING_MESSAGE_NAME = "ASRouter:parent-to-child";
|
|
const OUTGOING_MESSAGE_NAME = "ASRouter:child-to-parent";
|
|
|
|
export const ASRouterUtils = {
|
|
addListener(listener) {
|
|
global.RPMAddMessageListener(INCOMING_MESSAGE_NAME, listener);
|
|
},
|
|
removeListener(listener) {
|
|
global.RPMRemoveMessageListener(INCOMING_MESSAGE_NAME, listener);
|
|
},
|
|
sendMessage(action) {
|
|
global.RPMSendAsyncMessage(OUTGOING_MESSAGE_NAME, action);
|
|
},
|
|
blockById(id) {
|
|
ASRouterUtils.sendMessage({type: "BLOCK_MESSAGE_BY_ID", data: {id}});
|
|
},
|
|
blockBundle(bundle) {
|
|
ASRouterUtils.sendMessage({type: "BLOCK_BUNDLE", data: {bundle}});
|
|
},
|
|
executeAction({button_action, button_action_params}) {
|
|
if (button_action in ra) {
|
|
ASRouterUtils.sendMessage({type: button_action, data: {button_action_params}});
|
|
}
|
|
},
|
|
unblockById(id) {
|
|
ASRouterUtils.sendMessage({type: "UNBLOCK_MESSAGE_BY_ID", data: {id}});
|
|
},
|
|
unblockBundle(bundle) {
|
|
ASRouterUtils.sendMessage({type: "UNBLOCK_BUNDLE", data: {bundle}});
|
|
},
|
|
getNextMessage() {
|
|
ASRouterUtils.sendMessage({type: "GET_NEXT_MESSAGE"});
|
|
},
|
|
overrideMessage(id) {
|
|
ASRouterUtils.sendMessage({type: "OVERRIDE_MESSAGE", data: {id}});
|
|
},
|
|
sendTelemetry(ping) {
|
|
const payload = ac.ASRouterUserEvent(ping);
|
|
global.RPMSendAsyncMessage(AS_GENERAL_OUTGOING_MESSAGE_NAME, payload);
|
|
},
|
|
getEndpoint() {
|
|
if (window.location.href.includes("endpoint")) {
|
|
const params = new URLSearchParams(window.location.href.slice(window.location.href.indexOf("endpoint")));
|
|
try {
|
|
const endpoint = new URL(params.get("endpoint"));
|
|
return {
|
|
url: endpoint.href,
|
|
snippetId: params.get("snippetId")
|
|
};
|
|
} catch (e) {}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// 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));
|
|
}
|
|
|
|
function generateMessages(content) {
|
|
const cx = new MessageContext("en-US");
|
|
cx.addMessages(`RichTextSnippet = ${content}`);
|
|
return [cx];
|
|
}
|
|
|
|
// 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) {
|
|
if (links) {
|
|
return Object.keys(links).reduce((acc, linkTag) => {
|
|
acc[linkTag] = <a href={safeURI(links[linkTag].url)} data-metric={links[linkTag].metric} onClick={sendClick} />;
|
|
return acc;
|
|
}, {});
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Message wrapper used to sanitize markup and render HTML.
|
|
*/
|
|
function RichText(props) {
|
|
return (
|
|
<Localized id="RichTextSnippet" {...ALLOWED_TAGS} {...convertLinks(props.links, props.sendClick)}>
|
|
<span>{props.text}</span>
|
|
</Localized>
|
|
);
|
|
}
|
|
|
|
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.state = {message: {}, bundle: {}};
|
|
}
|
|
|
|
sendUserActionTelemetry(extraProps = {}) {
|
|
const {message, bundle} = this.state;
|
|
if (!message && !extraProps.message_id) {
|
|
throw new Error(`You must provide a message_id for bundled messages`);
|
|
}
|
|
const eventType = `${message.provider || bundle.provider}_user_event`;
|
|
ASRouterUtils.sendTelemetry({
|
|
message_id: message.id || extraProps.message_id,
|
|
source: extraProps.id,
|
|
action: eventType,
|
|
...extraProps
|
|
});
|
|
}
|
|
|
|
sendImpression(extraProps) {
|
|
this.sendUserActionTelemetry({event: "IMPRESSION", ...extraProps});
|
|
}
|
|
|
|
// If link has a `metric` data attribute send it as part of the `value`
|
|
// telemetry field which can have arbitrary values.
|
|
// Used for router messages with links as part of the content.
|
|
sendClick(event) {
|
|
const metric = {
|
|
value: event.target.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"
|
|
};
|
|
this.sendUserActionTelemetry({event: "CLICK_BUTTON", ...metric});
|
|
}
|
|
|
|
onBlockById(id) {
|
|
return () => ASRouterUtils.blockById(id);
|
|
}
|
|
|
|
clearBundle(bundle) {
|
|
return () => ASRouterUtils.blockBundle(bundle);
|
|
}
|
|
|
|
onMessageFromParent({data: action}) {
|
|
switch (action.type) {
|
|
case "SET_MESSAGE":
|
|
this.setState({message: action.data});
|
|
break;
|
|
case "SET_BUNDLED_MESSAGES":
|
|
this.setState({bundle: action.data});
|
|
break;
|
|
case "CLEAR_MESSAGE":
|
|
if (action.data.id === this.state.message.id) {
|
|
this.setState({message: {}});
|
|
}
|
|
break;
|
|
case "CLEAR_BUNDLE":
|
|
if (this.state.bundle.bundle) {
|
|
this.setState({bundle: {}});
|
|
}
|
|
break;
|
|
case "CLEAR_ALL":
|
|
this.setState({message: {}, bundle: {}});
|
|
}
|
|
}
|
|
|
|
componentWillMount() {
|
|
const endpoint = ASRouterUtils.getEndpoint();
|
|
ASRouterUtils.addListener(this.onMessageFromParent);
|
|
|
|
// If we are loading about:welcome we want to trigger the onboarding messages
|
|
if (this.props.document.location.href === "about:welcome") {
|
|
ASRouterUtils.sendMessage({type: "TRIGGER", data: {trigger: "firstRun"}});
|
|
} else {
|
|
ASRouterUtils.sendMessage({type: "CONNECT_UI_REQUEST", data: {endpoint}});
|
|
}
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
ASRouterUtils.removeListener(this.onMessageFromParent);
|
|
}
|
|
|
|
renderSnippets() {
|
|
return (
|
|
<ImpressionsWrapper
|
|
id="NEWTAB_FOOTER_BAR"
|
|
message={this.state.message}
|
|
sendImpression={this.sendImpression}
|
|
shouldSendImpressionOnUpdate={shouldSendImpressionOnUpdate}
|
|
// This helps with testing
|
|
document={this.props.document}>
|
|
<LocalizationProvider messages={generateMessages(this.state.message.content.text)}>
|
|
<SimpleSnippet
|
|
{...this.state.message}
|
|
richText={<RichText text={this.state.message.content.text}
|
|
links={this.state.message.content.links}
|
|
sendClick={this.sendClick} />}
|
|
UISurface="NEWTAB_FOOTER_BAR"
|
|
getNextMessage={ASRouterUtils.getNextMessage}
|
|
onBlock={this.onBlockById(this.state.message.id)}
|
|
sendUserActionTelemetry={this.sendUserActionTelemetry} />
|
|
</LocalizationProvider>
|
|
</ImpressionsWrapper>);
|
|
}
|
|
|
|
renderOnboarding() {
|
|
return (
|
|
<OnboardingMessage
|
|
{...this.state.bundle}
|
|
UISurface="NEWTAB_OVERLAY"
|
|
onAction={ASRouterUtils.executeAction}
|
|
onDoneButton={this.clearBundle(this.state.bundle.bundle)}
|
|
getNextMessage={ASRouterUtils.getNextMessage}
|
|
sendUserActionTelemetry={this.sendUserActionTelemetry} />);
|
|
}
|
|
|
|
render() {
|
|
const {message, bundle} = this.state;
|
|
if (!message.id && !bundle.template) { return null; }
|
|
if (bundle.template === "onboarding") { return this.renderOnboarding(); }
|
|
return this.renderSnippets();
|
|
}
|
|
}
|
|
|
|
ASRouterUISurface.defaultProps = {document: global.document};
|
|
|
|
export class ASRouterContent {
|
|
constructor() {
|
|
this.initialized = false;
|
|
this.containerElement = null;
|
|
}
|
|
|
|
_mount() {
|
|
this.containerElement = global.document.getElementById("snippets-container");
|
|
ReactDOM.render(<ASRouterUISurface />, this.containerElement);
|
|
}
|
|
|
|
_unmount() {
|
|
ReactDOM.unmountComponentAtNode(this.containerElement);
|
|
}
|
|
|
|
init() {
|
|
this._mount();
|
|
this.initialized = true;
|
|
}
|
|
|
|
uninit() {
|
|
if (this.initialized) {
|
|
this._unmount();
|
|
this.initialized = false;
|
|
}
|
|
}
|
|
}
|