diff options
Diffstat (limited to '')
18 files changed, 3915 insertions, 0 deletions
diff --git a/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.jsx b/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.jsx new file mode 100644 index 0000000000..18ef140618 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.jsx @@ -0,0 +1,140 @@ +/* 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 ReactDOM from "react-dom"; +import { AboutWelcomeUtils } from "../lib/aboutwelcome-utils"; +import { MultiStageAboutWelcome } from "./components/MultiStageAboutWelcome"; +import { ReturnToAMO } from "./components/ReturnToAMO"; + +class AboutWelcome extends React.PureComponent { + constructor(props) { + super(props); + this.state = { metricsFlowUri: null }; + this.fetchFxAFlowUri = this.fetchFxAFlowUri.bind(this); + } + + async fetchFxAFlowUri() { + this.setState({ metricsFlowUri: await window.AWGetFxAMetricsFlowURI?.() }); + } + + componentDidMount() { + if (!this.props.skipFxA) { + this.fetchFxAFlowUri(); + } + // Record impression with performance data after allowing the page to load + const recordImpression = domState => { + const { domComplete, domInteractive } = performance + .getEntriesByType("navigation") + .pop(); + AboutWelcomeUtils.sendImpressionTelemetry(this.props.messageId, { + domComplete, + domInteractive, + mountStart: performance.getEntriesByName("mount").pop().startTime, + domState, + source: this.props.UTMTerm, + }); + }; + if (document.readyState === "complete") { + // Page might have already triggered a load event because it waited for async data, + // e.g., attribution, so the dom load timing could be of a empty content + // with domState in telemetry captured as 'complete' + recordImpression(document.readyState); + } else { + window.addEventListener("load", () => recordImpression("load"), { + once: true, + }); + } + + // Captures user has seen about:welcome by setting + // firstrun.didSeeAboutWelcome pref to true and capturing welcome UI unique messageId + window.AWSendToParent("SET_WELCOME_MESSAGE_SEEN", this.props.messageId); + } + + render() { + const { props } = this; + if (props.template === "return_to_amo") { + return ( + <ReturnToAMO + message_id={props.messageId} + type={props.type} + name={props.name} + url={props.url} + iconURL={props.iconURL} + themeScreenshots={props.screenshots} + metricsFlowUri={this.state.metricsFlowUri} + /> + ); + } + return ( + <MultiStageAboutWelcome + message_id={props.messageId} + defaultScreens={props.screens} + updateHistory={!props.disableHistoryUpdates} + metricsFlowUri={this.state.metricsFlowUri} + utm_term={props.UTMTerm} + transitions={props.transitions} + backdrop={props.backdrop} + startScreen={props.startScreen || 0} + appAndSystemLocaleInfo={props.appAndSystemLocaleInfo} + /> + ); + } +} + +// Computes messageId and UTMTerm info used in telemetry +function ComputeTelemetryInfo(welcomeContent, experimentId, branchId) { + let messageId = + welcomeContent.template === "return_to_amo" + ? `RTAMO_DEFAULT_WELCOME_${welcomeContent.type.toUpperCase()}` + : "DEFAULT_ID"; + let UTMTerm = "aboutwelcome-default"; + + if (welcomeContent.id) { + messageId = welcomeContent.id.toUpperCase(); + } + + if (experimentId && branchId) { + UTMTerm = `aboutwelcome-${experimentId}-${branchId}`.toLowerCase(); + } + return { + messageId, + UTMTerm, + }; +} + +async function retrieveRenderContent() { + // Feature config includes RTAMO attribution data if exists + // else below data in order specified + // user prefs + // experiment data + // defaults + let featureConfig = await window.AWGetFeatureConfig(); + + let { messageId, UTMTerm } = ComputeTelemetryInfo( + featureConfig, + featureConfig.slug, + featureConfig.branch && featureConfig.branch.slug + ); + return { featureConfig, messageId, UTMTerm }; +} + +async function mount() { + let { + featureConfig: aboutWelcomeProps, + messageId, + UTMTerm, + } = await retrieveRenderContent(); + ReactDOM.render( + <AboutWelcome + messageId={messageId} + UTMTerm={UTMTerm} + {...aboutWelcomeProps} + />, + document.getElementById("multi-stage-message-root") + ); +} + +performance.mark("mount"); +mount(); diff --git a/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.scss b/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.scss new file mode 100644 index 0000000000..f4f756dfd0 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.scss @@ -0,0 +1,1676 @@ +/* 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/. */ +@use 'sass:math'; +@import '../styles/feature-callout'; + +$break-point-small: 570px; +$break-point-medium: 610px; +$break-point-large: 866px; +$container-min-width: 700px; +$logo-size: 80px; +$main-section-width: 504px; +$split-section-width: 400px; +$split-screen-height: 550px; +$small-main-section-height: 450px; +$small-secondary-section-height: 100px; +$noodle-buffer: 106px; +$video-section-width: 800px; + +html { + height: 100%; +} + +// Below variables are used via config JSON in AboutWelcomeDefaults +// and referenced below inside dummy class to pass test browser_parsable_css +.dummy { + background: var(--mr-welcome-background-color) var(--mr-welcome-background-gradient) var(--mr-secondary-position) var(--mr-screen-background-color); +} + +// Styling for content rendered in a Spotlight messaging surface. +:root { + &[dialogroot] { + background-color: transparent; + + body { + padding: 0; + } + + .onboardingContainer { + // Without this, the container will be 100vh in height. When the dialog + // overflows horizontally, the horizontal scrollbar will appear. If the + // scrollbars aren't overlay scrollbars (this is controlled by + // Theme::ScrollbarStyle), they will take up vertical space in the + // viewport, causing the dialog to overflow vertically. This causes the + // vertical scrollbar to appear, which takes up horizontal space, causing + // the horizontal scrollbar to appear, and so on. + height: 100%; + background-color: transparent; + + &:dir(rtl) { + transform: unset; + } + + .logo-container { + pointer-events: none; + } + + .screen { + &:dir(rtl) { + transform: unset; + } + } + } + } +} + +// Styling for about:welcome background container +.welcome-container { + .onboardingContainer { + min-height: $break-point-medium; + min-width: fit-content; + } +} + +.onboardingContainer { + --grey-subtitle-1: #696977; + --mr-welcome-background-color: #F8F6F4; + --mr-screen-heading-color: var(--in-content-text-color); + --mr-welcome-background-gradient: linear-gradient(0deg, rgba(144, 89, 255, 20%) 0%, rgba(2, 144, 238, 20%) 100%); + --mr-screen-background-color: #F8F6F4; + + @media (prefers-color-scheme: dark) { + --grey-subtitle-1: #FFF; + --mr-welcome-background-color: #333336; + --mr-welcome-background-gradient: linear-gradient(0deg, rgba(144, 89, 255, 30%) 0%, rgba(2, 144, 238, 30%) 100%); + --mr-screen-background-color: #62697A; + } + + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Ubuntu, + 'Helvetica Neue', sans-serif; + font-size: 16px; + position: relative; + text-align: center; + height: 100vh; + + @media (prefers-contrast) { + --mr-screen-background-color: buttontext; + --mr-screen-heading-color: buttonface; + + background-color: var(--in-content-page-background); + } + + // Transition all of these and reduced motion effectively only does opacity. + --transition: 0.6s opacity, 0.6s scale, 0.6s rotate, 0.6s translate; + + // Define some variables that are used for in/out states. + @media (prefers-reduced-motion: no-preference) { + --translate: 30px; + --rotate: 20deg; + --scale: 0.4; + --progress-bar-transition: 0.6s translate; + + // Scale is used for noodles that can be flipped. + &:dir(rtl) { + --scale: -0.4 0.4; + } + } + + // Use default values that match "unmoved" state to avoid motion. + @media (prefers-reduced-motion: reduce) { + --translate: 0; + --rotate: 0deg; + --scale: 1; + // To reduce motion, progress bar fades in instead of wiping in. + --progress-bar-transition: none; + + &:dir(rtl) { + --scale: -1 1; + } + } + + &:dir(rtl) { + transform: rotateY(180deg); + } + + .section-main { + display: flex; + flex-direction: column; + justify-content: center; + width: $main-section-width; + flex-shrink: 0; + } + + .section-main:not(.embedded-migration) { + position: relative; + } + + .main-content { + background-color: var(--in-content-page-background); + border-radius: 20px; + box-shadow: 0 2px 14px 0 rgba(0, 0, 0, 20%); + display: flex; + flex-direction: column; + height: 100%; + padding: 0; + transition: var(--transition); + z-index: 1; + box-sizing: border-box; + + &.no-steps { + padding-bottom: 48px; + } + + .main-content-inner { + display: flex; + flex-direction: column; + flex-grow: 1; + justify-content: space-around; + } + } + + // Handle conditional display of steps indicator + // Don't show when there's only one screen + .main-content .no-steps { + .main-content { + padding-bottom: 48px; + } + + .steps { + display: none; + } + } + + @mixin arrow-icon-styles { + .arrow-icon { + -moz-context-properties: fill; + fill: currentColor; + text-decoration: none; + + &::after { + content: ''; + padding-inline-end: 12px; + margin-inline-start: 4px; + background: url('chrome://browser/skin/forward.svg') no-repeat center / 12px; + } + + &:dir(rtl)::after { + background-image: url('chrome://browser/skin/back.svg'); + } + } + } + + @mixin secondary-cta-styles { + background-color: var(--in-content-button-background) !important; // stylelint-disable-line declaration-no-important + border: 1px solid var(--in-content-button-border-color); + line-height: 12px; + font-size: 0.72em; + font-weight: 600; + padding: 8px 16px; + text-decoration: none; + + &:hover { + background-color: var(--in-content-button-background-hover) !important; // stylelint-disable-line declaration-no-important + color: var(--in-content-button-text-color-hover); + } + } + + @mixin text-link-styles { + text-decoration: underline; + cursor: pointer; + color: var(--in-content-link-color); + + &:hover { + text-decoration: none; + color: var(--in-content-link-color-hover); + } + + &:active { + text-decoration: none; + color: var(--in-content-link-color-active); + } + } + + .screen { + display: flex; + position: relative; + flex-flow: row nowrap; + height: 100%; + min-height: 500px; + overflow: hidden; + + --in-content-link-color: var(--in-content-primary-button-background); + --in-content-link-color-hover: var(--in-content-primary-button-background-hover); + --in-content-link-color-active: var(--in-content-primary-button-background-active); + --in-content-link-color-visited: var(--in-content-link-color); + + &.light-text { + --in-content-page-color: rgb(251, 251, 254); + --in-content-primary-button-text-color: rgb(43, 42, 51); + --in-content-primary-button-text-color-hover: rgb(43, 42, 51); + --in-content-primary-button-background: rgb(0, 221, 255); + --in-content-primary-button-background-hover: rgb(128, 235, 255); + --in-content-primary-button-background-active: rgb(170, 242, 255); + --checkbox-checked-bgcolor: var(--in-content-primary-button-background); + --in-content-button-text-color: var(--in-content-page-color); + } + + &.dark-text { + --in-content-page-color: rgb(21, 20, 26); + --in-content-primary-button-text-color: rgb(251, 251, 254); + --in-content-primary-button-text-color-hover: rgb(251, 251, 254); + --in-content-primary-button-background: #0061E0; + --in-content-primary-button-background-hover: #0250BB; + --in-content-primary-button-background-active: #053E94; + --in-content-primary-button-border-color: transparent; + --in-content-primary-button-border-hover: transparent; + --checkbox-checked-bgcolor: var(--in-content-primary-button-background); + --in-content-button-text-color: var(--in-content-page-color); + } + + &:dir(rtl) { + transform: rotateY(180deg); + } + + &[pos='center'] { + background-color: rgba(21, 20, 26, 50%); + min-width: $main-section-width; + + &.with-noodles { + // Adjust for noodles partially extending out from the square modal + min-width: $main-section-width + $noodle-buffer; + min-height: $main-section-width + $noodle-buffer; + + .section-main { + height: $main-section-width; + } + } + + &.with-video { + justify-content: center; + background: none; + align-items: center; + + .section-main { + width: $video-section-width; + height: $split-screen-height; + } + + .main-content { + background-color: var(--mr-welcome-background-color); + border-radius: 8px; + box-shadow: 0 2px 14px rgba(58, 57, 68, 20%); + padding: 44px 85px 20px; + + .welcome-text { + margin: 0; + } + + .main-content-inner { + justify-content: space-between; + } + + h1, + h2 { + align-self: start; + } + + h1 { + font-size: 24px; + line-height: 28.8px; + } + + h2 { + font-size: 15px; + line-height: 22px; + } + + .secondary-cta { + @include arrow-icon-styles; + + justify-content: end; + + .secondary { + @include secondary-cta-styles; + + color: var(--in-content-button-text-color); + } + } + } + } + } + + &:not([pos='split']) { + .secondary-cta { + .secondary { + background: none; + color: var(--in-content-link-color); + font-size: 14px; + font-weight: normal; + line-height: 20px; + } + + &.top { + button { + color: #FFF; + + &:hover { + color: #E0E0E6; + } + } + } + } + } + + &[pos='split'] { + margin: auto; + min-height: $split-screen-height; + + &::before { + content: ''; + position: absolute; + box-shadow: 0 2px 14px 0 rgba(0, 0, 0, 20%); + width: $split-section-width + $split-section-width; + height: $split-screen-height; + border-radius: 8px; + inset: 0; + margin: auto; + pointer-events: none; + } + + .section-secondary, + .section-main { + width: $split-section-width; + height: $split-screen-height; + } + + .secondary-cta.top { + position: fixed; + padding-inline-end: 0; + + button { + color: var(--in-content-page-color); + } + } + + .section-main { + flex-direction: row; + display: block; + margin: auto auto auto 0; + + &:dir(rtl) { + margin: auto 0 auto auto; + } + + .main-content { + border-radius: 0 8px 8px 0; + overflow: hidden; + padding-inline: 35px 20px; + padding-block: 120px 0; + box-shadow: none; + + &.no-steps { + padding-bottom: 48px; + } + + &:dir(rtl) { + border-radius: 8px 0 0 8px; + } + + .main-content-inner { + min-height: 330px; + + .language-switcher-container { + .primary { + margin-bottom: 5px; + } + } + } + + .action-buttons { + position: relative; + text-align: initial; + height: 100%; + + .checkbox-container { + font-size: 13px; + margin-block: 1em; + + &:not(.multi-select-item) { + transition: var(--transition); + } + + input, + label { + vertical-align: middle; + } + } + + .additional-cta { + margin: 8px 0; + + &.cta-link { + background: none; + padding: 0; + font-weight: normal; + + @include text-link-styles; + } + + &.secondary { + &:hover { + background-color: var(--in-content-button-background-hover); + } + } + } + + &.additional-cta-container { + flex-wrap: nowrap; + align-items: start; + } + + .secondary-cta { + position: absolute; + bottom: -30px; + inset-inline-end: 0; + + .secondary { + @include secondary-cta-styles; + } + + @include arrow-icon-styles; + } + } + + .logo-container { + text-align: start; + } + + .brand-logo { + height: 25px; + margin-block: 0; + } + + .welcome-text { + margin-inline: 0 10px; + margin-block: 10px 35px; + text-align: initial; + align-items: initial; + + &:empty { + margin: 0; + } + + h1 { + font-size: 24px; + line-height: 1.2; + width: 300px; + } + + h2 { + margin: 10px 0 0; + min-height: 1em; + font-size: 15px; + line-height: 1.5; + + @media (prefers-contrast: no-preference) { + color: #5B5B66; + } + } + } + + .welcome-text h1, + .primary { + margin: 0; + } + + .steps { + z-index: 1; + + &.progress-bar { + width: $split-section-width; + margin-inline: -35px; + } + } + + @media (prefers-contrast) { + border: 1px solid var(--in-content-page-color); + + .steps.progress-bar { + border-top: 1px solid var(--in-content-page-color); + background-color: var(--in-content-page-background); + + .indicator { + background-color: var(--in-content-accent-color); + } + } + } + } + } + + .section-secondary { + --mr-secondary-position: center center / auto 350px; + + border-radius: 8px 0 0 8px; + margin: auto 0 auto auto; + display: flex; + align-items: center; + -moz-context-properties: fill, stroke, fill-opacity, stroke-opacity; + stroke: currentColor; + + &:dir(rtl) { + border-radius: 0 8px 8px 0; + margin: auto auto auto 0; + } + + h1 { + color: var(--mr-screen-heading-color); + font-weight: 700; + font-size: 47px; + line-height: 110%; + max-width: 340px; + text-align: initial; + white-space: pre-wrap; + text-shadow: none; + margin-inline: 40px 0; + } + + .image-alt { + width: inherit; + height: inherit; + } + + .hero-image { + flex: 1; + display: flex; + justify-content: center; + max-height: 100%; + + img { + width: 100%; + max-width: 180px; + margin: 25px 0; + padding-bottom: 30px; + + @media only screen and (max-width: 800px) { + padding-bottom: unset; + } + } + } + } + + .tiles-theme-container { + margin-block: -20px auto; + align-items: initial; + + .colorway-text { + text-align: initial; + transition: var(--transition); + font-size: 13px; + line-height: 1.5; + min-height: 4.5em; + margin-block: 10px 20px; + } + + .theme { + min-width: 38px; + } + } + + @media (prefers-contrast: no-preference) and (prefers-color-scheme: dark) { + .section-main .main-content { + .welcome-text h2 { + color: #CFCFD8; + } + + .action-buttons .secondary { + background-color: #2B2A33; + } + } + } + + @media only screen and (min-width: 800px) { + .tiles-theme-section { + margin-inline-start: -10px; + } + } + + @media only screen and (max-width: 800px) { + flex-direction: column; + min-height: $small-main-section-height + $small-secondary-section-height; + + &::before { + width: $split-section-width; + } + + .section-secondary, + .section-main { + width: $split-section-width; + } + + .section-secondary { + --mr-secondary-background-position-y: top; + --mr-secondary-position: center var(--mr-secondary-background-position-y) / 75%; + + border-radius: 8px 8px 0 0; + margin: auto auto 0; + height: $small-secondary-section-height; + + .hero-image img { + margin: 6px 0; + } + + .message-text { + margin-inline: auto; + } + + h1 { + font-size: 35px; + text-align: center; + white-space: normal; + margin-inline: auto; + margin-block: 14px 6px; + } + + &:dir(rtl) { + margin: auto auto 0; + border-radius: 8px 8px 0 0; + } + } + + .section-main { + margin: 0 auto auto; + height: $small-main-section-height; + + .main-content { + border-radius: 0 0 8px 8px; + padding: 30px 0 0; + + .main-content-inner { + align-items: center; + } + + .logo-container { + text-align: center; + + .brand-logo { + min-height: 25px; + + &, + &:dir(rtl) { + background-position: center; + } + } + } + + .welcome-text { + align-items: center; + text-align: center; + margin-inline: 0; + padding-inline: 30px; + + .spacer-bottom, + .spacer-top { + display: none; + } + } + + .action-buttons { + text-align: center; + + .checkbox-container { + display: none; + } + + .secondary-cta { + position: relative; + margin-block: 10px 0; + bottom: 0; + } + } + + .primary, + .secondary { + min-width: 240px; + } + + .colorway-text { + text-align: center; + margin-inline: 30px; + } + + .steps { + padding-block: 0; + margin: 0; + + &.progress-bar { + margin-inline: 0; + } + } + } + + .additional-cta { + &.cta-link { + align-self: center; + } + } + + .dismiss-button { + top: -$small-secondary-section-height; + } + + &:dir(rtl) { + margin: 0 auto auto; + + .main-content { + border-radius: 0 0 8px 8px; + } + } + } + + } + + @media only screen and (max-height: 650px) { + // Hide the "Sign in" button on the welcome screen when it would + // otherwise overlap the screen. We'd reposition it, but then it would + // overlap the dismiss button. We may change the alignment so they don't + // overlap in a future revision. + @media (min-width: 800px) and (max-width: 990px) { + .section-main .secondary-cta.top { + display: none; + } + } + + // Reposition the "Sign in" button on the welcome screen to move inside + // the screen when it would otherwise overlap the screen. + @media (max-width: 590px) { + .section-main .secondary-cta.top { + position: absolute; + padding: 0; + top: 0; + inset-inline-end: 0; + } + } + } + } + } + + .brand-logo { + margin-block: 60px 10px; + transition: var(--transition); + height: 80px; + + &.cta-top { + margin-top: 25px; + } + + &.hide { + visibility: hidden; + padding: unset; + margin-top: 50px; + } + } + + .rtamo-theme-icon { + max-height: 30px; + border-radius: 2px; + margin-bottom: 10px; + margin-top: 24px; + } + + .rtamo-icon { + text-align: start; + + @media only screen and (max-width: 800px) { + text-align: center; + } + } + + .text-link { + @include text-link-styles; + } + + .welcome-text { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + margin: 0.5em 1em; + transition: var(--transition); + + h1, + h2 { + color: var(--in-content-page-color); + line-height: 1.5; + } + + h1 { + font-size: 24px; + font-weight: 600; + margin: 0 6px; + letter-spacing: -0.02em; + outline: none; + } + + h2 { + font-size: 16px; + font-weight: normal; + margin: 10px 6px 0; + max-width: 750px; + letter-spacing: -0.01em; + } + + &.fancy { + h1 { + background-image: linear-gradient(90deg, #9059FF, #FF4AA2, #FF8C00, #FF4AA2, #9059FF); + background-clip: text; + background-size: 200%; + + @media (prefers-contrast: no-preference) { + color: transparent; + } + + @media (prefers-color-scheme: dark) { + background-image: linear-gradient(90deg, #C688FF, #FF84C0, #FFBD4F, #FF84C0, #C688FF); + + &::selection { + color: #FFF; + background-color: #696977; + } + } + } + } + + &.shine { + h1 { + animation: shine 50s linear infinite; + background-size: 400%; + } + + @keyframes shine { + to { + background-position: 400%; + } + } + } + + .cta-paragraph { + a { + margin: 0; + text-decoration: underline; + cursor: pointer; + } + } + } + + // Override light and dark mode fancy title colors for use over light and dark backgrounds + .screen.light-text .welcome-text.fancy h1 { + background-image: linear-gradient(90deg, #C688FF, #FF84C0, #FFBD4F, #FF84C0, #C688FF); + } + + .screen.dark-text .welcome-text.fancy h1 { + background-image: linear-gradient(90deg, #9059FF, #FF4AA2, #FF8C00, #FF4AA2, #9059FF); + } + + .welcomeZap { + span { + position: relative; + z-index: 1; + white-space: nowrap; + } + + .zap { + &::after { + display: block; + background-repeat: no-repeat; + background-size: 100% 100%; + content: ''; + position: absolute; + top: calc(100% - 0.15em); + width: 100%; + height: 0.3em; + left: 0; + z-index: -1; + transform: scaleY(3); + } + + &.short::after { + background-image: url('chrome://activity-stream/content/data/content/assets/short-zap.svg'); + } + + &.long::after { + background-image: url('chrome://activity-stream/content/data/content/assets/long-zap.svg'); + } + } + } + + .language-loader { + filter: invert(1); + margin-inline-end: 10px; + position: relative; + top: 3px; + width: 16px; + height: 16px; + margin-top: -6px; + } + + @media (prefers-color-scheme: dark) { + .language-loader { + filter: invert(0); + } + } + + .tiles-theme-container { + display: flex; + flex-direction: column; + align-items: center; + margin: 10px auto; + } + + .sr-only { + opacity: 0; + overflow: hidden; + position: absolute; + + &.input { + height: 1px; + width: 1px; + } + } + + .tiles-theme-section { + border: 0; + display: flex; + flex-wrap: wrap; + gap: 5px; + justify-content: space-evenly; + margin-inline: 10px; + padding: 10px; + transition: var(--transition); + + &:hover, + &:active, + &:focus-within { + border-radius: 8px; + outline: 2px solid var(--in-content-primary-button-background); + } + + .theme { + align-items: center; + display: flex; + flex-direction: column; + flex: 1; + padding: 0; + min-width: 50px; + width: 180px; + color: #000; + box-shadow: none; + border-radius: 4px; + cursor: pointer; + z-index: 0; + + &.colorway { + width: auto; + } + + &:focus, + &:active { + outline: initial; + outline-offset: initial; + } + + .icon.colorway, + .label.colorway { + width: 20px; + height: 20px; + } + + .icon { + background-size: cover; + width: 40px; + height: 40px; + border-radius: 40px; + outline: 1px solid var(--in-content-border-color); + outline-offset: -0.5px; + z-index: -1; + + &:dir(rtl) { + transform: scaleX(-1); + } + + &:focus, + &:active, + &.selected { + outline: 2px solid var(--in-content-primary-button-background); + outline-offset: 2px; + } + + &.light { + background-image: url('resource://builtin-themes/light/icon.svg'); + } + + &.dark { + background-image: url('resource://builtin-themes/dark/icon.svg'); + } + + &.alpenglow { + background-image: url('resource://builtin-themes/alpenglow/icon.svg'); + } + + &.default, + &.automatic { + background-image: url('resource://default-theme/icon.svg'); + + &.colorway { + background-image: url('chrome://activity-stream/content/data/content/assets/default.svg'); + } + } + + &.playmaker { + background-image: url('resource://builtin-themes/colorways/2022playmaker/balanced/icon.svg'); + } + + &.expressionist { + background-image: url('resource://builtin-themes/colorways/2022expressionist/balanced/icon.svg'); + } + + &.visionary { + background-image: url('resource://builtin-themes/colorways/2022visionary/balanced/icon.svg'); + } + + &.dreamer { + background-image: url('resource://builtin-themes/colorways/2022dreamer/balanced/icon.svg'); + } + + &.innovator { + background-image: url('resource://builtin-themes/colorways/2022innovator/balanced/icon.svg'); + } + + &.activist { + background-image: url('resource://builtin-themes/colorways/2022activist/balanced/icon.svg'); + } + } + + .text { + display: flex; + color: var(--in-content-page-color); + font-size: 14px; + font-weight: normal; + line-height: 20px; + margin-inline-start: 0; + margin-top: 9px; + } + } + + legend { + cursor: default; + } + } + + .tiles-container { + margin: 10px auto; + + &.info { + padding: 6px 12px 12px; + + &:hover, + &:focus { + background-color: rgba(217, 217, 227, 30%); + border-radius: 4px; + } + } + } + + .tiles-delayed { + animation: fadein 0.4s; + } + + .multi-select-container { + display: flex; + flex-direction: column; + align-items: flex-start; + margin-block: -1em 1em; + color: #5B5B66; + font-weight: 400; + font-size: 14px; + text-align: initial; + transition: var(--transition); + z-index: 1; + + .checkbox-container { + display: flex; + margin-bottom: 16px; + } + } + + @media (prefers-color-scheme: dark) { + .multi-select-container { + color: #CFCFD8; + } + } + + @media only screen and (max-width: 800px) { + .multi-select-container { + padding: 0 30px; + } + } + + .mobile-downloads { + .qr-code-image { + margin: 24px 0 10px; + width: 113px; + height: 113px; + } + + .email-link { + font-size: 16px; + font-weight: 400; + background: none; + + @include text-link-styles; + + &:hover { + background: none; + } + } + + .ios button { + background-image: url('chrome://app-marketplace-icons/locale/ios.svg'); + } + + .android button { + background-image: url('chrome://app-marketplace-icons/locale/android.png'); + } + } + + .mobile-download-buttons { + list-style: none; + padding: 10px 0; + margin: 0; + + li { + display: inline-block; + + button { + display: inline-block; + height: 45px; + width: 152px; + background-repeat: no-repeat; + background-size: contain; + background-position: center; + box-shadow: none; + border: 0; + } + + &:not(:first-child) { + margin-inline: 5px 0; + } + } + } + + .dismiss-button { + position: absolute; + z-index: 1; + top: 0; + left: auto; + right: 0; + box-sizing: border-box; + padding: 0; + margin: 16px; + display: block; + float: inline-end; + background: url('chrome://global/skin/icons/close.svg') no-repeat center / 16px; + height: 32px; + width: 32px; + align-self: end; + // override default min-height and min-width for buttons + min-height: 32px; + min-width: 32px; + -moz-context-properties: fill; + fill: currentColor; + transition: var(--transition); + + &:dir(rtl) { + left: 0; + right: auto; + } + } + + @keyframes fadein { + from { opacity: 0; } + } + + .secondary-cta { + display: flex; + align-items: end; + flex-direction: row; + justify-content: center; + font-size: 14px; + transition: var(--transition); + + &.top { + justify-content: end; + padding-inline-end: min(150px, 500px - 70vh); + padding-top: 4px; + position: absolute; + top: 10px; + inset-inline-end: 20px; + z-index: 2; + } + + span { + color: var(--grey-subtitle-1); + margin: 0 4px; + } + } + + .message-text, + .attrib-text { + transition: var(--transition); + } + + .helptext { + padding: 1em; + text-align: center; + color: var(--grey-subtitle-1); + font-size: 12px; + line-height: 18px; + + &.default { + align-self: center; + max-width: 40%; + } + + span { + padding-inline-end: 4px; + } + } + + .helptext-img { + height: 1.5em; + width: 1.5em; + margin-inline-end: 4px; + vertical-align: middle; + + &.end { + margin: 4px; + } + + &.footer { + vertical-align: bottom; + } + } + + .steps { + display: flex; + flex-direction: row; + justify-content: center; + margin-top: 0; + padding-block: 16px 0; + transition: var(--transition); + z-index: -1; + height: 48px; + box-sizing: border-box; + + &.has-helptext { + padding-bottom: 0; + } + + .indicator { + width: 0; + height: 0; + margin-inline-end: 4px; + margin-inline-start: 4px; + background: var(--grey-subtitle-1); + border-radius: 5px; + // using border will show up in Windows High Contrast Mode to improve accessibility. + border: 3px solid var(--in-content-button-text-color); + opacity: 0.35; + box-sizing: inherit; + + &.current { + opacity: 1; + border-color: var(--checkbox-checked-bgcolor); + + // This is the only step shown, so visually hide it to maintain spacing. + &:last-of-type:first-of-type { + opacity: 0; + } + } + } + + &.progress-bar { + height: 6px; + padding-block: 0; + margin-block: 42px 0; + background-color: color-mix(in srgb, var(--in-content-button-text-color) 25%, transparent); + justify-content: start; + opacity: 1; + transition: none; + + .indicator { + width: 100%; + height: 100%; + margin-inline: -1px; + background-color: var(--checkbox-checked-bgcolor); + border: 0; + border-radius: 0; + opacity: 1; + transition: var(--progress-bar-transition); + translate: calc(var(--progress-bar-progress, 0%) - 100%); + + &:dir(rtl) { + translate: calc(var(--progress-bar-progress, 0%) * -1 + 100%); + } + } + } + } + + .additional-cta-container { + &[flow] { + display: flex; + flex-flow: column wrap; + align-items: center; + + &[flow='row'] { + flex-direction: row; + justify-content: center; + + .secondary-cta { + flex-basis: 100%; + } + } + } + } + + .primary, + .secondary, + .additional-cta { + font-size: 13px; + line-height: 16px; + padding: 11px 15px; + transition: var(--transition); + + &.rtamo { + margin-top: 24px; + } + } + + .secondary { + background-color: var(--in-content-button-background); + color: var(--in-content-button-text-color); + } + + // Styles specific to background noodles, with screen-by-screen positions + .noodle { + display: block; + background-repeat: no-repeat; + background-size: 100% 100%; + position: absolute; + transition: var(--transition); + + // Flip noodles in a way that combines individual transforms. + &:dir(rtl) { + scale: -1 1; + } + } + + .outline-L { + background-image: url('chrome://activity-stream/content/data/content/assets/noodle-outline-L.svg'); + } + + .solid-L { + background-image: url('chrome://activity-stream/content/data/content/assets/noodle-solid-L.svg'); + -moz-context-properties: fill; + fill: var(--in-content-page-background); + display: none; + } + + .purple-C { + background-image: url('chrome://activity-stream/content/data/content/assets/noodle-C.svg'); + -moz-context-properties: fill; + fill: #E7258C; + } + + .orange-L { + background-image: url('chrome://activity-stream/content/data/content/assets/noodle-solid-L.svg'); + -moz-context-properties: fill; + fill: #FFA437; + } + + .screen-1 { + .section-main { + z-index: 1; + margin: auto; + } + + // Position of noodles on second screen + .outline-L { + width: 87px; + height: 80px; + transform: rotate(10deg) translate(-30%, 200%); + transition-delay: 0.4s; + z-index: 2; + } + + .orange-L { + width: 550px; + height: 660px; + transform: rotate(-155deg) translate(11%, -18%); + transition-delay: 0.2s; + } + + .purple-C { + width: 310px; + height: 260px; + transform: translate(-18%, -67%); + } + + .yellow-circle { + width: 165px; + height: 165px; + border-radius: 50%; + transform: translate(230%, -5%); + background: #952BB9; + transition-delay: -0.2s; + } + } + + // Defining the timing of the transition-in for items within the dialog, + // These longer delays are to allow for the dialog to slide down on first screen + .dialog-initial { + .brand-logo { + transition-delay: 0.6s; + } + + .welcome-text { + transition-delay: 0.8s; + } + + .tiles-theme-section, + .multi-select-container, + migration-wizard { + transition-delay: 0.9s; + } + + .primary, + .secondary, + .secondary-cta, + .steps, + .cta-link { + transition-delay: 1s; + } + } + + // Delays for transitioning-in of intermediate screens + .screen:not(.dialog-initial) { + .tiles-theme-section, + .multi-select-container, + .colorway-text { + transition-delay: 0.2s; + } + + .primary, + .secondary, + .secondary-cta, + .cta-link { + transition-delay: 0.4s; + } + } + + .screen-2 { + .section-main { + z-index: 1; + margin: auto; + } + + // Position of noodles on third screen + .outline-L { + width: 87px; + height: 80px; + transform: rotate(250deg) translate(-420%, 425%); + transition-delay: 0.2s; + z-index: 2; + } + + .orange-L { + height: 800px; + width: 660px; + transform: rotate(35deg) translate(-10%, -7%); + transition-delay: -0.4s; + } + + .purple-C { + width: 392px; + height: 394px; + transform: rotate(260deg) translate(-34%, -35%); + transition-delay: -0.2s; + fill: #952BB9; + } + + .yellow-circle { + width: 165px; + height: 165px; + border-radius: 50%; + transform: translate(160%, 130%); + background: #E7258C; + } + } + + &.transition-in { + .noodle { + opacity: 0; + rotate: var(--rotate); + scale: var(--scale); + } + + .dialog-initial { + .main-content, + .dismiss-button { + translate: 0 calc(-2 * var(--translate)); + } + + .brand-logo, + .steps { + opacity: 0; + translate: 0 calc(-1 * var(--translate)); + } + } + + .screen { + .welcome-text, + .multi-select-container, + .tiles-theme-section, + .colorway-text, + .primary, + .checkbox-container:not(.multi-select-item), + .secondary, + .secondary-cta:not(.top), + .cta-link, + migration-wizard { + opacity: 0; + translate: 0 calc(-1 * var(--translate)); + } + + &:not(.dialog-initial) { + .steps:not(.progress-bar) { + opacity: 0.2; + } + } + } + } + + &.transition-out { + .noodle { + opacity: 0; + rotate: var(--rotate); + scale: var(--scale); + transition-delay: 0.2s; + } + + .screen:not(.dialog-last) { + .main-content { + overflow: hidden; + } + + .welcome-text, + .multi-select-container { + opacity: 0; + translate: 0 var(--translate); + transition-delay: 0.1s; + } + + // content that is nested between inner main content and navigation CTAs + // requires an additional 0.1s transition to avoid overlap + .tiles-theme-section, + .colorway-text, + migration-wizard { + opacity: 0; + translate: 0 var(--translate); + transition-delay: 0.2s; + } + + .primary, + .checkbox-container:not(.multi-select-item), + .secondary, + .secondary-cta:not(.top), + .cta-link { + opacity: 0; + translate: 0 var(--translate); + transition-delay: 0.3s; + } + + .steps:not(.progress-bar) { + opacity: 0.2; + transition-delay: 0.5s; + } + } + + .dialog-last { + .noodle { + transition-delay: 0s; + } + + .main-content, + .dismiss-button { + opacity: 0; + translate: 0 calc(2 * var(--translate)); + transition-delay: 0.4s; + } + } + } + + migration-wizard { + width: unset; + transition: var(--transition); + + &::part(buttons) { + margin-top: 32px; + justify-content: flex-start; + } + + &::part(deck) { + font-size: 0.83em; + } + } +} diff --git a/browser/components/newtab/content-src/aboutwelcome/components/AdditionalCTA.jsx b/browser/components/newtab/content-src/aboutwelcome/components/AdditionalCTA.jsx new file mode 100644 index 0000000000..2b61d1a82a --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/AdditionalCTA.jsx @@ -0,0 +1,30 @@ +/* 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 AdditionalCTA = ({ content, handleAction }) => { + let buttonStyle = ""; + + if (!content.additional_button?.style) { + buttonStyle = "primary"; + } else { + buttonStyle = + content.additional_button?.style === "link" + ? "cta-link" + : content.additional_button?.style; + } + + return ( + <Localized text={content.additional_button?.label}> + <button + className={`${buttonStyle} additional-cta`} + onClick={handleAction} + value="additional_button" + disabled={content.additional_button?.disabled === true} + /> + </Localized> + ); +}; diff --git a/browser/components/newtab/content-src/aboutwelcome/components/CTAParagraph.jsx b/browser/components/newtab/content-src/aboutwelcome/components/CTAParagraph.jsx new file mode 100644 index 0000000000..41726626a4 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/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/newtab/content-src/aboutwelcome/components/EmbeddedMigrationWizard.jsx b/browser/components/newtab/content-src/aboutwelcome/components/EmbeddedMigrationWizard.jsx new file mode 100644 index 0000000000..43930009a5 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/EmbeddedMigrationWizard.jsx @@ -0,0 +1,38 @@ +/* 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 auto-request-state="" ref={ref}> + <panel-list /> + </migration-wizard> + ); +}; diff --git a/browser/components/newtab/content-src/aboutwelcome/components/HelpText.jsx b/browser/components/newtab/content-src/aboutwelcome/components/HelpText.jsx new file mode 100644 index 0000000000..f7b413be81 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/HelpText.jsx @@ -0,0 +1,49 @@ +/* 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"; +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} + 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} + alt="" + /> + </p> + ); + } + } else { + return ( + <Localized text={props.text}> + <p className={`helptext ${props.position}`} /> + </Localized> + ); + } + return null; +}; diff --git a/browser/components/newtab/content-src/aboutwelcome/components/HeroImage.jsx b/browser/components/newtab/content-src/aboutwelcome/components/HeroImage.jsx new file mode 100644 index 0000000000..e03a5f84f4 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/HeroImage.jsx @@ -0,0 +1,24 @@ +/* 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 HeroImage = props => { + const { height, url, alt } = props; + + if (!url) { + return null; + } + + return ( + <div className="hero-image"> + <img + style={height ? { height } : null} + src={url} + alt={alt || ""} + role={alt ? null : "presentation"} + /> + </div> + ); +}; diff --git a/browser/components/newtab/content-src/aboutwelcome/components/LanguageSwitcher.jsx b/browser/components/newtab/content-src/aboutwelcome/components/LanguageSwitcher.jsx new file mode 100644 index 0000000000..9abd749a6d --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/LanguageSwitcher.jsx @@ -0,0 +1,294 @@ +/* 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"; + +/** + * 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] + ); + + 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); + } + }, + [screens, negotiatedLanguage] + ); + + 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" } } + ); + }); + } + }, [isAwaitingLangpack, langPackInstallPhase]); + + 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/newtab/content-src/aboutwelcome/components/MRColorways.jsx b/browser/components/newtab/content-src/aboutwelcome/components/MRColorways.jsx new file mode 100644 index 0000000000..6a69f3483a --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/MRColorways.jsx @@ -0,0 +1,198 @@ +/* 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/newtab/content-src/aboutwelcome/components/MSLocalized.jsx b/browser/components/newtab/content-src/aboutwelcome/components/MSLocalized.jsx new file mode 100644 index 0000000000..461f19fb28 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/MSLocalized.jsx @@ -0,0 +1,108 @@ +/* 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"; +const CONFIGURABLE_STYLES = [ + "color", + "fontSize", + "fontWeight", + "letterSpacing", + "lineHeight", + "marginBlock", + "marginInline", + "paddingBlock", + "paddingInline", +]; +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/newtab/content-src/aboutwelcome/components/MobileDownloads.jsx b/browser/components/newtab/content-src/aboutwelcome/components/MobileDownloads.jsx new file mode 100644 index 0000000000..8390d2fd68 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/MobileDownloads.jsx @@ -0,0 +1,71 @@ +/* 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 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} + /> + ) : 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/newtab/content-src/aboutwelcome/components/MultiSelect.jsx b/browser/components/newtab/content-src/aboutwelcome/components/MultiSelect.jsx new file mode 100644 index 0000000000..0c1824215a --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/MultiSelect.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, { useEffect } from "react"; +import { Localized } from "./MSLocalized"; + +export const MultiSelect = props => { + let handleChange = event => { + if (event.currentTarget.checked) { + props.setActiveMultiSelect([ + ...props.activeMultiSelect, + event.currentTarget.value, + ]); + } else { + props.setActiveMultiSelect( + props.activeMultiSelect.filter(id => id !== event.currentTarget.value) + ); + } + }; + + let { data } = props.content.tiles; + // When screen renders for first time, update state + // with checkbox ids that has defaultvalue true + useEffect(() => { + if (!props.activeMultiSelect) { + props.setActiveMultiSelect( + data.map(item => item.defaultValue && item.id).filter(item => !!item) + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <div className="multi-select-container"> + {props.content.tiles.data.map(({ label, id }) => ( + <div key={id + label} className="checkbox-container multi-select-item"> + <input + type="checkbox" + id={id} + value={id} + checked={props.activeMultiSelect?.includes(id)} + onChange={handleChange} + /> + <Localized text={label}> + <label htmlFor={id}></label> + </Localized> + </div> + ))} + </div> + ); +}; diff --git a/browser/components/newtab/content-src/aboutwelcome/components/MultiStageAboutWelcome.jsx b/browser/components/newtab/content-src/aboutwelcome/components/MultiStageAboutWelcome.jsx new file mode 100644 index 0000000000..b58510e514 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/MultiStageAboutWelcome.jsx @@ -0,0 +1,468 @@ +/* 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"; +import { MultiStageProtonScreen } from "./MultiStageProtonScreen"; +import { useLanguageSwitcher } from "./LanguageSwitcher"; +import { + BASE_PARAMS, + addUtmParams, +} from "../../asrouter/templates/FirstRun/addUtmParams"; + +// 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) { + AboutWelcomeUtils.sendImpressionTelemetry( + `${props.message_id}_${order}_${screen.id}_${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 + + // Save the active multi select state containing array of checkbox ids + // used in handleAction to update MULTI_ACTION data + const [activeMultiSelect, setActiveMultiSelect] = useState(null); + + // 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; + + 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} + activeMultiSelect={activeMultiSelect} + setActiveMultiSelect={setActiveMultiSelect} + autoAdvance={screen.auto_advance} + negotiatedLanguage={negotiatedLanguage} + langPackInstallPhase={langPackInstallPhase} + /> + ) : null; + })} + </div> + </React.Fragment> + ); +}; + +export const SecondaryCTA = props => { + let targetElement = props.position + ? `secondary_button_${props.position}` + : `secondary_button`; + const buttonStyling = props.content.secondary_button?.has_arrow_icon + ? `secondary text-link arrow-icon` + : `secondary text-link`; + + return ( + <div + className={ + props.position ? `secondary-cta ${props.position}` : "secondary-cta" + } + > + <Localized text={props.content[targetElement].text}> + <span /> + </Localized> + <Localized text={props.content[targetElement].label}> + <button + className={buttonStyling} + value={targetElement} + onClick={props.handleAction} + /> + </Localized> + </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 (!(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; + + if (action.collectSelect) { + // Populate MULTI_ACTION data actions property with selected checkbox actions from tiles data + action.data = { + actions: [], + }; + + for (const checkbox of props.content?.tiles?.data ?? []) { + let checkboxAction; + if (this.props.activeMultiSelect.includes(checkbox.id)) { + checkboxAction = checkbox.checkedAction ?? checkbox.action; + } else { + checkboxAction = checkbox.uncheckedAction; + } + + if (checkboxAction) { + action.data.actions.push(checkboxAction); + } + } + + // Send telemetry with selected checkbox ids + AboutWelcomeUtils.sendActionTelemetry( + props.messageId, + props.activeMultiSelect, + "SELECT_CHECKBOX" + ); + } + + 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(); + } + } + + render() { + return ( + <MultiStageProtonScreen + content={this.props.content} + id={this.props.id} + order={this.props.order} + previousOrder={this.props.previousOrder} + activeTheme={this.props.activeTheme} + 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} + /> + ); + } +} diff --git a/browser/components/newtab/content-src/aboutwelcome/components/MultiStageProtonScreen.jsx b/browser/components/newtab/content-src/aboutwelcome/components/MultiStageProtonScreen.jsx new file mode 100644 index 0000000000..b51d2a2044 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/MultiStageProtonScreen.jsx @@ -0,0 +1,472 @@ +/* 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 { Colorways } from "./MRColorways"; +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"; + +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} + 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} + /> + ); +}; + +export const ProtonScreenActionButtons = props => { + const { content, addonName } = props; + const defaultValue = content.checkbox?.defaultValue; + + const [isChecked, setIsChecked] = useState(defaultValue || false); + + if ( + !content.primary_button && + !content.secondary_button && + !content.additional_button + ) { + return null; + } + + return ( + <div + className={`action-buttons ${ + content.additional_button ? "additional-cta-container" : "" + }`} + flow={content.additional_button?.flow} + > + <Localized text={content.primary_button?.label}> + <button + className="primary" + // 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={content.primary_button?.disabled === true} + 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} /> + ) : null} + </div> + ); +}; + +export class ProtonScreen extends React.PureComponent { + componentDidMount() { + this.mainContentHeader.focus(); + } + + getScreenClassName( + isFirstScreen, + isLastScreen, + includeNoodles, + isVideoOnboarding + ) { + const screenClass = `screen-${this.props.order % 2 !== 0 ? 1 : 2}`; + + if (isVideoOnboarding) return "with-video"; + + return `${isFirstScreen ? `dialog-initial` : ``} ${ + isLastScreen ? `dialog-last` : `` + } ${includeNoodles ? `with-noodles` : ``} ${screenClass}`; + } + + renderLogo({ + imageURL = "chrome://branding/content/about-logo.svg", + darkModeImageURL, + reducedMotionImageURL, + darkModeReducedMotionImageURL, + alt = "", + height, + }) { + return ( + <picture className="logo-container"> + {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} + <img + className="brand-logo" + style={{ height }} + src={imageURL} + alt={alt} + role={alt ? null : "presentation"} + /> + </picture> + ); + } + + renderContentTiles() { + const { content } = this.props; + return ( + <React.Fragment> + {content.tiles && + content.tiles.type === "colorway" && + content.tiles.colorways ? ( + <Colorways + content={content} + activeTheme={this.props.activeTheme} + 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} + activeMultiSelect={this.props.activeMultiSelect} + setActiveMultiSelect={this.props.setActiveMultiSelect} + handleAction={this.props.handleAction} + /> + ) : 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() { + return ( + <button + className="dismiss-button" + onClick={this.props.handleAction} + value="dismiss_button" + data-l10n-id={"spotlight-dialog-close-button"} + ></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={"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" + 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> + <Localized text={content.help_text}> + <span className="attrib-text" /> + </Localized> + </React.Fragment> + )} + </div> + ); + } + + render() { + const { + autoAdvance, + content, + isRtamo, + isTheme, + isFirstScreen, + isLastScreen, + isSingleScreen, + } = 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; + 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 + ) + : ""; + const isEmbeddedMigration = content.tiles?.type === "migration-wizard"; + + return ( + <main + className={`screen ${this.props.id || ""} + ${screenClassName} ${textColorClass}`} + role="alertdialog" + 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" : "" + }`} + role="document" + > + {content.secondary_button_top ? ( + <SecondaryCTA + content={content} + handleAction={this.props.handleAction} + position="top" + /> + ) : null} + {includeNoodles ? this.renderNoodles() : null} + <div + className={`main-content ${hideStepsIndicator ? "no-steps" : ""}`} + style={ + content.background && isCenterPosition + ? { background: content.background } + : {} + } + > + {content.logo ? this.renderLogo(content.logo) : null} + + {isRtamo ? ( + <div className="rtamo-icon"> + <img + className={`${isTheme ? "rtamo-theme-icon" : "brand-logo"}`} + src={this.props.iconURL} + role="presentation" + alt="" + /> + </div> + ) : null} + + <div className="main-content-inner"> + <div className={`welcome-text ${content.title_style || ""}`}> + {content.title ? ( + <Localized text={content.title}> + <h1 id="mainContentHeader" /> + </Localized> + ) : 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} + {this.renderContentTiles()} + {this.renderLanguageSwitcher()} + <ProtonScreenActionButtons + content={content} + addonName={this.props.addonName} + handleAction={this.props.handleAction} + /> + </div> + {!hideStepsIndicator ? this.renderStepsIndicator() : null} + </div> + {content.dismiss_button ? this.renderDismissButton() : null} + </div> + </main> + ); + } +} diff --git a/browser/components/newtab/content-src/aboutwelcome/components/OnboardingVideo.jsx b/browser/components/newtab/content-src/aboutwelcome/components/OnboardingVideo.jsx new file mode 100644 index 0000000000..629a409a59 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/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/newtab/content-src/aboutwelcome/components/ReturnToAMO.jsx b/browser/components/newtab/content-src/aboutwelcome/components/ReturnToAMO.jsx new file mode 100644 index 0000000000..fb5e80c6e4 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/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"; +import { MultiStageProtonScreen } from "./MultiStageProtonScreen"; +import { BASE_PARAMS } from "../../asrouter/templates/FirstRun/addUtmParams"; + +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/newtab/content-src/aboutwelcome/components/Themes.jsx b/browser/components/newtab/content-src/aboutwelcome/components/Themes.jsx new file mode 100644 index 0000000000..def2603426 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/Themes.jsx @@ -0,0 +1,51 @@ +/* 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 : {}} + > + <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/newtab/content-src/aboutwelcome/components/Zap.jsx b/browser/components/newtab/content-src/aboutwelcome/components/Zap.jsx new file mode 100644 index 0000000000..a067c4d7fe --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/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; +}; |