diff --git a/browser/components/newtab/.eslintrc.js b/browser/components/newtab/.eslintrc.js index 94fb85ac0983..ec1f2d35667f 100644 --- a/browser/components/newtab/.eslintrc.js +++ b/browser/components/newtab/.eslintrc.js @@ -44,7 +44,6 @@ module.exports = { // These files use fluent-dom to insert content "files": [ "content-src/asrouter/templates/OnboardingMessage/**", - "content-src/asrouter/templates/FirstRun/**", "content-src/asrouter/templates/Trailhead/**", "content-src/asrouter/templates/StartupOverlay/StartupOverlay.jsx", "content-src/components/TopSites/**", diff --git a/browser/components/newtab/CODEOWNERS b/browser/components/newtab/CODEOWNERS index 7601132713fb..eb3ed9f19d25 100644 --- a/browser/components/newtab/CODEOWNERS +++ b/browser/components/newtab/CODEOWNERS @@ -1,2 +1,2 @@ # flod as main contact for string changes -locales-src/ @flodolo +locales-src/en-US/strings.properties @flodolo diff --git a/browser/components/newtab/content-src/asrouter/asrouter-content.jsx b/browser/components/newtab/content-src/asrouter/asrouter-content.jsx index a4d76ab8e504..bb74d9bafd8f 100644 --- a/browser/components/newtab/content-src/asrouter/asrouter-content.jsx +++ b/browser/components/newtab/content-src/asrouter/asrouter-content.jsx @@ -8,19 +8,17 @@ 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 { OnboardingMessage } from "./templates/OnboardingMessage/OnboardingMessage"; import React from "react"; import ReactDOM from "react-dom"; +import { ReturnToAMO } from "./templates/ReturnToAMO/ReturnToAMO"; import { SnippetsTemplates } from "./templates/template-manifest"; -import { FirstRun } from "./templates/FirstRun/FirstRun"; +import { StartupOverlay } from "./templates/StartupOverlay/StartupOverlay"; +import { Trailhead } from "./templates/Trailhead/Trailhead"; const INCOMING_MESSAGE_NAME = "ASRouter:parent-to-child"; const OUTGOING_MESSAGE_NAME = "ASRouter:child-to-parent"; -const TEMPLATES_ABOVE_PAGE = [ - "trailhead", - "fxa_overlay", - "return_to_amo_overlay", -]; -const FIRST_RUN_TEMPLATES = TEMPLATES_ABOVE_PAGE; +const TEMPLATES_ABOVE_PAGE = ["trailhead"]; const TEMPLATES_BELOW_SEARCH = ["simple_below_search_snippet"]; export const ASRouterUtils = { @@ -48,6 +46,9 @@ export const ASRouterUtils = { dismissById(id) { ASRouterUtils.sendMessage({ type: "DISMISS_MESSAGE_BY_ID", data: { id } }); }, + dismissBundle(bundle) { + ASRouterUtils.sendMessage({ type: "DISMISS_BUNDLE", data: { bundle } }); + }, executeAction(button_action) { ASRouterUtils.sendMessage({ type: "USER_ACTION", @@ -108,7 +109,7 @@ export class ASRouterUISurface extends React.PureComponent { this.sendClick = this.sendClick.bind(this); this.sendImpression = this.sendImpression.bind(this); this.sendUserActionTelemetry = this.sendUserActionTelemetry.bind(this); - this.state = { message: {} }; + this.state = { message: {}, bundle: {} }; if (props.document) { this.headerPortal = props.document.getElementById( "header-asrouter-container" @@ -120,10 +121,14 @@ export class ASRouterUISurface extends React.PureComponent { } sendUserActionTelemetry(extraProps = {}) { - const { message } = this.state; - const eventType = `${message.provider}_user_event`; + const { message, bundle } = this.state; + if (!message && !extraProps.message_id) { + throw new Error(`You must provide a message_id for bundled messages`); + } + // snippets_user_event, onboarding_user_event + const eventType = `${message.provider || bundle.provider}_user_event`; ASRouterUtils.sendTelemetry({ - message_id: message.id, + message_id: message.id || extraProps.message_id, source: extraProps.id, action: eventType, ...extraProps, @@ -175,6 +180,26 @@ export class ASRouterUISurface extends React.PureComponent { return () => ASRouterUtils.dismissById(id); } + dismissBundle(bundle) { + return () => { + ASRouterUtils.dismissBundle(bundle); + this.sendUserActionTelemetry({ + event: "DISMISS", + id: "onboarding-cards", + message_id: bundle.map(m => m.id).join(","), + // Passing the action because some bundles (Trailhead) don't have a provider set + action: "onboarding_user_event", + }); + }; + } + + triggerOnboarding() { + ASRouterUtils.sendMessage({ + type: "TRIGGER", + data: { trigger: { id: "showOnboarding" } }, + }); + } + clearMessage(id) { if (id === this.state.message.id) { this.setState({ message: {} }); @@ -188,6 +213,9 @@ export class ASRouterUISurface extends React.PureComponent { case "SET_MESSAGE": this.setState({ message: action.data }); break; + case "SET_BUNDLED_MESSAGES": + this.setState({ bundle: action.data }); + break; case "CLEAR_MESSAGE": this.clearMessage(action.data.id); break; @@ -196,8 +224,13 @@ export class ASRouterUISurface extends React.PureComponent { this.setState({ message: {} }); } break; + case "CLEAR_BUNDLE": + if (this.state.bundle.bundle) { + this.setState({ bundle: {} }); + } + break; case "CLEAR_ALL": - this.setState({ message: {} }); + this.setState({ message: {}, bundle: {} }); break; case "AS_ROUTER_TARGETING_UPDATE": action.data.forEach(id => this.clearMessage(id)); @@ -238,11 +271,18 @@ export class ASRouterUISurface extends React.PureComponent { } renderSnippets() { - const { message } = this.state; - if (!SnippetsTemplates[message.template]) { + if ( + this.state.bundle.template === "onboarding" || + [ + "fxa_overlay", + "return_to_amo_overlay", + "trailhead", + "whatsnew_panel_message", + ].includes(this.state.message.template) + ) { return null; } - const SnippetComponent = SnippetsTemplates[message.template]; + const SnippetComponent = SnippetsTemplates[this.state.message.template]; const { content } = this.state.message; return ( @@ -269,6 +309,70 @@ export class ASRouterUISurface extends React.PureComponent { ); } + renderOnboarding() { + if (this.state.bundle.template === "onboarding") { + return ( + + ); + } + return null; + } + + renderFirstRunOverlay() { + const { message } = this.state; + if (message.template === "fxa_overlay") { + global.document.body.classList.add("fxa"); + return ( + + ); + } else if (message.template === "return_to_amo_overlay") { + global.document.body.classList.add("amo"); + return ( + + + + ); + } + return null; + } + + renderTrailhead() { + const { message } = this.state; + if (message.template === "trailhead") { + return ( + + ); + } + return null; + } + renderPreviewBanner() { if (this.state.message.provider !== "preview") { return null; @@ -282,27 +386,9 @@ export class ASRouterUISurface extends React.PureComponent { ); } - renderFirstRun() { - const { message } = this.state; - if (FIRST_RUN_TEMPLATES.includes(message.template)) { - return ( - - ); - } - return null; - } - render() { - const { message } = this.state; - if (!message.id) { + const { message, bundle } = this.state; + if (!message.id && !bundle.template) { return null; } const shouldRenderBelowSearch = TEMPLATES_BELOW_SEARCH.includes( @@ -321,7 +407,9 @@ export class ASRouterUISurface extends React.PureComponent { ReactDOM.createPortal( <> {this.renderPreviewBanner()} - {this.renderFirstRun()} + {this.renderTrailhead()} + {this.renderFirstRunOverlay()} + {this.renderOnboarding()} {this.renderSnippets()} , shouldRenderInHeader ? this.headerPortal : this.footerPortal diff --git a/browser/components/newtab/content-src/asrouter/templates/FirstRun/FirstRun.jsx b/browser/components/newtab/content-src/asrouter/templates/FirstRun/FirstRun.jsx deleted file mode 100644 index fff7078a6af9..000000000000 --- a/browser/components/newtab/content-src/asrouter/templates/FirstRun/FirstRun.jsx +++ /dev/null @@ -1,227 +0,0 @@ -/* 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 React from "react"; -import { Interrupt } from "./Interrupt"; -import { Triplets } from "./Triplets"; -import { actionCreators as ac, actionTypes as at } from "common/Actions.jsm"; -import { addUtmParams } from "./addUtmParams"; - -export const FLUENT_FILES = [ - "branding/brand.ftl", - "browser/branding/brandings.ftl", - "browser/branding/sync-brand.ftl", - "browser/newtab/onboarding.ftl", -]; - -export const helpers = { - selectInterruptAndTriplets(message = {}) { - const hasInterrupt = Boolean(message.content); - const hasTriplets = Boolean(message.bundle && message.bundle.length); - const UTMTerm = message.utm_term || ""; - return { - hasTriplets, - hasInterrupt, - interrupt: hasInterrupt ? message : null, - triplets: hasTriplets ? message.bundle : null, - UTMTerm, - }; - }, - - addFluent(document) { - FLUENT_FILES.forEach(file => { - const link = document.head.appendChild(document.createElement("link")); - link.href = file; - link.rel = "localization"; - }); - }, - - async fetchFlowParams({ fxaEndpoint, UTMTerm, dispatch, setFlowParams }) { - try { - const url = new URL( - `${fxaEndpoint}/metrics-flow?entrypoint=activity-stream-firstrun&form_type=email` - ); - addUtmParams(url, UTMTerm); - const response = await fetch(url, { credentials: "omit" }); - if (response.status === 200) { - const { deviceId, flowId, flowBeginTime } = await response.json(); - setFlowParams({ deviceId, flowId, flowBeginTime }); - } else { - dispatch( - ac.OnlyToMain({ - type: at.TELEMETRY_UNDESIRED_EVENT, - data: { - event: "FXA_METRICS_FETCH_ERROR", - value: response.status, - }, - }) - ); - } - } catch (error) { - dispatch( - ac.OnlyToMain({ - type: at.TELEMETRY_UNDESIRED_EVENT, - data: { event: "FXA_METRICS_ERROR" }, - }) - ); - } - }, -}; - -export class FirstRun extends React.PureComponent { - constructor(props) { - super(props); - - this.didLoadFlowParams = false; - - this.state = { - prevMessage: undefined, - - hasInterrupt: false, - hasTriplets: false, - - interrupt: undefined, - triplets: undefined, - - isInterruptVisible: false, - isTripletsContainerVisible: false, - isTripletsContentVisible: false, - - UTMTerm: "", - - flowParams: undefined, - }; - - this.closeInterrupt = this.closeInterrupt.bind(this); - this.closeTriplets = this.closeTriplets.bind(this); - - helpers.addFluent(this.props.document); - } - - static getDerivedStateFromProps(props, state) { - const { message } = props; - if (message && message.id !== state.prevMessageId) { - const { - hasTriplets, - hasInterrupt, - interrupt, - triplets, - UTMTerm, - } = helpers.selectInterruptAndTriplets(message); - - return { - prevMessageId: message.id, - - hasInterrupt, - hasTriplets, - - interrupt, - triplets, - - isInterruptVisible: hasInterrupt, - isTripletsContainerVisible: hasTriplets, - isTripletsContentVisible: !(hasInterrupt || !hasTriplets), - - UTMTerm, - }; - } - return null; - } - - fetchFlowParams() { - const { fxaEndpoint, dispatch } = this.props; - const { UTMTerm } = this.state; - if (fxaEndpoint && UTMTerm && !this.didLoadFlowParams) { - this.didLoadFlowParams = true; - helpers.fetchFlowParams({ - fxaEndpoint, - UTMTerm, - dispatch, - setFlowParams: flowParams => this.setState({ flowParams }), - }); - } - } - - removeHideMain() { - if (!this.state.hasInterrupt) { - // We need to remove hide-main since we should show it underneath everything that has rendered - this.props.document.body.classList.remove("hide-main", "welcome"); - } - } - - componentDidMount() { - this.fetchFlowParams(); - this.removeHideMain(); - } - - componentDidUpdate() { - // In case we didn't have FXA info immediately, try again when we receive it. - this.fetchFlowParams(); - this.removeHideMain(); - } - - closeInterrupt() { - this.setState(prevState => ({ - isInterruptVisible: false, - isTripletsContainerVisible: prevState.hasTriplets, - isTripletsContentVisible: prevState.hasTriplets, - })); - } - - closeTriplets() { - this.setState({ isTripletsContainerVisible: false }); - } - - render() { - const { props } = this; - const { - sendUserActionTelemetry, - fxaEndpoint, - dispatch, - executeAction, - } = props; - - const { - interrupt, - triplets, - isInterruptVisible, - isTripletsContainerVisible, - isTripletsContentVisible, - hasTriplets, - UTMTerm, - flowParams, - } = this.state; - - return ( - <> - {isInterruptVisible ? ( - - ) : null} - {hasTriplets ? ( - - ) : null} - - ); - } -} diff --git a/browser/components/newtab/content-src/asrouter/templates/FirstRun/Interrupt.jsx b/browser/components/newtab/content-src/asrouter/templates/FirstRun/Interrupt.jsx deleted file mode 100644 index 562e5e4bfccb..000000000000 --- a/browser/components/newtab/content-src/asrouter/templates/FirstRun/Interrupt.jsx +++ /dev/null @@ -1,67 +0,0 @@ -/* 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 React from "react"; -import { Trailhead } from "../Trailhead/Trailhead"; -import { ReturnToAMO } from "../ReturnToAMO/ReturnToAMO"; -import { StartupOverlay } from "../StartupOverlay/StartupOverlay"; -import { LocalizationProvider } from "fluent-react"; -import { generateBundles } from "../../rich-text-strings"; - -export class Interrupt extends React.PureComponent { - render() { - const { - onDismiss, - onNextScene, - message, - sendUserActionTelemetry, - executeAction, - dispatch, - fxaEndpoint, - UTMTerm, - flowParams, - } = this.props; - - switch (message.template) { - case "return_to_amo_overlay": - return ( - - - - ); - case "fxa_overlay": - return ( - - ); - case "trailhead": - return ( - - ); - default: - throw new Error(`${message.template} is not a valid FirstRun message`); - } - } -} diff --git a/browser/components/newtab/content-src/asrouter/templates/FirstRun/Triplets.jsx b/browser/components/newtab/content-src/asrouter/templates/FirstRun/Triplets.jsx deleted file mode 100644 index 74043a91e0a4..000000000000 --- a/browser/components/newtab/content-src/asrouter/templates/FirstRun/Triplets.jsx +++ /dev/null @@ -1,91 +0,0 @@ -/* 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 React from "react"; -import { OnboardingCard } from "../../templates/OnboardingMessage/OnboardingMessage"; -import { addUtmParams } from "./addUtmParams"; - -export class Triplets extends React.PureComponent { - constructor(props) { - super(props); - this.onCardAction = this.onCardAction.bind(this); - this.onHideContainer = this.onHideContainer.bind(this); - } - - componentWillMount() { - global.document.body.classList.add("inline-onboarding"); - } - - componentWillUnmount() { - this.props.document.body.classList.remove("inline-onboarding"); - } - - onCardAction(action) { - let actionUpdates = {}; - const { flowParams, UTMTerm } = this.props; - - if (action.type === "OPEN_URL") { - let url = new URL(action.data.args); - addUtmParams(url, UTMTerm); - - if (action.addFlowParams) { - url.searchParams.append("device_id", flowParams.deviceId); - url.searchParams.append("flow_id", flowParams.flowId); - url.searchParams.append("flow_begin_time", flowParams.flowBeginTime); - } - - actionUpdates = { data: { ...action.data, args: url.toString() } }; - } - - this.props.onAction({ ...action, ...actionUpdates }); - } - - onHideContainer() { - const { sendUserActionTelemetry, cards, hideContainer } = this.props; - hideContainer(); - sendUserActionTelemetry({ - event: "DISMISS", - id: "onboarding-cards", - message_id: cards.map(m => m.id).join(","), - action: "onboarding_user_event", - }); - } - - render() { - const { - cards, - showCardPanel, - showContent, - sendUserActionTelemetry, - } = this.props; - return ( -
-
-

-
- {cards.map(card => ( - - ))} -
- {showCardPanel && ( -

-
- ); - } -} diff --git a/browser/components/newtab/content-src/asrouter/templates/FirstRun/addUtmParams.js b/browser/components/newtab/content-src/asrouter/templates/FirstRun/addUtmParams.js deleted file mode 100644 index 2b66346d2827..000000000000 --- a/browser/components/newtab/content-src/asrouter/templates/FirstRun/addUtmParams.js +++ /dev/null @@ -1,27 +0,0 @@ -/* 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/. */ - -const BASE_PARAMS = { - utm_source: "activity-stream", - utm_campaign: "firstrun", - utm_medium: "referral", -}; - -/** - * Takes in a url as a string or URL object and returns a URL object with the - * utm_* parameters added to it. If a URL object is passed in, the paraemeters - * are added to it (the return value can be ignored in that case as it's the - * same object). - */ -export function addUtmParams(url, utmTerm) { - let returnUrl = url; - if (typeof returnUrl === "string") { - returnUrl = new URL(url); - } - Object.keys(BASE_PARAMS).forEach(key => { - returnUrl.searchParams.append(key, BASE_PARAMS[key]); - }); - returnUrl.searchParams.append("utm_term", utmTerm); - return returnUrl; -} diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.jsx b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.jsx index ba4e23344c18..ca6eab6e5273 100644 --- a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.jsx +++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.jsx @@ -2,8 +2,15 @@ * 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 { ModalOverlay } from "../../components/ModalOverlay/ModalOverlay"; import React from "react"; +const FLUENT_FILES = [ + "branding/brand.ftl", + "browser/branding/sync-brand.ftl", + "browser/newtab/onboarding.ftl", +]; + export class OnboardingCard extends React.PureComponent { constructor(props) { super(props); @@ -50,3 +57,33 @@ export class OnboardingCard extends React.PureComponent { ); } } + +export class OnboardingMessage extends React.PureComponent { + componentWillMount() { + FLUENT_FILES.forEach(file => { + const link = document.head.appendChild(document.createElement("link")); + link.href = file; + link.rel = "localization"; + }); + } + + render() { + const { props } = this; + const { button_label, header } = props.extraTemplateStrings; + return ( + +
+ {props.bundle.map(message => ( + + ))} +
+
+ ); + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/_OnboardingMessage.scss b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/_OnboardingMessage.scss index 5047f2420c77..c0e99124d88f 100644 --- a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/_OnboardingMessage.scss +++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/_OnboardingMessage.scss @@ -1,3 +1,20 @@ +.onboardingMessageContainer { + display: grid; + grid-column-gap: 21px; + grid-template-columns: auto auto auto; + padding-left: 30px; + padding-right: 30px; + min-height: 500px; + + // at 850px, the cards go from vertical layout to horizontal layout + @media(max-width: 850px) { + grid-template-columns: none; + grid-template-rows: auto auto auto; + padding-left: 110px; + padding-right: 110px; + } +} + .onboardingMessage { height: 340px; text-align: center; diff --git a/browser/components/newtab/content-src/asrouter/templates/ReturnToAMO/ReturnToAMO.jsx b/browser/components/newtab/content-src/asrouter/templates/ReturnToAMO/ReturnToAMO.jsx index e2c60f484b57..c944eefbb179 100644 --- a/browser/components/newtab/content-src/asrouter/templates/ReturnToAMO/ReturnToAMO.jsx +++ b/browser/components/newtab/content-src/asrouter/templates/ReturnToAMO/ReturnToAMO.jsx @@ -15,11 +15,8 @@ export class ReturnToAMO extends React.PureComponent { this.onBlockButton = this.onBlockButton.bind(this); } - componentWillMount() { - global.document.body.classList.add("amo"); - } - componentDidMount() { + this.props.onReady(); this.props.sendUserActionTelemetry({ event: "IMPRESSION", id: this.props.UISurface, diff --git a/browser/components/newtab/content-src/asrouter/templates/StartupOverlay/StartupOverlay.jsx b/browser/components/newtab/content-src/asrouter/templates/StartupOverlay/StartupOverlay.jsx index 7d16ec50f4eb..13b7da371f9c 100644 --- a/browser/components/newtab/content-src/asrouter/templates/StartupOverlay/StartupOverlay.jsx +++ b/browser/components/newtab/content-src/asrouter/templates/StartupOverlay/StartupOverlay.jsx @@ -2,15 +2,23 @@ * 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 } from "common/Actions.jsm"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.jsm"; +import { connect } from "react-redux"; import React from "react"; -export class StartupOverlay extends React.PureComponent { +const FLUENT_FILES = [ + "branding/brand.ftl", + "browser/branding/sync-brand.ftl", + "browser/newtab/onboarding.ftl", +]; + +export class _StartupOverlay extends React.PureComponent { constructor(props) { super(props); this.onInputChange = this.onInputChange.bind(this); this.onSubmit = this.onSubmit.bind(this); this.clickSkip = this.clickSkip.bind(this); + this.initScene = this.initScene.bind(this); this.removeOverlay = this.removeOverlay.bind(this); this.onInputInvalid = this.onInputInvalid.bind(this); @@ -18,20 +26,71 @@ export class StartupOverlay extends React.PureComponent { "utm_source=activity-stream&utm_campaign=firstrun&utm_medium=referral&utm_term=trailhead-control"; this.state = { - show: false, emailInput: "", + overlayRemoved: false, + deviceId: "", + flowId: "", + flowBeginTime: 0, }; + this.didFetch = false; } - componentWillMount() { - global.document.body.classList.add("fxa"); + async componentWillUpdate() { + if (this.props.fxa_endpoint && !this.didFetch) { + try { + this.didFetch = true; + const fxaParams = "entrypoint=activity-stream-firstrun&form_type=email"; + const response = await fetch( + `${this.props.fxa_endpoint}/metrics-flow?${fxaParams}&${ + this.utmParams + }`, + { credentials: "omit" } + ); + if (response.status === 200) { + const { deviceId, flowId, flowBeginTime } = await response.json(); + this.setState({ deviceId, flowId, flowBeginTime }); + } else { + this.props.dispatch( + ac.OnlyToMain({ + type: at.TELEMETRY_UNDESIRED_EVENT, + data: { + event: "FXA_METRICS_FETCH_ERROR", + value: response.status, + }, + }) + ); + } + } catch (error) { + this.props.dispatch( + ac.OnlyToMain({ + type: at.TELEMETRY_UNDESIRED_EVENT, + data: { event: "FXA_METRICS_ERROR" }, + }) + ); + } + } + } + + async componentWillMount() { + FLUENT_FILES.forEach(file => { + const link = document.head.appendChild(document.createElement("link")); + link.href = file; + link.rel = "localization"; + }); + + await this.componentWillUpdate(this.props); } componentDidMount() { + this.initScene(); + } + + initScene() { // Timeout to allow the scene to render once before attaching the attribute // to trigger the animation. setTimeout(() => { this.setState({ show: true }); + this.props.onReady(); }, 10); } @@ -39,11 +98,11 @@ export class StartupOverlay extends React.PureComponent { window.removeEventListener("visibilitychange", this.removeOverlay); document.body.classList.remove("hide-main", "fxa"); this.setState({ show: false }); - + this.props.onBlock(); setTimeout(() => { // Allow scrolling and fully remove overlay after animation finishes. - this.props.onBlock(); document.body.classList.remove("welcome"); + this.setState({ overlayRemoved: true }); }, 400); } @@ -73,9 +132,7 @@ export class StartupOverlay extends React.PureComponent { * Report to telemetry additional information about the form submission. */ _getFormInfo() { - const value = { - has_flow_params: this.props.flowParams.flowId.length > 0, - }; + const value = { has_flow_params: this.state.flowId.length > 0 }; return { value }; } @@ -88,6 +145,12 @@ export class StartupOverlay extends React.PureComponent { } render() { + // When skipping the onboarding tour we show AS but we are still on + // about:welcome, prop.isFirstrun is true and StartupOverlay is rendered + if (this.state.overlayRemoved) { + return null; + } + return (
@@ -150,17 +213,13 @@ export class StartupOverlay extends React.PureComponent { - + ({ fxa_endpoint: state.Prefs.values.fxa_endpoint }); +export const StartupOverlay = connect(getState)(_StartupOverlay); diff --git a/browser/components/newtab/content-src/asrouter/templates/Trailhead/Trailhead.jsx b/browser/components/newtab/content-src/asrouter/templates/Trailhead/Trailhead.jsx index 1457e955cba3..f991e8f787fd 100644 --- a/browser/components/newtab/content-src/asrouter/templates/Trailhead/Trailhead.jsx +++ b/browser/components/newtab/content-src/asrouter/templates/Trailhead/Trailhead.jsx @@ -2,11 +2,18 @@ * 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 } from "common/Actions.jsm"; +import { actionCreators as ac, actionTypes as at } from "common/Actions.jsm"; import { ModalOverlayWrapper } from "../../components/ModalOverlay/ModalOverlay"; -import { addUtmParams } from "../FirstRun/addUtmParams"; +import { OnboardingCard } from "../OnboardingMessage/OnboardingMessage"; import React from "react"; +const FLUENT_FILES = [ + "branding/brand.ftl", + "browser/branding/brandings.ftl", + "browser/branding/sync-brand.ftl", + "browser/newtab/onboarding.ftl", +]; + // From resource://devtools/client/shared/focus.js const FOCUSABLE_SELECTOR = [ "a[href]:not([tabindex='-1'])", @@ -22,35 +29,106 @@ export class Trailhead extends React.PureComponent { constructor(props) { super(props); this.closeModal = this.closeModal.bind(this); + this.hideCardPanel = this.hideCardPanel.bind(this); this.onInputChange = this.onInputChange.bind(this); this.onStartBlur = this.onStartBlur.bind(this); this.onSubmit = this.onSubmit.bind(this); this.onInputInvalid = this.onInputInvalid.bind(this); + this.onCardAction = this.onCardAction.bind(this); this.state = { emailInput: "", + isModalOpen: true, + showCardPanel: true, + showCards: false, + // The params below are for FxA metrics + deviceId: "", + flowId: "", + flowBeginTime: 0, }; + this.fxaMetricsInitialized = false; } get dialog() { return this.props.document.getElementById("trailheadDialog"); } + async componentWillMount() { + FLUENT_FILES.forEach(file => { + const link = document.head.appendChild(document.createElement("link")); + link.href = file; + link.rel = "localization"; + }); + + await this.componentWillUpdate(this.props); + } + + // Get the fxa data if we don't have it yet from mount or update + async componentWillUpdate(props) { + if (props.fxaEndpoint && !this.fxaMetricsInitialized) { + try { + this.fxaMetricsInitialized = true; + const url = new URL( + `${ + props.fxaEndpoint + }/metrics-flow?entrypoint=activity-stream-firstrun&form_type=email` + ); + this.addUtmParams(url); + const response = await fetch(url, { credentials: "omit" }); + if (response.status === 200) { + const { deviceId, flowId, flowBeginTime } = await response.json(); + this.setState({ deviceId, flowId, flowBeginTime }); + } else { + props.dispatch( + ac.OnlyToMain({ + type: at.TELEMETRY_UNDESIRED_EVENT, + data: { + event: "FXA_METRICS_FETCH_ERROR", + value: response.status, + }, + }) + ); + } + } catch (error) { + props.dispatch( + ac.OnlyToMain({ + type: at.TELEMETRY_UNDESIRED_EVENT, + data: { event: "FXA_METRICS_ERROR" }, + }) + ); + } + } + } + componentDidMount() { // We need to remove hide-main since we should show it underneath everything that has rendered this.props.document.body.classList.remove("hide-main"); - // The rest of the page is "hidden" to screen readers when the modal is open - this.props.document - .getElementById("root") - .setAttribute("aria-hidden", "true"); - // Start with focus in the email input box - const input = this.dialog.querySelector("input[name=email]"); - if (input) { - input.focus(); + // Add inline-onboarding class to disable fixed search header and fixed positioned settings icon + this.props.document.body.classList.add("inline-onboarding"); + + // The rest of the page is "hidden" when the modal is open + if (this.props.message.content) { + this.props.document + .getElementById("root") + .setAttribute("aria-hidden", "true"); + + // Start with focus in the email input box + this.dialog.querySelector("input[name=email]").focus(); + } else { + // No modal overlay, let the user scroll and deal them some cards. + this.props.document.body.classList.remove("welcome"); + + if (this.props.message.includeBundle || this.props.message.cards) { + this.revealCards(); + } } } + componentWillUnmount() { + this.props.document.body.classList.remove("inline-onboarding"); + } + onInputChange(e) { let error = e.target.previousSibling; this.setState({ emailInput: e.target.value }); @@ -94,7 +172,8 @@ export class Trailhead extends React.PureComponent { global.removeEventListener("visibilitychange", this.closeModal); this.props.document.body.classList.remove("welcome"); this.props.document.getElementById("root").removeAttribute("aria-hidden"); - this.props.onNextScene(); + this.setState({ isModalOpen: false }); + this.revealCards(); // If closeModal() was triggered by a visibilitychange event, the user actually // submitted the email form so we don't send a SKIPPED_SIGNIN ping. @@ -112,7 +191,7 @@ export class Trailhead extends React.PureComponent { * Report to telemetry additional information about the form submission. */ _getFormInfo() { - const value = { has_flow_params: this.props.flowParams.flowId.length > 0 }; + const value = { has_flow_params: this.state.flowId.length > 0 }; return { value }; } @@ -124,141 +203,234 @@ export class Trailhead extends React.PureComponent { e.target.focus(); } + hideCardPanel() { + this.setState({ showCardPanel: false }); + this.props.onDismissBundle(); + } + + revealCards() { + this.setState({ showCards: true }); + } + + /** + * Takes in a url as a string or URL object and returns a URL object with the + * utm_* parameters added to it. If a URL object is passed in, the paraemeters + * are added to it (the return value can be ignored in that case as it's the + * same object). + */ + addUtmParams(url, isCard = false) { + let returnUrl = url; + if (typeof returnUrl === "string") { + returnUrl = new URL(url); + } + returnUrl.searchParams.append("utm_source", "activity-stream"); + returnUrl.searchParams.append("utm_campaign", "firstrun"); + returnUrl.searchParams.append("utm_medium", "referral"); + returnUrl.searchParams.append( + "utm_term", + `${this.props.message.utm_term}${isCard ? "-card" : ""}` + ); + return returnUrl; + } + + onCardAction(action) { + let actionUpdates = {}; + + if (action.type === "OPEN_URL") { + let url = new URL(action.data.args); + this.addUtmParams(url, true); + + if (action.addFlowParams) { + url.searchParams.append("device_id", this.state.deviceId); + url.searchParams.append("flow_id", this.state.flowId); + url.searchParams.append("flow_begin_time", this.state.flowBeginTime); + } + + actionUpdates = { data: { ...action.data, args: url } }; + } + + this.props.onAction({ ...action, ...actionUpdates }); + } + render() { const { props } = this; - const { UTMTerm } = props; - const { content } = props.message; + const { bundle: cards, content, utm_term } = props.message; const innerClassName = ["trailhead", content && content.className] .filter(v => v) .join(" "); return ( - - +
-
+ + ) : null} + ); } } - -Trailhead.defaultProps = { - flowParams: { deviceId: "", flowId: "", flowBeginTime: "" }, -}; diff --git a/browser/components/newtab/content-src/components/CollapsibleSection/CollapsibleSection.jsx b/browser/components/newtab/content-src/components/CollapsibleSection/CollapsibleSection.jsx index 45053f7bcffb..d2b75b70cd7c 100644 --- a/browser/components/newtab/content-src/components/CollapsibleSection/CollapsibleSection.jsx +++ b/browser/components/newtab/content-src/components/CollapsibleSection/CollapsibleSection.jsx @@ -119,7 +119,6 @@ export class CollapsibleSection extends React.PureComponent { onKeyPress(event) { if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); this.onHeaderClick(); } } @@ -224,13 +223,16 @@ export class CollapsibleSection extends React.PureComponent { > {this.renderIcon()} + + {isCollapsible && ( -
    + +
      {this.props.options.map((option, i) => option.type === "separator" ? ( -
    • +
    • ) : ( option.type !== "empty" && ( ) ) @@ -76,7 +77,6 @@ export class ContextMenuItem extends React.PureComponent { super(props); this.onClick = this.onClick.bind(this); this.onKeyDown = this.onKeyDown.bind(this); - this.onKeyUp = this.onKeyUp.bind(this); this.focusFirst = this.focusFirst.bind(this); } @@ -129,8 +129,6 @@ export class ContextMenuItem extends React.PureComponent { this.focusSibling(event.target, event.key); break; case "Enter": - case " ": - event.preventDefault(); this.props.hideContext(); option.onClick(); break; @@ -140,24 +138,15 @@ export class ContextMenuItem extends React.PureComponent { } } - // Prevents the default behavior of spacebar - // scrolling the page & auto-triggering buttons. - onKeyUp(event) { - if (event.key === " ") { - event.preventDefault(); - } - } - render() { const { option } = this.props; return ( -
    • +