diff options
Diffstat (limited to 'browser/components/aboutwelcome/content-src/components')
19 files changed, 2823 insertions, 0 deletions
diff --git a/browser/components/aboutwelcome/content-src/components/AdditionalCTA.jsx b/browser/components/aboutwelcome/content-src/components/AdditionalCTA.jsx new file mode 100644 index 0000000000..7685195666 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/AdditionalCTA.jsx @@ -0,0 +1,42 @@ +/* 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 { Localized } from "./MSLocalized"; +import { SubmenuButton } from "./SubmenuButton"; + +export const AdditionalCTA = ({ content, handleAction }) => { + let buttonStyle = ""; + const isSplitButton = + content.submenu_button?.attached_to === "additional_button"; + let className = "additional-cta-box"; + if (isSplitButton) { + className += " split-button-container"; + } + + if (!content.additional_button?.style) { + buttonStyle = "primary"; + } else { + buttonStyle = + content.additional_button?.style === "link" + ? "cta-link" + : content.additional_button?.style; + } + + return ( + <div className={className}> + <Localized text={content.additional_button?.label}> + <button + className={`${buttonStyle} additional-cta`} + onClick={handleAction} + value="additional_button" + disabled={content.additional_button?.disabled === true} + /> + </Localized> + {isSplitButton ? ( + <SubmenuButton content={content} handleAction={handleAction} /> + ) : null} + </div> + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/AddonsPicker.jsx b/browser/components/aboutwelcome/content-src/components/AddonsPicker.jsx new file mode 100644 index 0000000000..10c88008de --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/AddonsPicker.jsx @@ -0,0 +1,116 @@ +/* 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 } from "react"; +import { AboutWelcomeUtils } from "../lib/aboutwelcome-utils.mjs"; +import { Localized } from "./MSLocalized"; + +export const Loader = () => { + return ( + <button className="primary"> + <div className="loaderContainer"> + <span className="loader" /> + </div> + </button> + ); +}; + +export const InstallButton = props => { + const [installing, setInstalling] = useState(false); + const [installComplete, setInstallComplete] = useState(false); + + let buttonLabel = installComplete ? "Installed" : "Add to Firefox"; + + function onClick(event) { + props.handleAction(event); + // Replace the label with the spinner + setInstalling(true); + + window.AWEnsureAddonInstalled(props.addonId).then(value => { + if (value === "complete") { + // Set the label to "Installed" + setInstallComplete(true); + } + // Whether the addon installs or not, we want to remove the spinner + setInstalling(false); + }); + } + + return ( + <div className="install-button-wrapper"> + {installing ? ( + <Loader /> + ) : ( + <Localized text={buttonLabel}> + <button + id={props.name} + value={props.index} + onClick={onClick} + disabled={installComplete} + className="primary" + /> + </Localized> + )} + </div> + ); +}; + +export const AddonsPicker = props => { + const { content } = props; + + if (!content) { + return null; + } + + function handleAction(event) { + const { message_id } = props; + let { action, source_id } = content.tiles.data[event.currentTarget.value]; + let { type, data } = action; + + if (type === "INSTALL_ADDON_FROM_URL") { + if (!data) { + return; + } + } + + AboutWelcomeUtils.handleUserAction({ type, data }); + AboutWelcomeUtils.sendActionTelemetry(message_id, source_id); + } + + return ( + <div className={"addons-picker-container"}> + {content.tiles.data.map(({ id, name, type, description, icon }, index) => + name ? ( + <div key={id} className="addon-container"> + <div className="rtamo-icon"> + <img + className={`${ + type === "theme" ? "rtamo-theme-icon" : "brand-logo" + }`} + src={icon} + role="presentation" + alt="" + /> + </div> + <div className="addon-details"> + <Localized text={name}> + <div className="addon-title" /> + </Localized> + <Localized text={description}> + <div className="addon-description" /> + </Localized> + </div> + <InstallButton + key={id} + addonId={id} + name={name} + handleAction={handleAction} + index={index} + /> + </div> + ) : null + )} + </div> + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/CTAParagraph.jsx b/browser/components/aboutwelcome/content-src/components/CTAParagraph.jsx new file mode 100644 index 0000000000..41726626a4 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/CTAParagraph.jsx @@ -0,0 +1,45 @@ +/* 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 { Localized } from "./MSLocalized"; + +export const CTAParagraph = props => { + const { content, handleAction } = props; + + if (!content?.text) { + return null; + } + + return ( + <h2 className="cta-paragraph"> + <Localized text={content.text}> + {content.text.string_name && typeof handleAction === "function" ? ( + <span + data-l10n-id={content.text.string_id} + onClick={handleAction} + onKeyUp={event => + ["Enter", " "].includes(event.key) ? handleAction(event) : null + } + value="cta_paragraph" + role="button" + tabIndex="0" + > + {" "} + {/* <a> is valid here because of click and keyup handling. */} + {/* <button> cannot be used due to fluent integration. <a> content is provided by fluent */} + {/* eslint-disable jsx-a11y/anchor-is-valid */} + <a + role="button" + tabIndex="0" + data-l10n-name={content.text.string_name} + > + {" "} + </a> + </span> + ) : null} + </Localized> + </h2> + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/EmbeddedMigrationWizard.jsx b/browser/components/aboutwelcome/content-src/components/EmbeddedMigrationWizard.jsx new file mode 100644 index 0000000000..2fff85abd9 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/EmbeddedMigrationWizard.jsx @@ -0,0 +1,40 @@ +/* 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, { useEffect, useRef } from "react"; + +export const EmbeddedMigrationWizard = ({ handleAction }) => { + const ref = useRef(); + useEffect(() => { + const handleBeginMigration = () => { + handleAction({ + currentTarget: { value: "migrate_start" }, + source: "primary_button", + }); + }; + const handleClose = () => { + handleAction({ currentTarget: { value: "migrate_close" } }); + }; + const { current } = ref; + current?.addEventListener( + "MigrationWizard:BeginMigration", + handleBeginMigration + ); + current?.addEventListener("MigrationWizard:Close", handleClose); + return () => { + current?.removeEventListener( + "MigrationWizard:BeginMigration", + handleBeginMigration + ); + current?.removeEventListener("MigrationWizard:Close", handleClose); + }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + return ( + <migration-wizard + force-show-import-all="false" + auto-request-state="" + ref={ref} + ></migration-wizard> + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/HelpText.jsx b/browser/components/aboutwelcome/content-src/components/HelpText.jsx new file mode 100644 index 0000000000..f7cb91df24 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/HelpText.jsx @@ -0,0 +1,54 @@ +/* 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 { Localized } from "./MSLocalized"; +import { AboutWelcomeUtils } from "../lib/aboutwelcome-utils.mjs"; +const MS_STRING_PROP = "string_id"; + +export const HelpText = props => { + if (!props.text) { + return null; + } + + if (props.hasImg) { + if (typeof props.text === "object" && props.text[MS_STRING_PROP]) { + return ( + <Localized text={props.text}> + <p className={`helptext ${props.position}`}> + <img + data-l10n-name="help-img" + className={`helptext-img ${props.position}`} + src={props.hasImg.src} + loading={AboutWelcomeUtils.getLoadingStrategyFor( + props.hasImg.src + )} + alt="" + ></img> + </p> + </Localized> + ); + } else if (typeof props.text === "string") { + // Add the img at the end of the props.text + return ( + <p className={`helptext ${props.position}`}> + {props.text} + <img + className={`helptext-img ${props.position} end`} + src={props.hasImg.src} + loading={AboutWelcomeUtils.getLoadingStrategyFor(props.hasImg.src)} + alt="" + /> + </p> + ); + } + } else { + return ( + <Localized text={props.text}> + <p className={`helptext ${props.position}`} /> + </Localized> + ); + } + return null; +}; diff --git a/browser/components/aboutwelcome/content-src/components/HeroImage.jsx b/browser/components/aboutwelcome/content-src/components/HeroImage.jsx new file mode 100644 index 0000000000..9ca89179fa --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/HeroImage.jsx @@ -0,0 +1,26 @@ +/* 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 { AboutWelcomeUtils } from "../lib/aboutwelcome-utils.mjs"; + +export const HeroImage = props => { + const { height, url, alt } = props; + + if (!url) { + return null; + } + + return ( + <div className="hero-image"> + <img + style={height ? { height } : null} + src={url} + loading={AboutWelcomeUtils.getLoadingStrategyFor(url)} + alt={alt || ""} + role={alt ? null : "presentation"} + /> + </div> + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/LanguageSwitcher.jsx b/browser/components/aboutwelcome/content-src/components/LanguageSwitcher.jsx new file mode 100644 index 0000000000..b5ebc69909 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/LanguageSwitcher.jsx @@ -0,0 +1,308 @@ +/* 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 } from "react"; +import { Localized } from "./MSLocalized"; +import { AboutWelcomeUtils } from "../lib/aboutwelcome-utils.mjs"; + +/** + * The language switcher implements a hook that should be placed at a higher level + * than the actual language switcher component, as it needs to preemptively fetch + * and install langpacks for the user if there is a language mismatch screen. + */ +export function useLanguageSwitcher( + appAndSystemLocaleInfo, + screens, + screenIndex, + setScreenIndex +) { + const languageMismatchScreenIndex = screens.findIndex( + ({ id }) => id === "AW_LANGUAGE_MISMATCH" + ); + const screen = screens[languageMismatchScreenIndex]; + + // Ensure fluent messages have the negotiatedLanguage args set, as they are rendered + // before the negotiatedLanguage is known. If the arg isn't present then Firefox will + // crash in development mode. + useEffect(() => { + if (screen?.content?.languageSwitcher) { + for (const text of Object.values(screen.content.languageSwitcher)) { + if (text?.args && text.args.negotiatedLanguage === undefined) { + text.args.negotiatedLanguage = ""; + } + } + } + }, [screen]); + + // If there is a mismatch, then Firefox can negotiate a better langpack to offer + // the user. + const [negotiatedLanguage, setNegotiatedLanguage] = useState(null); + useEffect( + function getNegotiatedLanguage() { + if (!appAndSystemLocaleInfo) { + return; + } + if (appAndSystemLocaleInfo.matchType !== "language-mismatch") { + // There is no language mismatch, so there is no need to negotiate a langpack. + return; + } + + (async () => { + const { langPack, langPackDisplayName } = + await window.AWNegotiateLangPackForLanguageMismatch( + appAndSystemLocaleInfo + ); + if (langPack) { + setNegotiatedLanguage({ + langPackDisplayName, + appDisplayName: appAndSystemLocaleInfo.displayNames.appLanguage, + langPack, + requestSystemLocales: [ + langPack.target_locale, + appAndSystemLocaleInfo.appLocaleRaw, + ], + originalAppLocales: [appAndSystemLocaleInfo.appLocaleRaw], + }); + } else { + setNegotiatedLanguage({ + langPackDisplayName: null, + appDisplayName: null, + langPack: null, + requestSystemLocales: null, + }); + } + })(); + }, + [appAndSystemLocaleInfo] + ); + + /** + * @type { + * "before-installation" + * | "installing" + * | "installed" + * | "installation-error" + * | "none-available" + * } + */ + const [langPackInstallPhase, setLangPackInstallPhase] = useState( + "before-installation" + ); + useEffect( + function ensureLangPackInstalled() { + if (!negotiatedLanguage) { + // There are no negotiated languages to download yet. + return; + } + setLangPackInstallPhase("installing"); + window + .AWEnsureLangPackInstalled(negotiatedLanguage, screen?.content) + .then( + content => { + // Update screen content with strings that might have changed. + screen.content = content; + setLangPackInstallPhase("installed"); + }, + error => { + console.error(error); + setLangPackInstallPhase("installation-error"); + } + ); + }, + [negotiatedLanguage, screen] + ); + + const [languageFilteredScreens, setLanguageFilteredScreens] = + useState(screens); + useEffect( + function filterScreen() { + // Remove the language screen if it exists (already removed for no live + // reload) and we either don't-need-to or can't switch. + if ( + screen && + (appAndSystemLocaleInfo?.matchType !== "language-mismatch" || + negotiatedLanguage?.langPack === null) + ) { + if (screenIndex > languageMismatchScreenIndex) { + setScreenIndex(screenIndex - 1); + } + setLanguageFilteredScreens( + screens.filter(s => s.id !== "AW_LANGUAGE_MISMATCH") + ); + } else { + setLanguageFilteredScreens(screens); + } + }, + // Removing screenIndex as a dependency as it's causing infinite re-renders (1873019) + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + appAndSystemLocaleInfo?.matchType, + languageMismatchScreenIndex, + negotiatedLanguage, + screen, + screens, + setScreenIndex, + ] + ); + + return { + negotiatedLanguage, + langPackInstallPhase, + languageFilteredScreens, + }; +} + +/** + * The language switcher is a separate component as it needs to perform some asynchronous + * network actions such as retrieving the list of langpacks available, and downloading + * a new langpack. On a fast connection, this won't be noticeable, but on slow or unreliable + * internet this may fail for a user. + */ +export function LanguageSwitcher(props) { + const { + content, + handleAction, + negotiatedLanguage, + langPackInstallPhase, + messageId, + } = props; + + const [isAwaitingLangpack, setIsAwaitingLangpack] = useState(false); + + // Determine the status of the langpack installation. + useEffect(() => { + if (isAwaitingLangpack && langPackInstallPhase !== "installing") { + window.AWSetRequestedLocales(negotiatedLanguage.requestSystemLocales); + requestAnimationFrame(() => { + handleAction( + // Simulate the click event. + { currentTarget: { value: "download_complete" } } + ); + }); + } + }, [ + handleAction, + isAwaitingLangpack, + langPackInstallPhase, + negotiatedLanguage?.requestSystemLocales, + ]); + + let showWaitingScreen = false; + let showPreloadingScreen = false; + let showReadyScreen = false; + + if (isAwaitingLangpack && langPackInstallPhase !== "installed") { + showWaitingScreen = true; + } else if (langPackInstallPhase === "before-installation") { + showPreloadingScreen = true; + } else { + showReadyScreen = true; + } + + // Use {display: "none"} rather than if statements to prevent layout thrashing with + // the localized text elements rendering as blank, then filling in the text. + return ( + <div className="action-buttons language-switcher-container"> + {/* Pre-loading screen */} + <div style={{ display: showPreloadingScreen ? "block" : "none" }}> + <button + className="primary" + value="primary_button" + disabled={true} + type="button" + > + <img + className="language-loader" + src="chrome://browser/skin/tabbrowser/tab-connecting.png" + alt="" + /> + <Localized text={content.languageSwitcher.waiting} /> + </button> + <div className="secondary-cta"> + <Localized text={content.languageSwitcher.skip}> + <button + value="decline_waiting" + type="button" + className="secondary text-link arrow-icon" + onClick={handleAction} + /> + </Localized> + </div> + </div> + {/* Waiting to download the language screen. */} + <div style={{ display: showWaitingScreen ? "block" : "none" }}> + <button + className="primary" + value="primary_button" + disabled={true} + type="button" + > + <img + className="language-loader" + src="chrome://browser/skin/tabbrowser/tab-connecting.png" + alt="" + /> + <Localized text={content.languageSwitcher.downloading} /> + </button> + <div className="secondary-cta"> + <Localized text={content.languageSwitcher.cancel}> + <button + type="button" + className="secondary text-link" + onClick={() => { + setIsAwaitingLangpack(false); + handleAction({ + currentTarget: { value: "cancel_waiting" }, + }); + }} + /> + </Localized> + </div> + </div> + {/* The typical ready screen. */} + <div style={{ display: showReadyScreen ? "block" : "none" }}> + <div> + <button + className="primary" + value="primary_button" + onClick={() => { + AboutWelcomeUtils.sendActionTelemetry( + messageId, + "download_langpack" + ); + setIsAwaitingLangpack(true); + }} + > + {content.languageSwitcher.switch ? ( + <Localized text={content.languageSwitcher.switch} /> + ) : ( + // This is the localized name from the Intl.DisplayNames API. + negotiatedLanguage?.langPackDisplayName + )} + </button> + </div> + <div> + <button + type="button" + className="primary" + value="decline" + onClick={event => { + window.AWSetRequestedLocales( + negotiatedLanguage.originalAppLocales + ); + handleAction(event); + }} + > + {content.languageSwitcher.continue ? ( + <Localized text={content.languageSwitcher.continue} /> + ) : ( + // This is the localized name from the Intl.DisplayNames API. + negotiatedLanguage?.appDisplayName + )} + </button> + </div> + </div> + </div> + ); +} diff --git a/browser/components/aboutwelcome/content-src/components/LinkParagraph.jsx b/browser/components/aboutwelcome/content-src/components/LinkParagraph.jsx new file mode 100644 index 0000000000..14de368b2a --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/LinkParagraph.jsx @@ -0,0 +1,59 @@ +/* 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, { useCallback } from "react"; +import { Localized } from "./MSLocalized"; + +export const LinkParagraph = props => { + const { text_content, handleAction } = props; + + const handleParagraphAction = useCallback( + event => { + if (event.target.closest("a")) { + handleAction({ ...event, currentTarget: event.target }); + } + }, + [handleAction] + ); + + const onKeyPress = useCallback( + event => { + if (event.key === "Enter" && !event.repeat) { + handleParagraphAction(event); + } + }, + [handleParagraphAction] + ); + + return ( + <Localized text={text_content.text}> + {/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */} + <p + className={ + text_content.font_styles === "legal" + ? "legal-paragraph" + : "link-paragraph" + } + onClick={handleParagraphAction} + value="link_paragraph" + onKeyPress={onKeyPress} + > + {/* eslint-disable jsx-a11y/anchor-is-valid */} + {text_content.link_keys?.map(link => ( + <a + key={link} + value={link} + role="link" + className="text-link" + data-l10n-name={link} + // must pass in tabIndex when no href is provided + tabIndex="0" + > + {" "} + </a> + ))} + </p> + </Localized> + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/MRColorways.jsx b/browser/components/aboutwelcome/content-src/components/MRColorways.jsx new file mode 100644 index 0000000000..758e6ddc4a --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/MRColorways.jsx @@ -0,0 +1,200 @@ +/* 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 } from "react"; +import { Localized } from "./MSLocalized"; + +export const ColorwayDescription = props => { + const { colorway } = props; + if (!colorway) { + return null; + } + const { label, description } = colorway; + return ( + <Localized text={description}> + <div + className="colorway-text" + data-l10n-args={JSON.stringify({ + colorwayName: label, + })} + /> + </Localized> + ); +}; + +// Return colorway as "default" for default theme variations Automatic, Light, Dark, +// Alpenglow theme and legacy colorways which is not supported in Colorway picker. +// For themes other then default, theme names exist in +// format colorway-variationId inside LIGHT_WEIGHT_THEMES in AboutWelcomeParent +export function computeColorWay(themeName, systemVariations) { + return !themeName || + themeName === "alpenglow" || + systemVariations.includes(themeName) + ? "default" + : themeName.split("-")[0]; +} + +// Set variationIndex based off activetheme value e.g. 'light', 'expressionist-soft' +export function computeVariationIndex( + themeName, + systemVariations, + variations, + defaultVariationIndex +) { + // Check if themeName is in systemVariations, if yes choose variationIndex by themeName + let index = systemVariations.findIndex(theme => theme === themeName); + if (index >= 0) { + return index; + } + + // If themeName is one of the colorways, select variation index from colorways + let variation = themeName?.split("-")[1]; + index = variations.findIndex(element => element === variation); + if (index >= 0) { + return index; + } + return defaultVariationIndex; +} + +export function Colorways(props) { + let { + colorways, + darkVariation, + defaultVariationIndex, + systemVariations, + variations, + } = props.content.tiles; + let hasReverted = false; + + // Active theme id from JSON e.g. "expressionist" + const activeId = computeColorWay(props.activeTheme, systemVariations); + const [colorwayId, setState] = useState(activeId); + const [variationIndex, setVariationIndex] = useState(defaultVariationIndex); + + function revertToDefaultTheme() { + if (hasReverted) { + return; + } + + // Spoofing an event with current target value of "navigate_away" + // helps the handleAction method to read the colorways theme as "revert" + // which causes the initial theme to be activated. + // The "navigate_away" action is set in content in the colorways screen JSON config. + // Any value in the JSON for theme will work, provided it is not `<event>`. + const event = { + currentTarget: { + value: "navigate_away", + }, + }; + props.handleAction(event); + hasReverted = true; + } + + // Revert to default theme if the user navigates away from the page or spotlight modal + // before clicking on the primary button to officially set theme. + useEffect(() => { + addEventListener("beforeunload", revertToDefaultTheme); + addEventListener("pagehide", revertToDefaultTheme); + + return () => { + removeEventListener("beforeunload", revertToDefaultTheme); + removeEventListener("pagehide", revertToDefaultTheme); + }; + }); + // Update state any time activeTheme changes. + useEffect(() => { + setState(computeColorWay(props.activeTheme, systemVariations)); + setVariationIndex( + computeVariationIndex( + props.activeTheme, + systemVariations, + variations, + defaultVariationIndex + ) + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.activeTheme]); + + //select a random colorway + useEffect(() => { + //We don't want the default theme to be selected + const randomIndex = Math.floor(Math.random() * (colorways.length - 1)) + 1; + const randomColorwayId = colorways[randomIndex].id; + + // Change the variation to be the dark variation if configured and dark. + // Additional colorway changes will remain dark while system is unchanged. + if ( + darkVariation !== undefined && + window.matchMedia("(prefers-color-scheme: dark)").matches + ) { + variations[variationIndex] = variations[darkVariation]; + } + const value = `${randomColorwayId}-${variations[variationIndex]}`; + props.handleAction({ currentTarget: { value } }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <div className="tiles-theme-container"> + <div> + <fieldset className="tiles-theme-section"> + <Localized text={props.content.subtitle}> + <legend className="sr-only" /> + </Localized> + {colorways.map(({ id, label, tooltip }) => ( + <Localized + key={id + label} + text={typeof tooltip === "object" ? tooltip : {}} + > + <label + className="theme" + title={label} + data-l10n-args={JSON.stringify({ + colorwayName: label, + })} + > + <Localized text={typeof tooltip === "object" ? tooltip : {}}> + <span + className="sr-only colorway label" + id={`${id}-label`} + data-l10n-args={JSON.stringify({ + colorwayName: tooltip, + })} + /> + </Localized> + <Localized text={typeof label === "object" ? label : {}}> + <input + type="radio" + data-colorway={id} + name="theme" + value={ + id === "default" + ? systemVariations[variationIndex] + : `${id}-${variations[variationIndex]}` + } + checked={colorwayId === id} + className="sr-only input" + onClick={props.handleAction} + data-l10n-args={JSON.stringify({ + colorwayName: label, + })} + aria-labelledby={`${id}-label`} + /> + </Localized> + <div + className={`icon colorway ${ + colorwayId === id ? "selected" : "" + } ${id}`} + /> + </label> + </Localized> + ))} + </fieldset> + </div> + <ColorwayDescription + colorway={colorways.find(colorway => colorway.id === activeId)} + /> + </div> + ); +} diff --git a/browser/components/aboutwelcome/content-src/components/MSLocalized.jsx b/browser/components/aboutwelcome/content-src/components/MSLocalized.jsx new file mode 100644 index 0000000000..42fb071475 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/MSLocalized.jsx @@ -0,0 +1,114 @@ +/* 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, { useEffect } from "react"; +export const CONFIGURABLE_STYLES = [ + "color", + "fontSize", + "fontWeight", + "letterSpacing", + "lineHeight", + "marginBlock", + "marginInline", + "paddingBlock", + "paddingInline", + "whiteSpace", +]; +const ZAP_SIZE_THRESHOLD = 160; + +/** + * Based on the .text prop, localizes an inner element if a string_id + * is provided, OR renders plain text, OR hides it if nothing is provided. + * Allows configuring of some styles including zap underline and color. + * + * Examples: + * + * Localized text + * ftl: + * title = Welcome + * jsx: + * <Localized text={{string_id: "title"}}><h1 /></Localized> + * output: + * <h1 data-l10n-id="title">Welcome</h1> + * + * Unlocalized text + * jsx: + * <Localized text="Welcome"><h1 /></Localized> + * <Localized text={{raw: "Welcome"}}><h1 /></Localized> + * output: + * <h1>Welcome</h1> + */ + +export const Localized = ({ text, children }) => { + // Dynamically determine the size of the zap style. + const zapRef = React.createRef(); + useEffect(() => { + const { current } = zapRef; + if (current) { + requestAnimationFrame(() => + current?.classList.replace( + "short", + current.getBoundingClientRect().width > ZAP_SIZE_THRESHOLD + ? "long" + : "short" + ) + ); + } + }); + + // Skip rendering of children with no text. + if (!text) { + return null; + } + + // Allow augmenting existing child container properties. + const props = { children: [], className: "", style: {}, ...children?.props }; + // Support nested Localized by starting with their children. + const textNodes = Array.isArray(props.children) + ? props.children + : [props.children]; + + // Pick desired fluent or raw/plain text to render. + if (text.string_id) { + // Set the key so React knows not to reuse when switching to plain text. + props.key = text.string_id; + props["data-l10n-id"] = text.string_id; + if (text.args) { + props["data-l10n-args"] = JSON.stringify(text.args); + } + } else if (text.raw) { + textNodes.push(text.raw); + } else if (typeof text === "string") { + textNodes.push(text); + } + + // Add zap style and content in a way that allows fluent to insert too. + if (text.zap) { + props.className += " welcomeZap"; + textNodes.push( + <span className="short zap" data-l10n-name="zap" ref={zapRef}> + {text.zap} + </span> + ); + } + + if (text.aria_label) { + props["aria-label"] = text.aria_label; + } + + // Apply certain configurable styles. + CONFIGURABLE_STYLES.forEach(style => { + if (text[style] !== undefined) { + props.style[style] = text[style]; + } + }); + + return React.cloneElement( + // Provide a default container for the text if necessary. + children ?? <span />, + props, + // Conditionally pass in as void elements can't accept empty array. + textNodes.length ? textNodes : null + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/MobileDownloads.jsx b/browser/components/aboutwelcome/content-src/components/MobileDownloads.jsx new file mode 100644 index 0000000000..fbd0940805 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/MobileDownloads.jsx @@ -0,0 +1,73 @@ +/* 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 { Localized } from "./MSLocalized"; +import { AboutWelcomeUtils } from "../lib/aboutwelcome-utils.mjs"; + +export const MarketplaceButtons = props => { + return ( + <ul className="mobile-download-buttons"> + {props.buttons.includes("ios") ? ( + <li className="ios"> + <button + data-l10n-id={"spotlight-ios-marketplace-button"} + value="ios" + onClick={props.handleAction} + ></button> + </li> + ) : null} + {props.buttons.includes("android") ? ( + <li className="android"> + <button + data-l10n-id={"spotlight-android-marketplace-button"} + value="android" + onClick={props.handleAction} + ></button> + </li> + ) : null} + </ul> + ); +}; + +export const MobileDownloads = props => { + const { QR_code: QRCode } = props.data; + const showEmailLink = + props.data.email && window.AWSendToDeviceEmailsSupported(); + + return ( + <div className="mobile-downloads"> + {/* Avoid use of Localized element to set alt text here as a plain string value + results in a React error due to "dangerouslySetInnerHTML" */} + {QRCode ? ( + <img + data-l10n-id={ + QRCode.alt_text.string_id ? QRCode.alt_text.string_id : null + } + className="qr-code-image" + alt={typeof QRCode.alt_text === "string" ? QRCode.alt_text : ""} + src={QRCode.image_url} + loading={AboutWelcomeUtils.getLoadingStrategyFor(QRCode.image_url)} + /> + ) : null} + {showEmailLink ? ( + <div> + <Localized text={props.data.email.link_text}> + <button + className="email-link" + value="email_link" + onClick={props.handleAction} + /> + </Localized> + </div> + ) : null} + {props.data.marketplace_buttons ? ( + <MarketplaceButtons + buttons={props.data.marketplace_buttons} + handleAction={props.handleAction} + /> + ) : null} + </div> + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/MultiSelect.jsx b/browser/components/aboutwelcome/content-src/components/MultiSelect.jsx new file mode 100644 index 0000000000..f65665a7b2 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/MultiSelect.jsx @@ -0,0 +1,158 @@ +/* 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, { useEffect, useCallback, useMemo, useRef } from "react"; +import { Localized, CONFIGURABLE_STYLES } from "./MSLocalized"; + +const MULTI_SELECT_STYLES = [ + ...CONFIGURABLE_STYLES, + "flexDirection", + "flexWrap", + "flexFlow", + "flexGrow", + "flexShrink", + "justifyContent", + "alignItems", + "gap", +]; + +const MULTI_SELECT_ICON_STYLES = [ + ...CONFIGURABLE_STYLES, + "width", + "height", + "background", + "backgroundColor", + "backgroundImage", + "backgroundSize", + "backgroundPosition", + "backgroundRepeat", + "backgroundOrigin", + "backgroundClip", + "border", + "borderRadius", + "appearance", + "fill", + "stroke", + "outline", + "outlineOffset", + "boxShadow", +]; + +function getValidStyle(style, validStyles, allowVars) { + if (!style) { + return null; + } + return Object.keys(style) + .filter( + key => validStyles.includes(key) || (allowVars && key.startsWith("--")) + ) + .reduce((obj, key) => { + obj[key] = style[key]; + return obj; + }, {}); +} + +export const MultiSelect = ({ + content, + screenMultiSelects, + setScreenMultiSelects, + activeMultiSelect, + setActiveMultiSelect, +}) => { + const { data } = content.tiles; + + const refs = useRef({}); + + const handleChange = useCallback(() => { + const newActiveMultiSelect = []; + Object.keys(refs.current).forEach(key => { + if (refs.current[key]?.checked) { + newActiveMultiSelect.push(key); + } + }); + setActiveMultiSelect(newActiveMultiSelect); + }, [setActiveMultiSelect]); + + const items = useMemo( + () => { + function getOrderedIds() { + if (screenMultiSelects) { + return screenMultiSelects; + } + let orderedIds = data + .map(item => ({ + id: item.id, + rank: item.randomize ? Math.random() : NaN, + })) + .sort((a, b) => b.rank - a.rank) + .map(({ id }) => id); + setScreenMultiSelects(orderedIds); + return orderedIds; + } + return getOrderedIds().map(id => data.find(item => item.id === id)); + }, + [] // eslint-disable-line react-hooks/exhaustive-deps + ); + + const containerStyle = useMemo( + () => getValidStyle(content.tiles.style, MULTI_SELECT_STYLES, true), + [content.tiles.style] + ); + + // When screen renders for first time, update state + // with checkbox ids that has defaultvalue true + useEffect(() => { + if (!activeMultiSelect) { + let newActiveMultiSelect = []; + items.forEach(({ id, defaultValue }) => { + if (defaultValue && id) { + newActiveMultiSelect.push(id); + } + }); + setActiveMultiSelect(newActiveMultiSelect); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + <div + className="multi-select-container" + style={containerStyle} + role={ + items.some(({ type, group }) => type === "radio" && group) + ? "radiogroup" + : "group" + } + aria-labelledby="multi-stage-multi-select-label" + > + {content.tiles.label ? ( + <Localized text={content.tiles.label}> + <h2 id="multi-stage-multi-select-label" /> + </Localized> + ) : null} + {items.map(({ id, label, icon, type = "checkbox", group, style }) => ( + <div + key={id + label} + className="checkbox-container multi-select-item" + style={getValidStyle(style, MULTI_SELECT_STYLES)} + > + <input + type={type} // checkbox or radio + id={id} + value={id} + name={group} + checked={activeMultiSelect?.includes(id)} + style={getValidStyle(icon?.style, MULTI_SELECT_ICON_STYLES)} + onChange={handleChange} + ref={el => (refs.current[id] = el)} + /> + {label ? ( + <Localized text={label}> + <label htmlFor={id}></label> + </Localized> + ) : null} + </div> + ))} + </div> + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/MultiStageAboutWelcome.jsx b/browser/components/aboutwelcome/content-src/components/MultiStageAboutWelcome.jsx new file mode 100644 index 0000000000..034055bf3d --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/MultiStageAboutWelcome.jsx @@ -0,0 +1,568 @@ +/* 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 ( + <React.Fragment> + <div + className={`outer-wrapper onboardingContainer proton transition-${transition}`} + style={props.backdrop ? { background: props.backdrop } : {}} + > + {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 ? ( + <WelcomeScreen + key={screen.id + order} + id={screen.id} + totalNumberOfScreens={totalNumberOfScreens} + isFirstScreen={isFirstScreen} + isLastScreen={isLastScreen} + isSingleScreen={isSingleScreen} + order={order} + previousOrder={previousOrder} + content={screen.content} + navigate={handleTransition} + messageId={`${props.message_id}_${order}_${screen.id}`} + UTMTerm={props.utm_term} + flowParams={flowParams} + activeTheme={activeTheme} + initialTheme={initialTheme} + setActiveTheme={setActiveTheme} + setInitialTheme={setInitialTheme} + screenMultiSelects={multiSelects[screen.id]} + setScreenMultiSelects={setScreenMultiSelects} + activeMultiSelect={activeMultiSelects[screen.id]} + setActiveMultiSelect={setActiveMultiSelect} + autoAdvance={screen.auto_advance} + negotiatedLanguage={negotiatedLanguage} + langPackInstallPhase={langPackInstallPhase} + forceHideStepsIndicator={screen.force_hide_steps_indicator} + ariaRole={props.ariaRole} + aboveButtonStepsIndicator={screen.above_button_steps_indicator} + /> + ) : null; + })} + </div> + </React.Fragment> + ); +}; + +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 ( + <div className={className}> + <Localized text={props.content[targetElement].text}> + <span /> + </Localized> + <Localized text={props.content[targetElement].label}> + <button + className={buttonStyling} + value={targetElement} + disabled={isDisabled(props.content.secondary_button?.disabled)} + onClick={props.handleAction} + /> + </Localized> + {isSplitButton ? ( + <SubmenuButton + content={props.content} + handleAction={props.handleAction} + /> + ) : null} + </div> + ); +}; + +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( + <div key={i} className={`indicator ${className}`} role="presentation" /> + ); + } + 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 ( + <div + className="indicator" + role="presentation" + style={{ "--progress-bar-progress": `${progress * 100}%` }} + /> + ); +}; + +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>" + ? 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 ( + <MultiStageProtonScreen + content={this.props.content} + id={this.props.id} + order={this.props.order} + previousOrder={this.props.previousOrder} + activeTheme={this.props.activeTheme} + screenMultiSelects={this.props.screenMultiSelects} + setScreenMultiSelects={this.props.setScreenMultiSelects} + activeMultiSelect={this.props.activeMultiSelect} + setActiveMultiSelect={this.props.setActiveMultiSelect} + totalNumberOfScreens={this.props.totalNumberOfScreens} + appAndSystemLocaleInfo={this.props.appAndSystemLocaleInfo} + negotiatedLanguage={this.props.negotiatedLanguage} + langPackInstallPhase={this.props.langPackInstallPhase} + handleAction={this.handleAction} + messageId={this.props.messageId} + isFirstScreen={this.props.isFirstScreen} + isLastScreen={this.props.isLastScreen} + isSingleScreen={this.props.isSingleScreen} + startsWithCorner={this.props.startsWithCorner} + autoAdvance={this.props.autoAdvance} + forceHideStepsIndicator={this.props.forceHideStepsIndicator} + ariaRole={this.props.ariaRole} + aboveButtonStepsIndicator={this.props.aboveButtonStepsIndicator} + /> + ); + } +} diff --git a/browser/components/aboutwelcome/content-src/components/MultiStageProtonScreen.jsx b/browser/components/aboutwelcome/content-src/components/MultiStageProtonScreen.jsx new file mode 100644 index 0000000000..ffe64f05f1 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/MultiStageProtonScreen.jsx @@ -0,0 +1,620 @@ +/* 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, { useEffect, useState } from "react"; +import { Localized } from "./MSLocalized"; +import { AboutWelcomeUtils } from "../lib/aboutwelcome-utils.mjs"; +import { MobileDownloads } from "./MobileDownloads"; +import { MultiSelect } from "./MultiSelect"; +import { Themes } from "./Themes"; +import { + SecondaryCTA, + StepsIndicator, + ProgressBar, +} from "./MultiStageAboutWelcome"; +import { LanguageSwitcher } from "./LanguageSwitcher"; +import { CTAParagraph } from "./CTAParagraph"; +import { HeroImage } from "./HeroImage"; +import { OnboardingVideo } from "./OnboardingVideo"; +import { AdditionalCTA } from "./AdditionalCTA"; +import { EmbeddedMigrationWizard } from "./EmbeddedMigrationWizard"; +import { AddonsPicker } from "./AddonsPicker"; +import { LinkParagraph } from "./LinkParagraph"; + +export const MultiStageProtonScreen = props => { + const { autoAdvance, handleAction, order } = props; + useEffect(() => { + if (autoAdvance) { + const timer = setTimeout(() => { + handleAction({ + currentTarget: { + value: autoAdvance, + }, + name: "AUTO_ADVANCE", + }); + }, 20000); + return () => clearTimeout(timer); + } + return () => {}; + }, [autoAdvance, handleAction, order]); + + return ( + <ProtonScreen + content={props.content} + id={props.id} + order={props.order} + activeTheme={props.activeTheme} + screenMultiSelects={props.screenMultiSelects} + setScreenMultiSelects={props.setScreenMultiSelects} + activeMultiSelect={props.activeMultiSelect} + setActiveMultiSelect={props.setActiveMultiSelect} + totalNumberOfScreens={props.totalNumberOfScreens} + handleAction={props.handleAction} + isFirstScreen={props.isFirstScreen} + isLastScreen={props.isLastScreen} + isSingleScreen={props.isSingleScreen} + previousOrder={props.previousOrder} + autoAdvance={props.autoAdvance} + isRtamo={props.isRtamo} + addonName={props.addonName} + isTheme={props.isTheme} + iconURL={props.iconURL} + messageId={props.messageId} + negotiatedLanguage={props.negotiatedLanguage} + langPackInstallPhase={props.langPackInstallPhase} + forceHideStepsIndicator={props.forceHideStepsIndicator} + ariaRole={props.ariaRole} + aboveButtonStepsIndicator={props.aboveButtonStepsIndicator} + /> + ); +}; + +export const ProtonScreenActionButtons = props => { + const { content, addonName, activeMultiSelect } = props; + const defaultValue = content.checkbox?.defaultValue; + + const [isChecked, setIsChecked] = useState(defaultValue || false); + const buttonRef = React.useRef(null); + + const shouldFocusButton = content?.primary_button?.should_focus_button; + + useEffect(() => { + if (shouldFocusButton) { + buttonRef.current?.focus(); + } + }, [shouldFocusButton]); + + if ( + !content.primary_button && + !content.secondary_button && + !content.additional_button + ) { + return null; + } + + // If we have a multi-select screen, we want to disable the primary button + // until the user has selected at least one item. + const isPrimaryDisabled = primaryDisabledValue => + primaryDisabledValue === "hasActiveMultiSelect" + ? !(activeMultiSelect?.length > 0) + : primaryDisabledValue; + + return ( + <div + className={`action-buttons ${ + content.additional_button ? "additional-cta-container" : "" + }`} + flow={content.additional_button?.flow} + alignment={content.additional_button?.alignment} + > + <Localized text={content.primary_button?.label}> + <button + ref={buttonRef} + className={`${content.primary_button?.style ?? "primary"}${ + content.primary_button?.has_arrow_icon ? " arrow-icon" : "" + }`} + // Whether or not the checkbox is checked determines which action + // should be handled. By setting value here, we indicate to + // this.handleAction() where in the content tree it should take + // the action to execute from. + value={isChecked ? "checkbox" : "primary_button"} + disabled={isPrimaryDisabled(content.primary_button?.disabled)} + onClick={props.handleAction} + data-l10n-args={ + addonName + ? JSON.stringify({ + "addon-name": addonName, + }) + : "" + } + /> + </Localized> + {content.additional_button ? ( + <AdditionalCTA content={content} handleAction={props.handleAction} /> + ) : null} + {content.checkbox ? ( + <div className="checkbox-container"> + <input + type="checkbox" + id="action-checkbox" + checked={isChecked} + onChange={() => { + setIsChecked(!isChecked); + }} + ></input> + <Localized text={content.checkbox.label}> + <label htmlFor="action-checkbox"></label> + </Localized> + </div> + ) : null} + {content.secondary_button ? ( + <SecondaryCTA + content={content} + handleAction={props.handleAction} + activeMultiSelect={activeMultiSelect} + /> + ) : null} + </div> + ); +}; + +export class ProtonScreen extends React.PureComponent { + componentDidMount() { + this.mainContentHeader.focus(); + } + + getScreenClassName( + isFirstScreen, + isLastScreen, + includeNoodles, + isVideoOnboarding, + isAddonsPicker + ) { + const screenClass = `screen-${this.props.order % 2 !== 0 ? 1 : 2}`; + + if (isVideoOnboarding) { + return "with-video"; + } + + if (isAddonsPicker) { + return "addons-picker"; + } + + return `${isFirstScreen ? `dialog-initial` : ``} ${ + isLastScreen ? `dialog-last` : `` + } ${includeNoodles ? `with-noodles` : ``} ${screenClass}`; + } + + renderTitle({ title, title_logo }) { + if (title_logo) { + const { alignment, ...rest } = title_logo; + return ( + <div + className="inline-icon-container" + alignment={alignment ?? "center"} + > + {this.renderPicture({ ...rest })} + <Localized text={title}> + <h1 id="mainContentHeader" /> + </Localized> + </div> + ); + } + return ( + <Localized text={title}> + <h1 id="mainContentHeader" /> + </Localized> + ); + } + + renderPicture({ + imageURL = "chrome://branding/content/about-logo.svg", + darkModeImageURL, + reducedMotionImageURL, + darkModeReducedMotionImageURL, + alt = "", + width, + height, + marginBlock, + marginInline, + className = "logo-container", + }) { + function getLoadingStrategy() { + for (let url of [ + imageURL, + darkModeImageURL, + reducedMotionImageURL, + darkModeReducedMotionImageURL, + ]) { + if (AboutWelcomeUtils.getLoadingStrategyFor(url) === "lazy") { + return "lazy"; + } + } + return "eager"; + } + + return ( + <picture className={className} style={{ marginInline, marginBlock }}> + {darkModeReducedMotionImageURL ? ( + <source + srcSet={darkModeReducedMotionImageURL} + media="(prefers-color-scheme: dark) and (prefers-reduced-motion: reduce)" + /> + ) : null} + {darkModeImageURL ? ( + <source + srcSet={darkModeImageURL} + media="(prefers-color-scheme: dark)" + /> + ) : null} + {reducedMotionImageURL ? ( + <source + srcSet={reducedMotionImageURL} + media="(prefers-reduced-motion: reduce)" + /> + ) : null} + <Localized text={alt}> + <div className="sr-only logo-alt" /> + </Localized> + <img + className="brand-logo" + style={{ height, width }} + src={imageURL} + alt="" + loading={getLoadingStrategy()} + role={alt ? null : "presentation"} + /> + </picture> + ); + } + + renderContentTiles() { + const { content } = this.props; + return ( + <React.Fragment> + {content.tiles && + content.tiles.type === "addons-picker" && + content.tiles.data ? ( + <AddonsPicker + content={content} + message_id={this.props.messageId} + handleAction={this.props.handleAction} + /> + ) : null} + {content.tiles && + content.tiles.type === "theme" && + content.tiles.data ? ( + <Themes + content={content} + activeTheme={this.props.activeTheme} + handleAction={this.props.handleAction} + /> + ) : null} + {content.tiles && + content.tiles.type === "mobile_downloads" && + content.tiles.data ? ( + <MobileDownloads + data={content.tiles.data} + handleAction={this.props.handleAction} + /> + ) : null} + {content.tiles && + content.tiles.type === "multiselect" && + content.tiles.data ? ( + <MultiSelect + content={content} + screenMultiSelects={this.props.screenMultiSelects} + setScreenMultiSelects={this.props.setScreenMultiSelects} + activeMultiSelect={this.props.activeMultiSelect} + setActiveMultiSelect={this.props.setActiveMultiSelect} + /> + ) : null} + {content.tiles && content.tiles.type === "migration-wizard" ? ( + <EmbeddedMigrationWizard handleAction={this.props.handleAction} /> + ) : null} + </React.Fragment> + ); + } + + renderNoodles() { + return ( + <React.Fragment> + <div className={"noodle orange-L"} /> + <div className={"noodle purple-C"} /> + <div className={"noodle solid-L"} /> + <div className={"noodle outline-L"} /> + <div className={"noodle yellow-circle"} /> + </React.Fragment> + ); + } + + renderLanguageSwitcher() { + return this.props.content.languageSwitcher ? ( + <LanguageSwitcher + content={this.props.content} + handleAction={this.props.handleAction} + negotiatedLanguage={this.props.negotiatedLanguage} + langPackInstallPhase={this.props.langPackInstallPhase} + messageId={this.props.messageId} + /> + ) : null; + } + + renderDismissButton() { + const { size, marginBlock, marginInline, label } = + this.props.content.dismiss_button; + return ( + <button + className="dismiss-button" + onClick={this.props.handleAction} + value="dismiss_button" + data-l10n-id={label?.string_id || "spotlight-dialog-close-button"} + button-size={size} + style={{ marginBlock, marginInline }} + ></button> + ); + } + + renderStepsIndicator() { + const currentStep = (this.props.order ?? 0) + 1; + const previousStep = (this.props.previousOrder ?? -1) + 1; + const { content, totalNumberOfScreens: total } = this.props; + return ( + <div + id="steps" + className={`steps${content.progress_bar ? " progress-bar" : ""}`} + data-l10n-id={ + content.steps_indicator?.string_id || + "onboarding-welcome-steps-indicator-label" + } + data-l10n-args={JSON.stringify({ + current: currentStep, + total: total ?? 0, + })} + data-l10n-attrs="aria-label" + role="progressbar" + aria-valuenow={currentStep} + aria-valuemin={1} + aria-valuemax={total} + > + {content.progress_bar ? ( + <ProgressBar + step={currentStep} + previousStep={previousStep} + totalNumberOfScreens={total} + /> + ) : ( + <StepsIndicator + order={this.props.order} + totalNumberOfScreens={total} + /> + )} + </div> + ); + } + + renderSecondarySection(content) { + return ( + <div + className={`section-secondary ${ + content.hide_secondary_section ? "with-secondary-section-hidden" : "" + }`} + style={ + content.background + ? { + background: content.background, + "--mr-secondary-background-position-y": + content.split_narrow_bkg_position, + } + : {} + } + > + <Localized text={content.image_alt_text}> + <div className="sr-only image-alt" role="img" /> + </Localized> + {content.hero_image ? ( + <HeroImage url={content.hero_image.url} /> + ) : ( + <React.Fragment> + <div className="message-text"> + <div className="spacer-top" /> + <Localized text={content.hero_text}> + <h1 /> + </Localized> + <div className="spacer-bottom" /> + </div> + </React.Fragment> + )} + </div> + ); + } + + renderOrderedContent(content) { + const elements = []; + for (const item of content) { + switch (item.type) { + case "text": + elements.push( + <LinkParagraph + text_content={item} + handleAction={this.props.handleAction} + /> + ); + break; + case "image": + elements.push( + this.renderPicture({ + imageURL: item.url, + darkModeImageURL: item.darkModeImageURL, + height: item.height, + width: item.width, + alt: item.alt_text, + marginInline: item.marginInline, + className: "inline-image", + }) + ); + } + } + return <>{elements}</>; + } + + render() { + const { + autoAdvance, + content, + isRtamo, + isTheme, + isFirstScreen, + isLastScreen, + isSingleScreen, + forceHideStepsIndicator, + ariaRole, + aboveButtonStepsIndicator, + } = this.props; + const includeNoodles = content.has_noodles; + // The default screen position is "center" + const isCenterPosition = content.position === "center" || !content.position; + const hideStepsIndicator = + autoAdvance || + content?.video_container || + isSingleScreen || + forceHideStepsIndicator; + const textColorClass = content.text_color + ? `${content.text_color}-text` + : ""; + // Assign proton screen style 'screen-1' or 'screen-2' to centered screens + // by checking if screen order is even or odd. + const screenClassName = isCenterPosition + ? this.getScreenClassName( + isFirstScreen, + isLastScreen, + includeNoodles, + content?.video_container, + content.tiles?.type === "addons-picker" + ) + : ""; + const isEmbeddedMigration = content.tiles?.type === "migration-wizard"; + + return ( + <main + className={`screen ${this.props.id || ""} + ${screenClassName} ${textColorClass}`} + role={ariaRole ?? "alertdialog"} + layout={content.layout} + pos={content.position || "center"} + tabIndex="-1" + aria-labelledby="mainContentHeader" + ref={input => { + this.mainContentHeader = input; + }} + > + {isCenterPosition ? null : this.renderSecondarySection(content)} + <div + className={`section-main ${ + isEmbeddedMigration ? "embedded-migration" : "" + }`} + hide-secondary-section={ + content.hide_secondary_section + ? String(content.hide_secondary_section) + : null + } + role="document" + > + {content.secondary_button_top ? ( + <SecondaryCTA + content={content} + handleAction={this.props.handleAction} + position="top" + /> + ) : null} + {includeNoodles ? this.renderNoodles() : null} + {content.dismiss_button ? this.renderDismissButton() : null} + <div + className={`main-content ${hideStepsIndicator ? "no-steps" : ""}`} + style={{ + background: + content.background && isCenterPosition + ? content.background + : null, + width: + content.width && content.position !== "split" + ? content.width + : null, + }} + > + {content.logo ? this.renderPicture(content.logo) : null} + + {isRtamo ? ( + <div className="rtamo-icon"> + <img + className={`${isTheme ? "rtamo-theme-icon" : "brand-logo"}`} + src={this.props.iconURL} + loading={AboutWelcomeUtils.getLoadingStrategyFor( + this.props.iconURL + )} + alt="" + role="presentation" + /> + </div> + ) : null} + + <div className="main-content-inner"> + <div className={`welcome-text ${content.title_style || ""}`}> + {content.title ? this.renderTitle(content) : null} + + {content.subtitle ? ( + <Localized text={content.subtitle}> + <h2 + data-l10n-args={JSON.stringify({ + "addon-name": this.props.addonName, + ...this.props.appAndSystemLocaleInfo?.displayNames, + })} + aria-flowto={ + this.props.messageId?.includes("FEATURE_TOUR") + ? "steps" + : "" + } + /> + </Localized> + ) : null} + {content.cta_paragraph ? ( + <CTAParagraph + content={content.cta_paragraph} + handleAction={this.props.handleAction} + /> + ) : null} + </div> + {content.video_container ? ( + <OnboardingVideo + content={content.video_container} + handleAction={this.props.handleAction} + /> + ) : null} + {content.above_button_content + ? this.renderOrderedContent(content.above_button_content) + : null} + {this.renderContentTiles()} + {this.renderLanguageSwitcher()} + {!hideStepsIndicator && aboveButtonStepsIndicator + ? this.renderStepsIndicator() + : null} + <ProtonScreenActionButtons + content={content} + addonName={this.props.addonName} + handleAction={this.props.handleAction} + activeMultiSelect={this.props.activeMultiSelect} + /> + </div> + {!hideStepsIndicator && !aboveButtonStepsIndicator + ? this.renderStepsIndicator() + : null} + </div> + </div> + <Localized text={content.info_text}> + <span className="info-text" /> + </Localized> + </main> + ); + } +} diff --git a/browser/components/aboutwelcome/content-src/components/OnboardingVideo.jsx b/browser/components/aboutwelcome/content-src/components/OnboardingVideo.jsx new file mode 100644 index 0000000000..629a409a59 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/OnboardingVideo.jsx @@ -0,0 +1,34 @@ +/* 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"; + +export const OnboardingVideo = props => { + const vidUrl = props.content.video_url; + const autoplay = props.content.autoPlay; + + const handleVideoAction = event => { + props.handleAction({ + currentTarget: { + value: event, + }, + }); + }; + + return ( + <div> + <video // eslint-disable-line jsx-a11y/media-has-caption + controls={true} + autoPlay={autoplay} + src={vidUrl} + width="604px" + height="340px" + onPlay={() => handleVideoAction("video_start")} + onEnded={() => handleVideoAction("video_end")} + > + <source src={vidUrl}></source> + </video> + </div> + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/ReturnToAMO.jsx b/browser/components/aboutwelcome/content-src/components/ReturnToAMO.jsx new file mode 100644 index 0000000000..e262e3d92a --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/ReturnToAMO.jsx @@ -0,0 +1,105 @@ +/* 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 { + AboutWelcomeUtils, + DEFAULT_RTAMO_CONTENT, +} from "../lib/aboutwelcome-utils.mjs"; +import { MultiStageProtonScreen } from "./MultiStageProtonScreen"; +import { BASE_PARAMS } from "../lib/addUtmParams.mjs"; + +export class ReturnToAMO extends React.PureComponent { + constructor(props) { + super(props); + this.fetchFlowParams = this.fetchFlowParams.bind(this); + this.handleAction = this.handleAction.bind(this); + } + + async fetchFlowParams() { + if (this.props.metricsFlowUri) { + this.setState({ + flowParams: await AboutWelcomeUtils.fetchFlowParams( + this.props.metricsFlowUri + ), + }); + } + } + + componentDidUpdate() { + this.fetchFlowParams(); + } + + handleAction(event) { + const { content, message_id, url, utm_term } = this.props; + let { action, source_id } = content[event.currentTarget.value]; + let { type, data } = action; + + if (type === "INSTALL_ADDON_FROM_URL") { + if (!data) { + return; + } + // Set add-on url in action.data.url property from JSON + data = { ...data, url }; + } else if (type === "SHOW_FIREFOX_ACCOUNTS") { + let params = { + ...BASE_PARAMS, + utm_term: `aboutwelcome-${utm_term}-screen`, + }; + if (action.addFlowParams && this.state.flowParams) { + params = { + ...params, + ...this.state.flowParams, + }; + } + data = { ...data, extraParams: params }; + } + + AboutWelcomeUtils.handleUserAction({ type, data }); + AboutWelcomeUtils.sendActionTelemetry(message_id, source_id); + } + + render() { + const { content, type } = this.props; + + if (!content) { + return null; + } + + if (content?.primary_button.label) { + content.primary_button.label.string_id = type.includes("theme") + ? "return-to-amo-add-theme-label" + : "mr1-return-to-amo-add-extension-label"; + } + + // For experiments, when needed below rendered UI allows settings hard coded strings + // directly inside JSON except for ReturnToAMOText which picks add-on name and icon from fluent string + return ( + <div + className={"outer-wrapper onboardingContainer proton"} + style={content.backdrop ? { background: content.backdrop } : {}} + > + <MultiStageProtonScreen + content={content} + isRtamo={true} + isTheme={type.includes("theme")} + id={this.props.message_id} + order={this.props.order || 0} + totalNumberOfScreens={1} + isSingleScreen={true} + autoAdvance={this.props.auto_advance} + iconURL={ + type.includes("theme") + ? this.props.themeScreenshots[0]?.url + : this.props.iconURL + } + addonName={this.props.name} + handleAction={this.handleAction} + /> + </div> + ); + } +} + +ReturnToAMO.defaultProps = DEFAULT_RTAMO_CONTENT; diff --git a/browser/components/aboutwelcome/content-src/components/SubmenuButton.jsx b/browser/components/aboutwelcome/content-src/components/SubmenuButton.jsx new file mode 100644 index 0000000000..e0c8144e73 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/SubmenuButton.jsx @@ -0,0 +1,149 @@ +/* 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, { useEffect, useRef, useCallback } from "react"; +import { Localized } from "./MSLocalized"; + +export const SubmenuButton = props => { + return document.createXULElement ? <SubmenuButtonInner {...props} /> : null; +}; + +function translateMenuitem(item, element) { + let { label } = item; + if (!label) { + return; + } + if (label.raw) { + element.setAttribute("label", label.raw); + } + if (label.access_key) { + element.setAttribute("accesskey", label.access_key); + } + if (label.aria_label) { + element.setAttribute("aria-label", label.aria_label); + } + if (label.tooltip_text) { + element.setAttribute("tooltiptext", label.tooltip_text); + } + if (label.string_id) { + element.setAttribute("data-l10n-id", label.string_id); + if (label.args) { + element.setAttribute("data-l10n-args", JSON.stringify(label.args)); + } + } +} + +function addMenuitems(items, popup) { + for (let item of items) { + switch (item.type) { + case "separator": + popup.appendChild(document.createXULElement("menuseparator")); + break; + case "menu": + let menu = document.createXULElement("menu"); + menu.className = "fxms-multi-stage-menu"; + translateMenuitem(item, menu); + if (item.id) { + menu.value = item.id; + } + if (item.icon) { + menu.classList.add("menu-iconic"); + menu.setAttribute("image", item.icon); + } + popup.appendChild(menu); + let submenuPopup = document.createXULElement("menupopup"); + menu.appendChild(submenuPopup); + addMenuitems(item.submenu, submenuPopup); + break; + case "action": + let menuitem = document.createXULElement("menuitem"); + translateMenuitem(item, menuitem); + menuitem.config = item; + if (item.id) { + menuitem.value = item.id; + } + if (item.icon) { + menuitem.classList.add("menuitem-iconic"); + menuitem.setAttribute("image", item.icon); + } + popup.appendChild(menuitem); + break; + } + } +} + +const SubmenuButtonInner = ({ content, handleAction }) => { + const ref = useRef(null); + const isPrimary = content.submenu_button?.style === "primary"; + const onCommand = useCallback( + event => { + let { config } = event.target; + let mockEvent = { + currentTarget: ref.current, + source: config.id, + name: "command", + action: config.action, + }; + handleAction(mockEvent); + }, + [handleAction] + ); + const onClick = useCallback(() => { + let button = ref.current; + let submenu = button?.querySelector(".fxms-multi-stage-submenu"); + if (submenu && !button.hasAttribute("open")) { + submenu.openPopup(button, { position: "after_end" }); + } + }, []); + useEffect(() => { + let button = ref.current; + if (!button || button.querySelector(".fxms-multi-stage-submenu")) { + return null; + } + let menupopup = document.createXULElement("menupopup"); + menupopup.className = "fxms-multi-stage-submenu"; + addMenuitems(content.submenu_button.submenu, menupopup); + button.appendChild(menupopup); + let stylesheet; + if ( + !document.head.querySelector( + `link[href="chrome://global/content/widgets.css"], link[href="chrome://global/skin/global.css"]` + ) + ) { + stylesheet = document.createElement("link"); + stylesheet.rel = "stylesheet"; + stylesheet.href = "chrome://global/content/widgets.css"; + document.head.appendChild(stylesheet); + } + if (!menupopup.listenersRegistered) { + menupopup.addEventListener("command", onCommand); + menupopup.addEventListener("popupshowing", event => { + if (event.target === menupopup && event.target.anchorNode) { + event.target.anchorNode.toggleAttribute("open", true); + } + }); + menupopup.addEventListener("popuphiding", event => { + if (event.target === menupopup && event.target.anchorNode) { + event.target.anchorNode.toggleAttribute("open", false); + } + }); + menupopup.listenersRegistered = true; + } + return () => { + menupopup?.remove(); + stylesheet?.remove(); + }; + }, [onCommand]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + <Localized text={content.submenu_button.label ?? {}}> + <button + className={`submenu-button ${isPrimary ? "primary" : "secondary"}`} + value="submenu_button" + onClick={onClick} + ref={ref} + /> + </Localized> + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/Themes.jsx b/browser/components/aboutwelcome/content-src/components/Themes.jsx new file mode 100644 index 0000000000..0ee986f982 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/Themes.jsx @@ -0,0 +1,52 @@ +/* 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 { Localized } from "./MSLocalized"; + +export const Themes = props => { + return ( + <div className="tiles-theme-container"> + <div> + <fieldset className="tiles-theme-section"> + <Localized text={props.content.subtitle}> + <legend className="sr-only" /> + </Localized> + {props.content.tiles.data.map( + ({ theme, label, tooltip, description }) => ( + <Localized + key={theme + label} + text={typeof tooltip === "object" ? tooltip : {}} + > + {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + <label className="theme" title={theme + label}> + <Localized + text={typeof description === "object" ? description : {}} + > + <input + type="radio" + value={theme} + name="theme" + checked={theme === props.activeTheme} + className="sr-only input" + onClick={props.handleAction} + /> + </Localized> + <div + className={`icon ${ + theme === props.activeTheme ? " selected" : "" + } ${theme}`} + /> + <Localized text={label}> + <div className="text" /> + </Localized> + </label> + </Localized> + ) + )} + </fieldset> + </div> + </div> + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/Zap.jsx b/browser/components/aboutwelcome/content-src/components/Zap.jsx new file mode 100644 index 0000000000..a067c4d7fe --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/Zap.jsx @@ -0,0 +1,60 @@ +/* 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, { useEffect } from "react"; +import { Localized } from "./MSLocalized"; +const MS_STRING_PROP = "string_id"; +const ZAP_SIZE_THRESHOLD = 160; + +function calculateZapLength() { + let span = document.querySelector(".zap"); + if (!span) { + return; + } + let rect = span.getBoundingClientRect(); + if (rect && rect.width > ZAP_SIZE_THRESHOLD) { + span.classList.add("long"); + } else { + span.classList.add("short"); + } +} + +export const Zap = props => { + useEffect(() => { + requestAnimationFrame(() => calculateZapLength()); + }); + + if (!props.text) { + return null; + } + + if (props.hasZap) { + if (typeof props.text === "object" && props.text[MS_STRING_PROP]) { + return ( + <Localized text={props.text}> + <h1 className="welcomeZap"> + <span data-l10n-name="zap" className="zap" /> + </h1> + </Localized> + ); + } else if (typeof props.text === "string") { + // Parse string to zap style last word of the props.text + let titleArray = props.text.split(" "); + let lastWord = `${titleArray.pop()}`; + return ( + <h1 className="welcomeZap"> + {titleArray.join(" ").concat(" ")} + <span className="zap">{lastWord}</span> + </h1> + ); + } + } else { + return ( + <Localized text={props.text}> + <h1 /> + </Localized> + ); + } + return null; +}; |