asrouter-content.jsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. import {addLocaleData, IntlProvider} from "react-intl";
  2. import {actionCreators as ac} from "common/Actions.jsm";
  3. import {OUTGOING_MESSAGE_NAME as AS_GENERAL_OUTGOING_MESSAGE_NAME} from "content-src/lib/init-store";
  4. import {generateBundles} from "./rich-text-strings";
  5. import {ImpressionsWrapper} from "./components/ImpressionsWrapper/ImpressionsWrapper";
  6. import {LocalizationProvider} from "fluent-react";
  7. import {NEWTAB_DARK_THEME} from "content-src/lib/constants";
  8. import {OnboardingMessage} from "./templates/OnboardingMessage/OnboardingMessage";
  9. import React from "react";
  10. import ReactDOM from "react-dom";
  11. import {ReturnToAMO} from "./templates/ReturnToAMO/ReturnToAMO";
  12. import {SnippetsTemplates} from "./templates/template-manifest";
  13. import {StartupOverlay} from "./templates/StartupOverlay/StartupOverlay";
  14. import {Trailhead} from "./templates/Trailhead/Trailhead";
  15. const INCOMING_MESSAGE_NAME = "ASRouter:parent-to-child";
  16. const OUTGOING_MESSAGE_NAME = "ASRouter:child-to-parent";
  17. const TEMPLATES_ABOVE_PAGE = ["trailhead"];
  18. const TEMPLATES_BELOW_SEARCH = ["simple_below_search_snippet"];
  19. export const ASRouterUtils = {
  20. addListener(listener) {
  21. if (global.RPMAddMessageListener) {
  22. global.RPMAddMessageListener(INCOMING_MESSAGE_NAME, listener);
  23. }
  24. },
  25. removeListener(listener) {
  26. if (global.RPMRemoveMessageListener) {
  27. global.RPMRemoveMessageListener(INCOMING_MESSAGE_NAME, listener);
  28. }
  29. },
  30. sendMessage(action) {
  31. if (global.RPMSendAsyncMessage) {
  32. global.RPMSendAsyncMessage(OUTGOING_MESSAGE_NAME, action);
  33. }
  34. },
  35. blockById(id, options) {
  36. ASRouterUtils.sendMessage({type: "BLOCK_MESSAGE_BY_ID", data: {id, ...options}});
  37. },
  38. dismissById(id) {
  39. ASRouterUtils.sendMessage({type: "DISMISS_MESSAGE_BY_ID", data: {id}});
  40. },
  41. dismissBundle(bundle) {
  42. ASRouterUtils.sendMessage({type: "DISMISS_BUNDLE", data: {bundle}});
  43. },
  44. executeAction(button_action) {
  45. ASRouterUtils.sendMessage({
  46. type: "USER_ACTION",
  47. data: button_action,
  48. });
  49. },
  50. unblockById(id) {
  51. ASRouterUtils.sendMessage({type: "UNBLOCK_MESSAGE_BY_ID", data: {id}});
  52. },
  53. unblockBundle(bundle) {
  54. ASRouterUtils.sendMessage({type: "UNBLOCK_BUNDLE", data: {bundle}});
  55. },
  56. overrideMessage(id) {
  57. ASRouterUtils.sendMessage({type: "OVERRIDE_MESSAGE", data: {id}});
  58. },
  59. sendTelemetry(ping) {
  60. if (global.RPMSendAsyncMessage) {
  61. const payload = ac.ASRouterUserEvent(ping);
  62. global.RPMSendAsyncMessage(AS_GENERAL_OUTGOING_MESSAGE_NAME, payload);
  63. }
  64. },
  65. getPreviewEndpoint() {
  66. if (global.location && global.location.href.includes("endpoint")) {
  67. const params = new URLSearchParams(global.location.href.slice(global.location.href.indexOf("endpoint")));
  68. try {
  69. const endpoint = new URL(params.get("endpoint"));
  70. return {
  71. url: endpoint.href,
  72. snippetId: params.get("snippetId"),
  73. theme: this.getPreviewTheme(),
  74. };
  75. } catch (e) {}
  76. }
  77. return null;
  78. },
  79. getPreviewTheme() {
  80. return new URLSearchParams(global.location.href.slice(global.location.href.indexOf("theme"))).get("theme");
  81. },
  82. };
  83. // Note: nextProps/prevProps refer to props passed to <ImpressionsWrapper />, not <ASRouterUISurface />
  84. function shouldSendImpressionOnUpdate(nextProps, prevProps) {
  85. return (nextProps.message.id && (!prevProps.message || prevProps.message.id !== nextProps.message.id));
  86. }
  87. export class ASRouterUISurface extends React.PureComponent {
  88. constructor(props) {
  89. super(props);
  90. this.onMessageFromParent = this.onMessageFromParent.bind(this);
  91. this.sendClick = this.sendClick.bind(this);
  92. this.sendImpression = this.sendImpression.bind(this);
  93. this.sendUserActionTelemetry = this.sendUserActionTelemetry.bind(this);
  94. this.state = {message: {}, bundle: {}};
  95. if (props.document) {
  96. this.headerPortal = props.document.getElementById("header-asrouter-container");
  97. this.footerPortal = props.document.getElementById("footer-asrouter-container");
  98. }
  99. }
  100. sendUserActionTelemetry(extraProps = {}) {
  101. const {message, bundle} = this.state;
  102. if (!message && !extraProps.message_id) {
  103. throw new Error(`You must provide a message_id for bundled messages`);
  104. }
  105. // snippets_user_event, onboarding_user_event
  106. const eventType = `${message.provider || bundle.provider}_user_event`;
  107. ASRouterUtils.sendTelemetry({
  108. message_id: message.id || extraProps.message_id,
  109. source: extraProps.id,
  110. action: eventType,
  111. ...extraProps,
  112. });
  113. }
  114. sendImpression(extraProps) {
  115. if (this.state.message.provider === "preview") {
  116. return;
  117. }
  118. ASRouterUtils.sendMessage({type: "IMPRESSION", data: this.state.message});
  119. this.sendUserActionTelemetry({event: "IMPRESSION", ...extraProps});
  120. }
  121. // If link has a `metric` data attribute send it as part of the `value`
  122. // telemetry field which can have arbitrary values.
  123. // Used for router messages with links as part of the content.
  124. sendClick(event) {
  125. const metric = {
  126. value: event.target.dataset.metric,
  127. // Used for the `source` of the event. Needed to differentiate
  128. // from other snippet or onboarding events that may occur.
  129. id: "NEWTAB_FOOTER_BAR_CONTENT",
  130. };
  131. const action = {
  132. type: event.target.dataset.action,
  133. data: {args: event.target.dataset.args},
  134. };
  135. if (action.type) {
  136. ASRouterUtils.executeAction(action);
  137. }
  138. if (!this.state.message.content.do_not_autoblock && !event.target.dataset.do_not_autoblock) {
  139. ASRouterUtils.blockById(this.state.message.id);
  140. }
  141. if (this.state.message.provider !== "preview") {
  142. this.sendUserActionTelemetry({event: "CLICK_BUTTON", ...metric});
  143. }
  144. }
  145. onBlockById(id) {
  146. return options => ASRouterUtils.blockById(id, options);
  147. }
  148. onDismissById(id) {
  149. return () => ASRouterUtils.dismissById(id);
  150. }
  151. dismissBundle(bundle) {
  152. return () => {
  153. ASRouterUtils.dismissBundle(bundle);
  154. this.sendUserActionTelemetry({
  155. event: "DISMISS",
  156. id: "onboarding-cards",
  157. message_id: bundle.map(m => m.id).join(","),
  158. // Passing the action because some bundles (Trailhead) don't have a provider set
  159. action: "onboarding_user_event",
  160. });
  161. };
  162. }
  163. triggerOnboarding() {
  164. ASRouterUtils.sendMessage({type: "TRIGGER", data: {trigger: {id: "showOnboarding"}}});
  165. }
  166. clearMessage(id) {
  167. if (id === this.state.message.id) {
  168. this.setState({message: {}});
  169. // Remove any styles related to the RTAMO message
  170. document.body.classList.remove("welcome", "hide-main", "amo");
  171. }
  172. }
  173. onMessageFromParent({data: action}) {
  174. switch (action.type) {
  175. case "SET_MESSAGE":
  176. this.setState({message: action.data});
  177. break;
  178. case "SET_BUNDLED_MESSAGES":
  179. this.setState({bundle: action.data});
  180. break;
  181. case "CLEAR_MESSAGE":
  182. this.clearMessage(action.data.id);
  183. break;
  184. case "CLEAR_PROVIDER":
  185. if (action.data.id === this.state.message.provider) {
  186. this.setState({message: {}});
  187. }
  188. break;
  189. case "CLEAR_BUNDLE":
  190. if (this.state.bundle.bundle) {
  191. this.setState({bundle: {}});
  192. }
  193. break;
  194. case "CLEAR_ALL":
  195. this.setState({message: {}, bundle: {}});
  196. break;
  197. case "AS_ROUTER_TARGETING_UPDATE":
  198. action.data.forEach(id => this.clearMessage(id));
  199. break;
  200. }
  201. }
  202. componentWillMount() {
  203. if (global.document) {
  204. // Add locale data for StartupOverlay because it uses react-intl
  205. addLocaleData(global.document.documentElement.lang);
  206. }
  207. const endpoint = ASRouterUtils.getPreviewEndpoint();
  208. if (endpoint && endpoint.theme === "dark") {
  209. global.window.dispatchEvent(new CustomEvent("LightweightTheme:Set", {detail: {data: NEWTAB_DARK_THEME}}));
  210. }
  211. ASRouterUtils.addListener(this.onMessageFromParent);
  212. // If we are loading about:welcome we want to trigger the onboarding messages
  213. if (this.props.document && this.props.document.location.href === "about:welcome") {
  214. ASRouterUtils.sendMessage({type: "TRIGGER", data: {trigger: {id: "firstRun"}}});
  215. } else {
  216. ASRouterUtils.sendMessage({type: "SNIPPETS_REQUEST", data: {endpoint}});
  217. }
  218. }
  219. componentWillUnmount() {
  220. ASRouterUtils.removeListener(this.onMessageFromParent);
  221. }
  222. renderSnippets() {
  223. if (this.state.bundle.template === "onboarding" ||
  224. this.state.message.template === "fxa_overlay" ||
  225. this.state.message.template === "return_to_amo_overlay" ||
  226. this.state.message.template === "trailhead") {
  227. return null;
  228. }
  229. const SnippetComponent = SnippetsTemplates[this.state.message.template];
  230. const {content} = this.state.message;
  231. return (
  232. <ImpressionsWrapper
  233. id="NEWTAB_FOOTER_BAR"
  234. message={this.state.message}
  235. sendImpression={this.sendImpression}
  236. shouldSendImpressionOnUpdate={shouldSendImpressionOnUpdate}
  237. // This helps with testing
  238. document={this.props.document}>
  239. <LocalizationProvider bundles={generateBundles(content)}>
  240. <SnippetComponent
  241. {...this.state.message}
  242. UISurface="NEWTAB_FOOTER_BAR"
  243. onBlock={this.onBlockById(this.state.message.id)}
  244. onDismiss={this.onDismissById(this.state.message.id)}
  245. onAction={ASRouterUtils.executeAction}
  246. sendClick={this.sendClick}
  247. sendUserActionTelemetry={this.sendUserActionTelemetry} />
  248. </LocalizationProvider>
  249. </ImpressionsWrapper>);
  250. }
  251. renderOnboarding() {
  252. if (this.state.bundle.template === "onboarding") {
  253. return (
  254. <OnboardingMessage
  255. {...this.state.bundle}
  256. UISurface="NEWTAB_OVERLAY"
  257. onAction={ASRouterUtils.executeAction}
  258. onDismissBundle={this.dismissBundle(this.state.bundle.bundle)}
  259. sendUserActionTelemetry={this.sendUserActionTelemetry} />);
  260. }
  261. return null;
  262. }
  263. renderFirstRunOverlay() {
  264. const {message} = this.state;
  265. if (message.template === "fxa_overlay") {
  266. global.document.body.classList.add("fxa");
  267. return (
  268. <IntlProvider locale={global.document.documentElement.lang} messages={global.gActivityStreamStrings}>
  269. <StartupOverlay
  270. onReady={this.triggerOnboarding}
  271. onBlock={this.onDismissById(message.id)}
  272. dispatch={this.props.dispatch} />
  273. </IntlProvider>
  274. );
  275. } else if (message.template === "return_to_amo_overlay") {
  276. global.document.body.classList.add("amo");
  277. return (
  278. <LocalizationProvider messages={generateBundles({"amo_html": message.content.text})}>
  279. <ReturnToAMO
  280. {...message}
  281. UISurface="NEWTAB_OVERLAY"
  282. onReady={this.triggerOnboarding}
  283. onBlock={this.onDismissById(message.id)}
  284. onAction={ASRouterUtils.executeAction}
  285. sendUserActionTelemetry={this.sendUserActionTelemetry} />
  286. </LocalizationProvider>
  287. );
  288. }
  289. return null;
  290. }
  291. renderTrailhead() {
  292. const {message} = this.state;
  293. if (message.template === "trailhead") {
  294. return (<Trailhead
  295. document={this.props.document}
  296. message={message}
  297. onAction={ASRouterUtils.executeAction}
  298. onDismissBundle={this.dismissBundle(this.state.message.bundle)}
  299. sendUserActionTelemetry={this.sendUserActionTelemetry}
  300. dispatch={this.props.dispatch}
  301. fxaEndpoint={this.props.fxaEndpoint} />);
  302. }
  303. return null;
  304. }
  305. renderPreviewBanner() {
  306. if (this.state.message.provider !== "preview") {
  307. return null;
  308. }
  309. return (
  310. <div className="snippets-preview-banner">
  311. <span className="icon icon-small-spacer icon-info" />
  312. <span>Preview Purposes Only</span>
  313. </div>
  314. );
  315. }
  316. render() {
  317. const {message, bundle} = this.state;
  318. if (!message.id && !bundle.template) { return null; }
  319. const shouldRenderBelowSearch = TEMPLATES_BELOW_SEARCH.includes(message.template);
  320. const shouldRenderInHeader = TEMPLATES_ABOVE_PAGE.includes(message.template);
  321. return shouldRenderBelowSearch ?
  322. // Render special below search snippets in place;
  323. <div className="below-search-snippet">{this.renderSnippets()}</div> :
  324. // For onboarding, regular snippets etc. we should render
  325. // everything in our footer container.
  326. ReactDOM.createPortal(
  327. <>
  328. {this.renderPreviewBanner()}
  329. {this.renderTrailhead()}
  330. {this.renderFirstRunOverlay()}
  331. {this.renderOnboarding()}
  332. {this.renderSnippets()}
  333. </>,
  334. shouldRenderInHeader ? this.headerPortal : this.footerPortal
  335. );
  336. }
  337. }
  338. ASRouterUISurface.defaultProps = {document: global.document};