/* 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, { useState, useEffect, useRef } from "react"; import { Localized } from "./MSLocalized"; import { AboutWelcomeUtils } from "../lib/aboutwelcome-utils.mjs"; import { MultiStageProtonScreen } from "./MultiStageProtonScreen"; import { useLanguageSwitcher } from "./LanguageSwitcher"; import { SubmenuButton } from "./SubmenuButton"; import { BASE_PARAMS, addUtmParams } from "../lib/addUtmParams.mjs"; // Amount of milliseconds for all transitions to complete (including delays). const TRANSITION_OUT_TIME = 1000; const LANGUAGE_MISMATCH_SCREEN_ID = "AW_LANGUAGE_MISMATCH"; export const MultiStageAboutWelcome = props => { let { defaultScreens } = props; const didFilter = useRef(false); const [didMount, setDidMount] = useState(false); const [screens, setScreens] = useState(defaultScreens); const [index, setScreenIndex] = useState(props.startScreen); const [previousOrder, setPreviousOrder] = useState(props.startScreen - 1); useEffect(() => { (async () => { // If we want to load index from history state, we don't want to send impression yet if (!didMount) { return; } // On about:welcome first load, screensVisited should be empty let screensVisited = didFilter.current ? screens.slice(0, index) : []; let upcomingScreens = defaultScreens .filter(s => !screensVisited.find(v => v.id === s.id)) // Filter out Language Mismatch screen from upcoming // screens if screens set from useLanguageSwitcher hook // has filtered language screen .filter( upcomingScreen => !( !screens.find(s => s.id === LANGUAGE_MISMATCH_SCREEN_ID) && upcomingScreen.id === LANGUAGE_MISMATCH_SCREEN_ID ) ); let filteredScreens = screensVisited.concat( (await window.AWEvaluateScreenTargeting(upcomingScreens)) ?? upcomingScreens ); // Use existing screen for the filtered screen to carry over any modification // e.g. if AW_LANGUAGE_MISMATCH exists, use it from existing screens setScreens( filteredScreens.map( filtered => screens.find(s => s.id === filtered.id) ?? filtered ) ); didFilter.current = true; const screenInitials = filteredScreens .map(({ id }) => id?.split("_")[1]?.[0]) .join(""); // Send impression ping when respective screen first renders filteredScreens.forEach((screen, order) => { if (index === order) { const messageId = `${props.message_id}_${order}_${screen.id}_${screenInitials}`; AboutWelcomeUtils.sendImpressionTelemetry(messageId, { screen_family: props.message_id, screen_index: order, screen_id: screen.id, screen_initials: screenInitials, }); window.AWAddScreenImpression?.(screen); } }); // Remember that a new screen has loaded for browser navigation if (props.updateHistory && index > window.history.state) { window.history.pushState(index, ""); } // Remember the previous screen index so we can animate the transition setPreviousOrder(index); })(); }, [index, didMount]); // eslint-disable-line react-hooks/exhaustive-deps const [flowParams, setFlowParams] = useState(null); const { metricsFlowUri } = props; useEffect(() => { (async () => { if (metricsFlowUri) { setFlowParams(await AboutWelcomeUtils.fetchFlowParams(metricsFlowUri)); } })(); }, [metricsFlowUri]); // Allow "in" style to render to actually transition towards regular state, // which also makes using browser back/forward navigation skip transitions. const [transition, setTransition] = useState(props.transitions ? "in" : ""); useEffect(() => { if (transition === "in") { requestAnimationFrame(() => requestAnimationFrame(() => setTransition("")) ); } }, [transition]); // Transition to next screen, opening about:home on last screen button CTA const handleTransition = () => { // Only handle transitioning out from a screen once. if (transition === "out") { return; } // Start transitioning things "out" immediately when moving forwards. setTransition(props.transitions ? "out" : ""); // Actually move forwards after all transitions finish. setTimeout( () => { if (index < screens.length - 1) { setTransition(props.transitions ? "in" : ""); setScreenIndex(prevState => prevState + 1); } else { window.AWFinish(); } }, props.transitions ? TRANSITION_OUT_TIME : 0 ); }; useEffect(() => { // When about:welcome loads (on refresh or pressing back button // from about:home), ensure history state usEffect runs before // useEffect hook that send impression telemetry setDidMount(true); if (props.updateHistory) { // Switch to the screen tracked in state (null for initial state) // or last screen index if a user navigates by pressing back // button from about:home const handler = ({ state }) => { if (transition === "out") { return; } setTransition(props.transitions ? "out" : ""); setTimeout( () => { setTransition(props.transitions ? "in" : ""); setScreenIndex(Math.min(state, screens.length - 1)); }, props.transitions ? TRANSITION_OUT_TIME : 0 ); }; // Handle page load, e.g., going back to about:welcome from about:home const { state } = window.history; if (state) { setScreenIndex(Math.min(state, screens.length - 1)); setPreviousOrder(Math.min(state, screens.length - 1)); } // Watch for browser back/forward button navigation events window.addEventListener("popstate", handler); return () => window.removeEventListener("popstate", handler); } return false; }, []); // eslint-disable-line react-hooks/exhaustive-deps const [multiSelects, setMultiSelects] = useState({}); // Save the active multi select state for each screen as an object keyed by // screen id. Each screen id has an array containing checkbox ids used in // handleAction to update MULTI_ACTION data. This allows us to remember the // state of each screen's multi select checkboxes when navigating back and // forth between screens, while also allowing a message to have more than one // multi select screen. const [activeMultiSelects, setActiveMultiSelects] = useState({}); // Get the active theme so the rendering code can make it selected // by default. const [activeTheme, setActiveTheme] = useState(null); const [initialTheme, setInitialTheme] = useState(null); useEffect(() => { (async () => { let theme = await window.AWGetSelectedTheme(); setInitialTheme(theme); setActiveTheme(theme); })(); }, []); const { negotiatedLanguage, langPackInstallPhase, languageFilteredScreens } = useLanguageSwitcher( props.appAndSystemLocaleInfo, screens, index, setScreenIndex ); useEffect(() => { setScreens(languageFilteredScreens); }, [languageFilteredScreens]); return (
{screens.map((screen, order) => { const isFirstScreen = screen === screens[0]; const isLastScreen = screen === screens[screens.length - 1]; const totalNumberOfScreens = screens.length; const isSingleScreen = totalNumberOfScreens === 1; const setActiveMultiSelect = valueOrFn => setActiveMultiSelects(prevState => ({ ...prevState, [screen.id]: typeof valueOrFn === "function" ? valueOrFn(prevState[screen.id]) : valueOrFn, })); const setScreenMultiSelects = valueOrFn => setMultiSelects(prevState => ({ ...prevState, [screen.id]: typeof valueOrFn === "function" ? valueOrFn(prevState[screen.id]) : valueOrFn, })); return index === order ? ( ) : null; })}
); }; export const SecondaryCTA = props => { const targetElement = props.position ? `secondary_button_${props.position}` : `secondary_button`; let buttonStyling = props.content.secondary_button?.has_arrow_icon ? `secondary arrow-icon` : `secondary`; const isPrimary = props.content.secondary_button?.style === "primary"; const isTextLink = !["split", "callout"].includes(props.content.position) && props.content.tiles?.type !== "addons-picker" && !isPrimary; const isSplitButton = props.content.submenu_button?.attached_to === targetElement; let className = "secondary-cta"; if (props.position) { className += ` ${props.position}`; } if (isSplitButton) { className += " split-button-container"; } const isDisabled = React.useCallback( disabledValue => disabledValue === "hasActiveMultiSelect" ? !(props.activeMultiSelect?.length > 0) : disabledValue, [props.activeMultiSelect?.length] ); if (isTextLink) { buttonStyling += " text-link"; } if (isPrimary) { buttonStyling = props.content.secondary_button?.has_arrow_icon ? `primary arrow-icon` : `primary`; } return (
); }; export const StepsIndicator = props => { let steps = []; for (let i = 0; i < props.totalNumberOfScreens; i++) { let className = `${i === props.order ? "current" : ""} ${ i < props.order ? "complete" : "" }`; steps.push(
); } return steps; }; export const ProgressBar = ({ step, previousStep, totalNumberOfScreens }) => { const [progress, setProgress] = React.useState( previousStep / totalNumberOfScreens ); useEffect(() => { // We don't need to hook any dependencies because any time the step changes, // the screen's entire DOM tree will be re-rendered. setProgress(step / totalNumberOfScreens); }, []); // eslint-disable-line react-hooks/exhaustive-deps return (
); }; export class WelcomeScreen extends React.PureComponent { constructor(props) { super(props); this.handleAction = this.handleAction.bind(this); } handleOpenURL(action, flowParams, UTMTerm) { let { type, data } = action; if (type === "SHOW_FIREFOX_ACCOUNTS") { let params = { ...BASE_PARAMS, utm_term: `${UTMTerm}-screen`, }; if (action.addFlowParams && flowParams) { params = { ...params, ...flowParams, }; } data = { ...data, extraParams: params }; } else if (type === "OPEN_URL") { let url = new URL(data.args); addUtmParams(url, `${UTMTerm}-screen`); if (action.addFlowParams && flowParams) { url.searchParams.append("device_id", flowParams.deviceId); url.searchParams.append("flow_id", flowParams.flowId); url.searchParams.append("flow_begin_time", flowParams.flowBeginTime); } data = { ...data, args: url.toString() }; } return AboutWelcomeUtils.handleUserAction({ type, data }); } async handleAction(event) { let { props } = this; const value = event.currentTarget.value ?? event.currentTarget.getAttribute("value"); const source = event.source || value; let targetContent = props.content[value] || props.content.tiles || props.content.languageSwitcher; if (value === "submenu_button" && event.action) { targetContent = { action: event.action }; } if (!(targetContent && targetContent.action)) { return; } // Send telemetry before waiting on actions AboutWelcomeUtils.sendActionTelemetry(props.messageId, source, event.name); // Send additional telemetry if a messaging surface like feature callout is // dismissed via the dismiss button. Other causes of dismissal will be // handled separately by the messaging surface's own code. if (value === "dismiss_button" && !event.name) { AboutWelcomeUtils.sendDismissTelemetry(props.messageId, source); } let { action } = targetContent; action = JSON.parse(JSON.stringify(action)); if (action.collectSelect) { this.setMultiSelectActions(action); } let actionResult; if (["OPEN_URL", "SHOW_FIREFOX_ACCOUNTS"].includes(action.type)) { actionResult = await this.handleOpenURL( action, props.flowParams, props.UTMTerm ); } else if (action.type) { actionResult = await AboutWelcomeUtils.handleUserAction(action); if (action.type === "FXA_SIGNIN_FLOW") { AboutWelcomeUtils.sendActionTelemetry( props.messageId, actionResult ? "sign_in" : "sign_in_cancel", "FXA_SIGNIN_FLOW" ); } // Wait until migration closes to complete the action const hasMigrate = a => a.type === "SHOW_MIGRATION_WIZARD" || (a.type === "MULTI_ACTION" && a.data?.actions?.some(hasMigrate)); if (hasMigrate(action)) { await window.AWWaitForMigrationClose(); AboutWelcomeUtils.sendActionTelemetry(props.messageId, "migrate_close"); } } // A special tiles.action.theme value indicates we should use the event's value vs provided value. if (action.theme) { let themeToUse = action.theme === "" ? event.currentTarget.value : this.props.initialTheme || action.theme; this.props.setActiveTheme(themeToUse); window.AWSelectTheme(themeToUse); } // If the action has persistActiveTheme: true, we set the initial theme to the currently active theme // so that it can be reverted to in the event that the user navigates away from the screen if (action.persistActiveTheme) { this.props.setInitialTheme(this.props.activeTheme); } // `navigate` and `dismiss` can be true/false/undefined, or they can be a // string "actionResult" in which case we should use the actionResult // (boolean resolved by handleUserAction) const shouldDoBehavior = behavior => behavior === "actionResult" ? actionResult : behavior; if (shouldDoBehavior(action.navigate)) { props.navigate(); } if (shouldDoBehavior(action.dismiss)) { window.AWFinish(); } } setMultiSelectActions(action) { let { props } = this; // Populate MULTI_ACTION data actions property with selected checkbox // actions from tiles data if (action.type !== "MULTI_ACTION") { console.error( "collectSelect is only supported for MULTI_ACTION type actions" ); action.type = "MULTI_ACTION"; } if (!Array.isArray(action.data?.actions)) { console.error( "collectSelect is only supported for MULTI_ACTION type actions with an array of actions" ); action.data = { actions: [] }; } // Prepend the multi-select actions to the CTA's actions array, but keep // the actions in the same order they appear in. This way the CTA action // can go last, after the multi-select actions are processed. For example, // 1. checkbox action 1 // 2. checkbox action 2 // 3. radio action // 4. CTA action (which perhaps depends on the radio action) let multiSelectActions = []; for (const checkbox of props.content?.tiles?.data ?? []) { let checkboxAction; if (props.activeMultiSelect?.includes(checkbox.id)) { checkboxAction = checkbox.checkedAction ?? checkbox.action; } else { checkboxAction = checkbox.uncheckedAction; } if (checkboxAction) { multiSelectActions.push(checkboxAction); } } action.data.actions.unshift(...multiSelectActions); // Send telemetry with selected checkbox ids AboutWelcomeUtils.sendActionTelemetry( props.messageId, props.activeMultiSelect, "SELECT_CHECKBOX" ); } render() { return ( ); } }