diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /browser/components/newtab/content-src | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/newtab/content-src')
191 files changed, 29878 insertions, 0 deletions
diff --git a/browser/components/newtab/content-src/.eslintrc.js b/browser/components/newtab/content-src/.eslintrc.js new file mode 100644 index 0000000000..bf7904cc43 --- /dev/null +++ b/browser/components/newtab/content-src/.eslintrc.js @@ -0,0 +1,12 @@ +/* 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/. */ + +/* eslint-disable import/no-commonjs */ + +module.exports = { + rules: { + "import/no-commonjs": 2, + "react/jsx-no-bind": 0, + }, +}; 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; +}; diff --git a/browser/components/newtab/content-src/activity-stream.jsx b/browser/components/newtab/content-src/activity-stream.jsx new file mode 100644 index 0000000000..c588e8e850 --- /dev/null +++ b/browser/components/newtab/content-src/activity-stream.jsx @@ -0,0 +1,57 @@ +/* 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 { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { Base } from "content-src/components/Base/Base"; +import { DetectUserSessionStart } from "content-src/lib/detect-user-session-start"; +import { initStore } from "content-src/lib/init-store"; +import { Provider } from "react-redux"; +import React from "react"; +import ReactDOM from "react-dom"; +import { reducers } from "common/Reducers.sys.mjs"; + +export const NewTab = ({ store }) => ( + <Provider store={store}> + <Base /> + </Provider> +); + +export function renderWithoutState() { + const store = initStore(reducers); + new DetectUserSessionStart(store).sendEventOrAddListener(); + + // If this document has already gone into the background by the time we've reached + // here, we can deprioritize requesting the initial state until the event loop + // frees up. If, however, the visibility changes, we then send the request. + let didRequest = false; + let requestIdleCallbackId = 0; + function doRequest() { + if (!didRequest) { + if (requestIdleCallbackId) { + cancelIdleCallback(requestIdleCallbackId); + } + didRequest = true; + store.dispatch(ac.AlsoToMain({ type: at.NEW_TAB_STATE_REQUEST })); + } + } + + if (document.hidden) { + requestIdleCallbackId = requestIdleCallback(doRequest); + addEventListener("visibilitychange", doRequest, { once: true }); + } else { + doRequest(); + } + + ReactDOM.hydrate(<NewTab store={store} />, document.getElementById("root")); +} + +export function renderCache(initialState) { + const store = initStore(reducers, initialState); + new DetectUserSessionStart(store).sendEventOrAddListener(); + + ReactDOM.hydrate(<NewTab store={store} />, document.getElementById("root")); +} diff --git a/browser/components/newtab/content-src/asrouter/README.md b/browser/components/newtab/content-src/asrouter/README.md new file mode 100644 index 0000000000..0ee3345630 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/README.md @@ -0,0 +1,34 @@ +# Activity Stream Router + +## Preferences `browser.newtab.activity-stream.asrouter.*` + +Name | Used for | Type | Example value +--- | --- | --- | --- +`allowHosts` | Allow a host in order to fetch messages from its endpoint | `[String]` | `["gist.github.com", "gist.githubusercontent.com", "localhost:8000"]` +`providers.snippets` | Message provider options for snippets | `Object` | [see below](#message-providers) +`providers.cfr` | Message provider options for cfr | `Object` | [see below](#message-providers) +`providers.onboarding` | Message provider options for onboarding | `Object` | [see below](#message-providers) +`useRemoteL10n` | Controls whether to use the remote Fluent files for l10n, default as `true` | `Boolean` | `[true|false]` + +### Message providers examples + +```json +{ + "id" : "snippets", + "type" : "remote", + "enabled": true, + "url" : "https://snippets.cdn.mozilla.net/us-west/bundles/bundle_d6d90fb9098ce8b45e60acf601bcb91b68322309.json", + "updateCycleInMs" : 14400000 +} +``` + +```json +{ + "id" : "onboarding", + "enabled": true, + "type" : "local", + "localProvider" : "OnboardingMessageProvider" +} +``` + +### [Snippet message format documentation](https://github.com/mozilla/activity-stream/blob/master/content-src/asrouter/schemas/message-format.md) diff --git a/browser/components/newtab/content-src/asrouter/asrouter-content.jsx b/browser/components/newtab/content-src/asrouter/asrouter-content.jsx new file mode 100644 index 0000000000..4c3f08d4cd --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/asrouter-content.jsx @@ -0,0 +1,346 @@ +/* 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 { MESSAGE_TYPE_HASH as msg } from "common/ActorConstants.sys.mjs"; +import { actionTypes as at } from "common/Actions.sys.mjs"; +import { ASRouterUtils } from "./asrouter-utils"; +import { generateBundles } from "./rich-text-strings"; +import { ImpressionsWrapper } from "./components/ImpressionsWrapper/ImpressionsWrapper"; +import { LocalizationProvider, ReactLocalization } from "@fluent/react"; +import { NEWTAB_DARK_THEME } from "content-src/lib/constants"; +import React from "react"; +import ReactDOM from "react-dom"; +import { SnippetsTemplates } from "./templates/template-manifest"; + +const TEMPLATES_BELOW_SEARCH = ["simple_below_search_snippet"]; + +// Note: nextProps/prevProps refer to props passed to <ImpressionsWrapper />, not <ASRouterUISurface /> +function shouldSendImpressionOnUpdate(nextProps, prevProps) { + return ( + nextProps.message.id && + (!prevProps.message || prevProps.message.id !== nextProps.message.id) + ); +} + +export class ASRouterUISurface extends React.PureComponent { + constructor(props) { + super(props); + this.sendClick = this.sendClick.bind(this); + this.sendImpression = this.sendImpression.bind(this); + this.sendUserActionTelemetry = this.sendUserActionTelemetry.bind(this); + this.onUserAction = this.onUserAction.bind(this); + this.fetchFlowParams = this.fetchFlowParams.bind(this); + this.onBlockSelected = this.onBlockSelected.bind(this); + this.onBlockById = this.onBlockById.bind(this); + this.onDismiss = this.onDismiss.bind(this); + this.onMessageFromParent = this.onMessageFromParent.bind(this); + + this.state = { message: {} }; + if (props.document) { + this.footerPortal = props.document.getElementById( + "footer-asrouter-container" + ); + } + } + + async fetchFlowParams(params = {}) { + let result = {}; + const { fxaEndpoint } = this.props; + if (!fxaEndpoint) { + const err = + "Tried to fetch flow params before fxaEndpoint pref was ready"; + console.error(err); + } + + try { + const urlObj = new URL(fxaEndpoint); + urlObj.pathname = "metrics-flow"; + Object.keys(params).forEach(key => { + urlObj.searchParams.append(key, params[key]); + }); + const response = await fetch(urlObj.toString(), { credentials: "omit" }); + if (response.status === 200) { + const { deviceId, flowId, flowBeginTime } = await response.json(); + result = { deviceId, flowId, flowBeginTime }; + } else { + console.error("Non-200 response", response); + } + } catch (error) { + console.error(error); + } + return result; + } + + sendUserActionTelemetry(extraProps = {}) { + const { message } = this.state; + const eventType = `${message.provider}_user_event`; + const source = extraProps.id; + delete extraProps.id; + ASRouterUtils.sendTelemetry({ + source, + message_id: message.id, + action: eventType, + ...extraProps, + }); + } + + sendImpression(extraProps) { + if (this.state.message.provider === "preview") { + return Promise.resolve(); + } + + this.sendUserActionTelemetry({ event: "IMPRESSION", ...extraProps }); + return ASRouterUtils.sendMessage({ + type: msg.IMPRESSION, + data: this.state.message, + }); + } + + // If link has a `metric` data attribute send it as part of the `event_context` + // telemetry field which can have arbitrary values. + // Used for router messages with links as part of the content. + sendClick(event) { + const { dataset } = event.target; + const metric = { + event_context: dataset.metric, + // Used for the `source` of the event. Needed to differentiate + // from other snippet or onboarding events that may occur. + id: "NEWTAB_FOOTER_BAR_CONTENT", + }; + const { entrypoint_name, entrypoint_value } = dataset; + // Assign the snippet referral for the action + const entrypoint = entrypoint_name + ? new URLSearchParams([[entrypoint_name, entrypoint_value]]).toString() + : entrypoint_value; + const action = { + type: dataset.action, + data: { + args: dataset.args, + ...(entrypoint && { entrypoint }), + }, + }; + if (action.type) { + ASRouterUtils.executeAction(action); + } + if ( + !this.state.message.content.do_not_autoblock && + !dataset.do_not_autoblock + ) { + this.onBlockById(this.state.message.id); + } + if (this.state.message.provider !== "preview") { + this.sendUserActionTelemetry({ event: "CLICK_BUTTON", ...metric }); + } + } + + onBlockSelected(options) { + return this.onBlockById(this.state.message.id, { + ...options, + campaign: this.state.message.campaign, + }); + } + + onBlockById(id, options) { + return ASRouterUtils.blockById(id, options).then(clearAll => { + if (clearAll) { + this.setState({ message: {} }); + } + }); + } + + onDismiss() { + this.clearMessage(this.state.message.id); + } + + // Blocking a snippet by id blocks the entire campaign + // so when clearing we use the two values interchangeably + clearMessage(idOrCampaign) { + if ( + idOrCampaign === this.state.message.id || + idOrCampaign === this.state.message.campaign + ) { + this.setState({ message: {} }); + } + } + + clearProvider(id) { + if (this.state.message.provider === id) { + this.setState({ message: {} }); + } + } + + onMessageFromParent({ type, data }) { + // These only exists due to onPrefChange events in ASRouter + switch (type) { + case "ClearMessages": { + data.forEach(id => this.clearMessage(id)); + break; + } + case "ClearProviders": { + data.forEach(id => this.clearProvider(id)); + break; + } + case "EnterSnippetsPreviewMode": { + this.props.dispatch({ type: at.SNIPPETS_PREVIEW_MODE }); + break; + } + } + } + + requestMessage(endpoint) { + ASRouterUtils.sendMessage({ + type: "NEWTAB_MESSAGE_REQUEST", + data: { endpoint }, + }).then(state => this.setState(state)); + } + + componentWillMount() { + const endpoint = ASRouterUtils.getPreviewEndpoint(); + if (endpoint && endpoint.theme === "dark") { + global.window.dispatchEvent( + new CustomEvent("LightweightTheme:Set", { + detail: { data: NEWTAB_DARK_THEME }, + }) + ); + } + if (endpoint && endpoint.dir === "rtl") { + //Set `dir = rtl` on the HTML + this.props.document.dir = "rtl"; + } + ASRouterUtils.addListener(this.onMessageFromParent); + this.requestMessage(endpoint); + } + + componentWillUnmount() { + ASRouterUtils.removeListener(this.onMessageFromParent); + } + + componentDidUpdate(prevProps, prevState) { + if ( + prevProps.adminContent && + JSON.stringify(prevProps.adminContent) !== + JSON.stringify(this.props.adminContent) + ) { + this.updateContent(); + } + if (prevState.message.id !== this.state.message.id) { + const main = global.window.document.querySelector("main"); + if (main) { + if (this.state.message.id) { + main.classList.add("has-snippet"); + } else { + main.classList.remove("has-snippet"); + } + } + } + } + + updateContent() { + this.setState({ + ...this.props.adminContent, + }); + } + + async getMonitorUrl({ url, flowRequestParams = {} }) { + const flowValues = await this.fetchFlowParams(flowRequestParams); + + // Note that flowParams are actually added dynamically on the page + const urlObj = new URL(url); + ["deviceId", "flowId", "flowBeginTime"].forEach(key => { + if (key in flowValues) { + urlObj.searchParams.append(key, flowValues[key]); + } + }); + + return urlObj.toString(); + } + + async onUserAction(action) { + switch (action.type) { + // This needs to be handled locally because its + case "ENABLE_FIREFOX_MONITOR": + const url = await this.getMonitorUrl(action.data.args); + ASRouterUtils.executeAction({ type: "OPEN_URL", data: { args: url } }); + break; + default: + ASRouterUtils.executeAction(action); + } + } + + renderSnippets() { + const { message } = this.state; + if (!SnippetsTemplates[message.template]) { + return null; + } + const SnippetComponent = SnippetsTemplates[message.template]; + const { content } = message; + + return ( + <ImpressionsWrapper + id="NEWTAB_FOOTER_BAR" + message={this.state.message} + sendImpression={this.sendImpression} + shouldSendImpressionOnUpdate={shouldSendImpressionOnUpdate} + // This helps with testing + document={this.props.document} + > + <LocalizationProvider + l10n={new ReactLocalization(generateBundles(content))} + > + <SnippetComponent + {...this.state.message} + UISurface="NEWTAB_FOOTER_BAR" + onBlock={this.onBlockSelected} + onDismiss={this.onDismiss} + onAction={this.onUserAction} + sendClick={this.sendClick} + sendUserActionTelemetry={this.sendUserActionTelemetry} + /> + </LocalizationProvider> + </ImpressionsWrapper> + ); + } + + renderPreviewBanner() { + if (this.state.message.provider !== "preview") { + return null; + } + + return ( + <div className="snippets-preview-banner"> + <span className="icon icon-small-spacer icon-info" /> + <span>Preview Purposes Only</span> + </div> + ); + } + + render() { + const { message } = this.state; + if (!message.id) { + return null; + } + const shouldRenderBelowSearch = TEMPLATES_BELOW_SEARCH.includes( + message.template + ); + + return shouldRenderBelowSearch ? ( + // Render special below search snippets in place; + <div className="below-search-snippet-wrapper"> + {this.renderSnippets()} + </div> + ) : ( + // For regular snippets etc. we should render everything in our footer + // container. + ReactDOM.createPortal( + <> + {this.renderPreviewBanner()} + {this.renderSnippets()} + </>, + this.footerPortal + ) + ); + } +} + +ASRouterUISurface.defaultProps = { document: global.document }; diff --git a/browser/components/newtab/content-src/asrouter/asrouter-utils.js b/browser/components/newtab/content-src/asrouter/asrouter-utils.js new file mode 100644 index 0000000000..9864823a77 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/asrouter-utils.js @@ -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 { MESSAGE_TYPE_HASH as msg } from "common/ActorConstants.sys.mjs"; +import { actionCreators as ac } from "common/Actions.sys.mjs"; + +export const ASRouterUtils = { + addListener(listener) { + if (global.ASRouterAddParentListener) { + global.ASRouterAddParentListener(listener); + } + }, + removeListener(listener) { + if (global.ASRouterRemoveParentListener) { + global.ASRouterRemoveParentListener(listener); + } + }, + sendMessage(action) { + if (global.ASRouterMessage) { + return global.ASRouterMessage(action); + } + throw new Error(`Unexpected call:\n${JSON.stringify(action, null, 3)}`); + }, + blockById(id, options) { + return ASRouterUtils.sendMessage({ + type: msg.BLOCK_MESSAGE_BY_ID, + data: { id, ...options }, + }); + }, + modifyMessageJson(content) { + return ASRouterUtils.sendMessage({ + type: msg.MODIFY_MESSAGE_JSON, + data: { content }, + }); + }, + executeAction(button_action) { + return ASRouterUtils.sendMessage({ + type: msg.USER_ACTION, + data: button_action, + }); + }, + unblockById(id) { + return ASRouterUtils.sendMessage({ + type: msg.UNBLOCK_MESSAGE_BY_ID, + data: { id }, + }); + }, + blockBundle(bundle) { + return ASRouterUtils.sendMessage({ + type: msg.BLOCK_BUNDLE, + data: { bundle }, + }); + }, + unblockBundle(bundle) { + return ASRouterUtils.sendMessage({ + type: msg.UNBLOCK_BUNDLE, + data: { bundle }, + }); + }, + overrideMessage(id) { + return ASRouterUtils.sendMessage({ + type: msg.OVERRIDE_MESSAGE, + data: { id }, + }); + }, + sendTelemetry(ping) { + return ASRouterUtils.sendMessage(ac.ASRouterUserEvent(ping)); + }, + getPreviewEndpoint() { + if ( + global.document && + global.document.location && + global.document.location.href.includes("endpoint") + ) { + const params = new URLSearchParams( + global.document.location.href.slice( + global.document.location.href.indexOf("endpoint") + ) + ); + try { + const endpoint = new URL(params.get("endpoint")); + return { + url: endpoint.href, + snippetId: params.get("snippetId"), + theme: this.getPreviewTheme(), + dir: this.getPreviewDir(), + }; + } catch (e) {} + } + + return null; + }, + getPreviewTheme() { + return new URLSearchParams( + global.document.location.href.slice( + global.document.location.href.indexOf("theme") + ) + ).get("theme"); + }, + getPreviewDir() { + return new URLSearchParams( + global.document.location.href.slice( + global.document.location.href.indexOf("dir") + ) + ).get("dir"); + }, +}; diff --git a/browser/components/newtab/content-src/asrouter/components/Button/Button.jsx b/browser/components/newtab/content-src/asrouter/components/Button/Button.jsx new file mode 100644 index 0000000000..b3ece86f16 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/components/Button/Button.jsx @@ -0,0 +1,32 @@ +/* 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"; + +const ALLOWED_STYLE_TAGS = ["color", "backgroundColor"]; + +export const Button = props => { + const style = {}; + + // Add allowed style tags from props, e.g. props.color becomes style={color: props.color} + for (const tag of ALLOWED_STYLE_TAGS) { + if (typeof props[tag] !== "undefined") { + style[tag] = props[tag]; + } + } + // remove border if bg is set to something custom + if (style.backgroundColor) { + style.border = "0"; + } + + return ( + <button + onClick={props.onClick} + className={props.className || "ASRouterButton secondary"} + style={style} + > + {props.children} + </button> + ); +}; diff --git a/browser/components/newtab/content-src/asrouter/components/Button/_Button.scss b/browser/components/newtab/content-src/asrouter/components/Button/_Button.scss new file mode 100644 index 0000000000..35234be4b0 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/components/Button/_Button.scss @@ -0,0 +1,51 @@ +.ASRouterButton { + font-weight: 600; + font-size: 14px; + white-space: nowrap; + border-radius: 2px; + border: 0; + font-family: inherit; + padding: 8px 15px; + margin-inline-start: 12px; + color: inherit; + cursor: pointer; + + .tall & { + margin-inline-start: 20px; + } + + &.test-only { + width: 0; + height: 0; + overflow: hidden; + display: block; + visibility: hidden; + } + + &.primary { + border: 1px solid var(--newtab-primary-action-background); + background-color: var(--newtab-primary-action-background); + color: var(--newtab-primary-element-text-color); + + &:hover { + background-color: var(--newtab-primary-element-hover-color); + } + + &:active { + background-color: var(--newtab-primary-element-active-color); + } + } + + &.slim { + border: $border-primary; + margin-inline-start: 0; + font-size: 12px; + padding: 6px 12px; + + &:hover, + &:focus { + box-shadow: $shadow-primary; + transition: box-shadow 150ms; + } + } +} diff --git a/browser/components/newtab/content-src/asrouter/components/ConditionalWrapper/ConditionalWrapper.jsx b/browser/components/newtab/content-src/asrouter/components/ConditionalWrapper/ConditionalWrapper.jsx new file mode 100644 index 0000000000..e4b0812f26 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/components/ConditionalWrapper/ConditionalWrapper.jsx @@ -0,0 +1,9 @@ +/* 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/. */ + +// lifted from https://gist.github.com/kitze/23d82bb9eb0baabfd03a6a720b1d637f +const ConditionalWrapper = ({ condition, wrap, children }) => + condition && wrap ? wrap(children) : children; + +export default ConditionalWrapper; diff --git a/browser/components/newtab/content-src/asrouter/components/ImpressionsWrapper/ImpressionsWrapper.jsx b/browser/components/newtab/content-src/asrouter/components/ImpressionsWrapper/ImpressionsWrapper.jsx new file mode 100644 index 0000000000..8498bde03b --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/components/ImpressionsWrapper/ImpressionsWrapper.jsx @@ -0,0 +1,76 @@ +/* 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 VISIBLE = "visible"; +export const VISIBILITY_CHANGE_EVENT = "visibilitychange"; + +/** + * Component wrapper used to send telemetry pings on every impression. + */ +export class ImpressionsWrapper extends React.PureComponent { + // This sends an event when a user sees a set of new content. If content + // changes while the page is hidden (i.e. preloaded or on a hidden tab), + // only send the event if the page becomes visible again. + sendImpressionOrAddListener() { + if (this.props.document.visibilityState === VISIBLE) { + this.props.sendImpression({ id: this.props.id }); + } else { + // We should only ever send the latest impression stats ping, so remove any + // older listeners. + if (this._onVisibilityChange) { + this.props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + + // When the page becomes visible, send the impression stats ping if the section isn't collapsed. + this._onVisibilityChange = () => { + if (this.props.document.visibilityState === VISIBLE) { + this.props.sendImpression({ id: this.props.id }); + this.props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + }; + this.props.document.addEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + componentWillUnmount() { + if (this._onVisibilityChange) { + this.props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + componentDidMount() { + if (this.props.sendOnMount) { + this.sendImpressionOrAddListener(); + } + } + + componentDidUpdate(prevProps) { + if (this.props.shouldSendImpressionOnUpdate(this.props, prevProps)) { + this.sendImpressionOrAddListener(); + } + } + + render() { + return this.props.children; + } +} + +ImpressionsWrapper.defaultProps = { + document: global.document, + sendOnMount: true, +}; diff --git a/browser/components/newtab/content-src/asrouter/components/ModalOverlay/ModalOverlay.jsx b/browser/components/newtab/content-src/asrouter/components/ModalOverlay/ModalOverlay.jsx new file mode 100644 index 0000000000..fdfdf22db2 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/components/ModalOverlay/ModalOverlay.jsx @@ -0,0 +1,56 @@ +/* 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 class ModalOverlayWrapper extends React.PureComponent { + constructor(props) { + super(props); + this.onKeyDown = this.onKeyDown.bind(this); + } + + // The intended behaviour is to listen for an escape key + // but not for a click; see Bug 1582242 + onKeyDown(event) { + if (event.key === "Escape") { + this.props.onClose(event); + } + } + + componentWillMount() { + this.props.document.addEventListener("keydown", this.onKeyDown); + this.props.document.body.classList.add("modal-open"); + } + + componentWillUnmount() { + this.props.document.removeEventListener("keydown", this.onKeyDown); + this.props.document.body.classList.remove("modal-open"); + } + + render() { + const { props } = this; + let className = props.unstyled ? "" : "modalOverlayInner active"; + if (props.innerClassName) { + className += ` ${props.innerClassName}`; + } + return ( + <div + className="modalOverlayOuter active" + onKeyDown={this.onKeyDown} + role="presentation" + > + <div + className={className} + aria-labelledby={props.headerId} + id={props.id} + role="dialog" + > + {props.children} + </div> + </div> + ); + } +} + +ModalOverlayWrapper.defaultProps = { document: global.document }; diff --git a/browser/components/newtab/content-src/asrouter/components/ModalOverlay/_ModalOverlay.scss b/browser/components/newtab/content-src/asrouter/components/ModalOverlay/_ModalOverlay.scss new file mode 100644 index 0000000000..a1006c9437 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/components/ModalOverlay/_ModalOverlay.scss @@ -0,0 +1,103 @@ +// Variable for the about:welcome modal scrollbars +$modal-scrollbar-z-index: 1100; + +.activity-stream { + &.modal-open { + overflow: hidden; + } +} + +.modalOverlayOuter { + background: var(--newtab-overlay-color); + height: 100%; + position: fixed; + top: 0; + left: 0; + width: 100%; + display: none; + z-index: $modal-scrollbar-z-index; + overflow: auto; + + &.active { + display: flex; + } +} + +.modalOverlayInner { + min-width: min-content; + width: 100%; + max-width: 960px; + position: relative; + margin: auto; + background: var(--newtab-background-color-secondary); + box-shadow: $shadow-large; + border-radius: 4px; + display: none; + z-index: $modal-scrollbar-z-index; + + // modal takes over entire screen + @media(max-width: 960px) { + height: 100%; + top: 0; + left: 0; + box-shadow: none; + border-radius: 0; + } + + &.active { + display: block; + } + + h2 { + color: var(--newtab-text-primary-color); + text-align: center; + font-weight: 200; + margin-top: 30px; + font-size: 28px; + line-height: 37px; + letter-spacing: -0.13px; + + @media(max-width: 960px) { + margin-top: 100px; + } + + @media(max-width: 850px) { + margin-top: 30px; + } + } + + .footer { + border-top: $border-secondary; + border-radius: 4px; + height: 70px; + width: 100%; + position: absolute; + bottom: 0; + text-align: center; + background-color: $white; + + // if modal is short enough, footer becomes sticky + @media(max-width: 850px) and (max-height: 730px) { + position: sticky; + } + + // if modal is narrow enough, footer becomes sticky + @media(max-width: 650px) and (max-height: 600px) { + position: sticky; + } + + .modalButton { + margin-top: 20px; + min-width: 150px; + height: 30px; + padding: 4px 30px 6px; + font-size: 15px; + + &:focus, + &.active, + &:hover { + @include fade-in-card; + } + } + } +} diff --git a/browser/components/newtab/content-src/asrouter/components/RichText/RichText.jsx b/browser/components/newtab/content-src/asrouter/components/RichText/RichText.jsx new file mode 100644 index 0000000000..d430fa5c3d --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/components/RichText/RichText.jsx @@ -0,0 +1,86 @@ +/* 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 { Localized } from "@fluent/react"; +import React from "react"; +import { RICH_TEXT_KEYS } from "../../rich-text-strings"; +import { safeURI } from "../../template-utils"; + +// Elements allowed in snippet content +const ALLOWED_TAGS = { + b: <b />, + i: <i />, + u: <u />, + strong: <strong />, + em: <em />, + br: <br />, +}; + +/** + * Transform an object (tag name: {url}) into (tag name: anchor) where the url + * is used as href, in order to render links inside a Fluent.Localized component. + */ +export function convertLinks( + links, + sendClick, + doNotAutoBlock, + openNewWindow = false +) { + if (links) { + return Object.keys(links).reduce((acc, linkTag) => { + const { action } = links[linkTag]; + // Setting the value to false will not include the attribute in the anchor + const url = action ? false : safeURI(links[linkTag].url); + + acc[linkTag] = ( + // eslint was getting a false positive caused by the dynamic injection + // of content. + // eslint-disable-next-line jsx-a11y/anchor-has-content + <a + href={url} + target={openNewWindow ? "_blank" : ""} + data-metric={links[linkTag].metric} + data-action={action} + data-args={links[linkTag].args} + data-do_not_autoblock={doNotAutoBlock} + data-entrypoint_name={links[linkTag].entrypoint_name} + data-entrypoint_value={links[linkTag].entrypoint_value} + rel="noreferrer" + onClick={sendClick} + /> + ); + return acc; + }, {}); + } + + return null; +} + +/** + * Message wrapper used to sanitize markup and render HTML. + */ +export function RichText(props) { + if (!RICH_TEXT_KEYS.includes(props.localization_id)) { + throw new Error( + `ASRouter: ${props.localization_id} is not a valid rich text property. If you want it to be processed, you need to add it to asrouter/rich-text-strings.js` + ); + } + return ( + <Localized + id={props.localization_id} + elems={{ + ...ALLOWED_TAGS, + ...props.customElements, + ...convertLinks( + props.links, + props.sendClick, + props.doNotAutoBlock, + props.openNewWindow + ), + }} + > + <span>{props.text}</span> + </Localized> + ); +} diff --git a/browser/components/newtab/content-src/asrouter/components/SnippetBase/SnippetBase.jsx b/browser/components/newtab/content-src/asrouter/components/SnippetBase/SnippetBase.jsx new file mode 100644 index 0000000000..fd25337fbf --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/components/SnippetBase/SnippetBase.jsx @@ -0,0 +1,121 @@ +/* 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 class SnippetBase extends React.PureComponent { + constructor(props) { + super(props); + this.onBlockClicked = this.onBlockClicked.bind(this); + this.onDismissClicked = this.onDismissClicked.bind(this); + this.setBlockButtonRef = this.setBlockButtonRef.bind(this); + this.onBlockButtonMouseEnter = this.onBlockButtonMouseEnter.bind(this); + this.onBlockButtonMouseLeave = this.onBlockButtonMouseLeave.bind(this); + this.state = { blockButtonHover: false }; + } + + componentDidMount() { + if (this.blockButtonRef) { + this.blockButtonRef.addEventListener( + "mouseenter", + this.onBlockButtonMouseEnter + ); + this.blockButtonRef.addEventListener( + "mouseleave", + this.onBlockButtonMouseLeave + ); + } + } + + componentWillUnmount() { + if (this.blockButtonRef) { + this.blockButtonRef.removeEventListener( + "mouseenter", + this.onBlockButtonMouseEnter + ); + this.blockButtonRef.removeEventListener( + "mouseleave", + this.onBlockButtonMouseLeave + ); + } + } + + setBlockButtonRef(element) { + this.blockButtonRef = element; + } + + onBlockButtonMouseEnter() { + this.setState({ blockButtonHover: true }); + } + + onBlockButtonMouseLeave() { + this.setState({ blockButtonHover: false }); + } + + onBlockClicked() { + if (this.props.provider !== "preview") { + this.props.sendUserActionTelemetry({ + event: "BLOCK", + id: this.props.UISurface, + }); + } + + this.props.onBlock(); + } + + onDismissClicked() { + if (this.props.provider !== "preview") { + this.props.sendUserActionTelemetry({ + event: "DISMISS", + id: this.props.UISurface, + }); + } + + this.props.onDismiss(); + } + + renderDismissButton() { + if (this.props.footerDismiss) { + return ( + <div className="footer"> + <div className="footer-content"> + <button + className="ASRouterButton secondary" + onClick={this.onDismissClicked} + > + {this.props.content.scene2_dismiss_button_text} + </button> + </div> + </div> + ); + } + + const label = this.props.content.block_button_text || "Remove this"; + return ( + <button + className="blockButton" + title={label} + aria-label={label} + onClick={this.onBlockClicked} + ref={this.setBlockButtonRef} + /> + ); + } + + render() { + const { props } = this; + const { blockButtonHover } = this.state; + + const containerClassName = `SnippetBaseContainer${ + props.className ? ` ${props.className}` : "" + }${blockButtonHover ? " active" : ""}`; + + return ( + <div className={containerClassName} style={this.props.textStyle}> + <div className="innerWrapper">{props.children}</div> + {this.renderDismissButton()} + </div> + ); + } +} diff --git a/browser/components/newtab/content-src/asrouter/components/SnippetBase/_SnippetBase.scss b/browser/components/newtab/content-src/asrouter/components/SnippetBase/_SnippetBase.scss new file mode 100644 index 0000000000..86bc30fa8b --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/components/SnippetBase/_SnippetBase.scss @@ -0,0 +1,117 @@ +.SnippetBaseContainer { + position: fixed; + z-index: 2; + bottom: 0; + left: 0; + right: 0; + background-color: var(--newtab-background-color-secondary); + color: var(--newtab-text-primary-color); + font-size: 14px; + line-height: 20px; + border-top: 1px solid transparent; + box-shadow: $shadow-secondary; + display: flex; + align-items: center; + + a { + cursor: pointer; + color: var(--newtab-primary-action-background); + + &:hover { + text-decoration: underline; + } + + [lwt-newtab-brighttext] & { + font-weight: bold; + } + } + + input { + &[type='checkbox'] { + margin-inline-start: 0; + } + } + + .innerWrapper { + margin: 0 auto; + display: flex; + align-items: center; + padding: 12px $section-horizontal-padding; + // This is to account for the block button on smaller screens + padding-inline-end: 36px; + max-width: $wrapper-max-width-large + ($section-horizontal-padding * 2); + + @media (min-width: $break-point-large) { + padding-inline-end: $section-horizontal-padding; + } + + @media (min-width: $break-point-widest) { + max-width: $wrapper-max-width-widest + ($section-horizontal-padding * 2); + } + } + + .blockButton { + display: none; + background: none; + border: 0; + position: absolute; + top: 20px; + inset-inline-end: 12px; + height: 16px; + width: 16px; + background-image: url('chrome://global/skin/icons/close.svg'); + -moz-context-properties: fill; + color: inherit; + fill: currentColor; + opacity: 0.5; + margin-top: -8px; + padding: 0; + cursor: pointer; + } + + &:hover .blockButton { + display: block; + } + + .icon { + height: 42px; + width: 42px; + margin-inline-end: 12px; + flex-shrink: 0; + } +} + +.snippets-preview-banner { + font-size: 15px; + line-height: 42px; + color: var(--newtab-text-primary-color); + background: var(--newtab-background-color-secondary); + text-align: center; + position: absolute; + top: 0; + width: 100%; + + span { + vertical-align: middle; + } +} + +// We show snippet icons for both themes and conditionally hide +// based on which theme is currently active +body { + &:not([lwt-newtab-brighttext]) { + .icon-dark-theme, + .icon.icon-dark-theme, + .scene2Icon .icon-dark-theme { + display: none; + } + } + + &[lwt-newtab-brighttext] { + .icon-light-theme, + .icon.icon-light-theme, + .scene2Icon .icon-light-theme { + display: none; + } + } +} diff --git a/browser/components/newtab/content-src/asrouter/docs/cfr_doorhanger_screenshot.png b/browser/components/newtab/content-src/asrouter/docs/cfr_doorhanger_screenshot.png Binary files differnew file mode 100644 index 0000000000..aee3bcf3bd --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/docs/cfr_doorhanger_screenshot.png diff --git a/browser/components/newtab/content-src/asrouter/docs/debugging-docs.md b/browser/components/newtab/content-src/asrouter/docs/debugging-docs.md new file mode 100644 index 0000000000..fedeeced5a --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/docs/debugging-docs.md @@ -0,0 +1,63 @@ +# Using ASRouter Devtools + +## How to enable ASRouter devtools +- In `about:config`, set `browser.newtabpage.activity-stream.asrouter.devtoolsEnabled` to `true` +- Visit `about:newtab#asrouter` to see the devtools. + +## Overview of ASRouter devtools + +![Devtools image](./debugging-guide.png) + +## How to enable/disable a provider + +To enable a provider such as `snippets`, Look at the list of "Message Providers" at the top of the page. Make sure the checkbox is checked next to the provider you want to enable. + +To disable it, uncheck the checkbox. You should see a red label indicating the provider is now disabled. + +## How to see all messages from a provider + +In order to see all active messages for a current provider such as `snippets`, use the drop down selector under the "Messages" section. Select the name of the provider you are interested in. + +The messages on the page should now be filtered to include only the provider you selected. + +## How to test telemetry pings + +To test telemetry pings, complete the the following steps: + +- In about:config, set: + - `browser.newtabpage.activity-stream.telemetry` to `true` + - `browser.ping-centre.log` to `true` +- To view additional debug logs for messaging system or about:welcome, set: + - `messaging-system.log` to `debug` + - `browser.aboutwelcome.log` to `debug` +- Open the Browser Toolbox devtools (Tools > Web Developer > Browser Toolbox) and switch to the console tab. Add a filter for for `activity-stream` to only display relevant pings: + +![Devtools telemetry ping](./telemetry-screenshot.png) + +You should now see pings show up as you view/interact with ASR messages/templates. + +## Snippets debugging + +### How to view preview URLs + +Follow these steps to view preview URLs (e.g. `about:newtab?endpoint=https://gist.githubusercontent.com/piatra/d193ca7e0f513cc19fc6a1d396c214f7/raw/8bcaf9548212e4c613577e839198cc14e7317630/newsletter_snippet.json&theme=dark`) + +You can preview in the two different themes (light and dark) by adding `&theme=dark` or `&theme=light` at the end of the url. + +#### IMPORTANT NOTES +- Links to URLs starting with `about:newtab` cannot be clicked on directly. They must be copy and pasted into the address bar. +- Previews should only be tested in `Firefox 64` and later. +- The endpoint must be HTTPS, the host must be allowed (see testing instructions below) +- Errors are surfaced in the `Console` tab of the `Browser Toolbox` + +#### Testing instructions +- If your endpoint URL has a host name of `snippets-admin.mozilla.org`, you can paste the URL into the address bar view it without any further steps. +- If your endpoint URL starts with some other host name, it must be **allowed**. Open the Browser Toolbox devtools (Tools > Developer > Browser Toolbox) and paste the following code (where `gist.githubusercontent.com` is the hostname of your endpoint URL): +```js +Services.prefs.setStringPref( + "browser.newtab.activity-stream.asrouter.allowHosts", + "[\"gist.githubusercontent.com\"]" +); +``` +- Restart the browser +- You should now be able to paste the URL into the address bar and view it. diff --git a/browser/components/newtab/content-src/asrouter/docs/debugging-guide.png b/browser/components/newtab/content-src/asrouter/docs/debugging-guide.png Binary files differnew file mode 100644 index 0000000000..8616a29ab3 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/docs/debugging-guide.png diff --git a/browser/components/newtab/content-src/asrouter/docs/first-run.md b/browser/components/newtab/content-src/asrouter/docs/first-run.md new file mode 100644 index 0000000000..a7d84b0136 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/docs/first-run.md @@ -0,0 +1,68 @@ +# Onboarding flow + +Onboarding flow comprises of entire flow users have after Firefox has successfully been installed or upgraded. + +For new users, the first instance of new tab shows relevant messaging on about:welcome. For existing users, an upgrade dialog with release highlights is shown on major release upgrades. + + +## New User Onboarding + +A full-page multistep experience that shows up on first run since Fx80 with `browser.aboutwelcome.enabled` pref as `true`. Setting `browser.aboutwelcome.enabled` to `false` takes user to about:newtab and hides about:welcome. + +### Default values + +Multistage proton onboarding experience is live since Fx89 and its major variations are: + +#### Zero onboarding + +No about:welcome experience is shown (users see about:newtab during first run). + +Testing instructions: Set `browser.aboutwelcome.enabled` to `false` in about:config + +#### Proton + +A full-page multistep experience that shows a large splash screen and several subsequent screens. See [Default experience variations](#default-experience-variations) for more information. + +#### Return to AMO (RTAMO) + +Special custom onboarding experience shown to users when they try to download an addon from addons.mozilla.org but don’t have Firefox installed. This experience allows them to install the addon they were trying to install directly from a button on [RTAMO](https://docs.google.com/document/d/1QOJ8P0xQbdynAmEzOIx8I5qARwA-VqmOMpHzK9h9msg/edit?usp=sharing). + +Note that this uses [attribution data](https://docs.google.com/document/d/1zB5zwiyNVOiTD4I3aZ-Wm8KFai9nnWuRHsPg-NW4tcc/edit#heading=h.szk066tfte4n) added to the browser during the download process, which is only currently implemented for Windows. + +Testing instructions: +- Set pref browser.newtabpage.activity-stream.asrouter.devtoolsEnabled as true +- Open about:newtab#devtools +- Click Targeting -> Attribution -> Force Attribution +- Open about:welcome, should display RTAMO page + +### General capabilities +- Run experiments and roll-outs through Nimbus (see [FeatureManifests](https://searchfox.org/mozilla-central/rev/5e955a47c4af398e2a859b34056017764e7a2252/toolkit/components/nimbus/FeatureManifest.js#56)), only windows is supported. FeatureConfig (from prefs or experiments) has higher precedence to defaults. See [Default experience variations](#default-experience-variations) +- AboutWelcomeDefaults methods [getDefaults](https://searchfox.org/mozilla-central/rev/81c32a2ea5605c5cb22bd02d28c362c140b5cfb4/browser/components/newtab/aboutwelcome/lib/AboutWelcomeDefaults.jsm#539) and [prepareContentForReact](https://searchfox.org/mozilla-central/rev/81c32a2ea5605c5cb22bd02d28c362c140b5cfb4/browser/components/newtab/aboutwelcome/lib/AboutWelcomeDefaults.jsm#566) have dynamic rules which are applied to both experiments and default UI before content is shown to user. +- about:welcome only shows up for users who download Firefox Beta or release (currently not enabled on Nightly) +- [Enterprise builds](https://searchfox.org/mozilla-central/rev/5e955a47c4af398e2a859b34056017764e7a2252/browser/components/enterprisepolicies/Policies.jsm#1385) can turn off about:welcome by setting the browser.aboutwelcome.enabled preference to false. + +### Default experience variations +In order of precedence: +- Has AMO attribution + - Return to AMO +- Experiments +- Defaults + - Proton default content with below screens + - Welcome Screen with option to 'Pin Firefox', 'Set default' or 'Get Started' + - Import screen allows user to import password, bookmarks and browsing history from previous browser. + - Set a theme lets users personalize Firefox with a theme. + +## Upgrade Dialog +Upgrade Dialog was first introduced in Fx89 with MR1 release. It replaces whatsnew tab with an upgrade modal explaining proton changes, setting Firefox as default and/or pinning, and allowing theme change. + +### Feature Details: +- Hides whatsnew tab on release channel when Upgrade Modal is shown +- Modal dialog appears on major version upgrade to 89 for MR1 + - It’s a window modal preventing access to tabs and other toolbar UI +- Support desired content and actions on each screen. For MR1 initial screen explains proton changes, highlight option to set Firefox as default and pin. Subsequent screen allows theme changes. + +### Testing Instructions: +- In about:config, set: + - `browser.startup.homepage_override.mstone` to `88.0` . The dialog only shows after it detects a major upgrade and need to set to 88 to trigger MR1 upgrade dialog. + - Ensure pref `browser.startup.upgradeDialog.version` is empty. After the dialog shows, `browser.startup.upgradeDialog.version` remembers what version of the dialog to avoid reshowing. +- Restart Firefox diff --git a/browser/components/newtab/content-src/asrouter/docs/index.rst b/browser/components/newtab/content-src/asrouter/docs/index.rst new file mode 100644 index 0000000000..58241ef0d7 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/docs/index.rst @@ -0,0 +1,104 @@ +================ +Messaging System +================ + +Vision +------ +Firefox must be an opinionated user agent that keeps folks safe, informed and +effective while browsing the Web. In order to have an opinion, Firefox must +have a voice. + +That voice will **respect the user’s attention** while surfacing contextually +relevant and timely information tailored to their individual needs and choices. + +What does Messaging System support? +----------------------------------- +There are several key windows of opportunity, such as the first-run activation +phase or coordinated feature releases, where Firefox engages with users. + +The Firefox Messaging System supports this engagement by targeting messages +exactly to the users who need to see them and enables the development of new +user messages that can be easily tested and deployed. It offers standard +mechanisms to measure user engagement and to perform user messaging experiments +with reduced effort across engineering teams and a faster delivery cycle from +ideation to analysis of results. + +This translates to **users seeing fewer and more relevant in-product +messages**, while supporting fast delivery, experimentation, and protection of +our users time and attention. + +Messaging System Overview +------------------------- +At the core of the Firefox Messaging System is the Messaging System Router +(called ASRouter for historical reasons). The router is a generalized Firefox +component and set of conventions that provides: + +* Flexible and configurable routing of local or remote Messages to UI + Templates. This allows new message campaigns to be started and controlled + on or off-trains +* Traffic Cop message sequencing and intermediation to prevent multiple + messages being concurrently shown +* Programmable message targeting language to show the right message to the + right user at the right time +* A template library of reusable Message and Notification UIs +* Full compatibility with Normandy pref-flip experiments +* Generalized and privacy conscious event telemetry +* Flexible Frequency Capping to mitigate user message fatigue +* Localized off train Messages +* Powerful development/debugging/QA tools on about:newtab#devtools + +Message Routing +--------------- +.. image:: ./message-routing-overview.png + :align: center + :alt: Message Routing Overview + +The Firefox Messaging System implements a separation-of-concerns pattern for +Messages, UI Templates, and Timing/Targeting mechanisms. This allows us to +maintain a high standard of security and quality while still allowing for +maximum flexibility around content creation. + + +UI Templates +------------ +We have built a library of reusable Notification and Message interfaces which +land in the Firefox codebase and ride the trains. These templates have a +defined schema according to the available design components (e.g. titles, text, +icons) and access to a set of enhanced user actions such as triggering URLs, +launching menus, or installing addons, which can be attached to interactive +elements (such as buttons). + +Current templates include\: + +* Moments Page - appears on start-up as a full content page +* Contextual Feature Recommendation - highlighted word in the Location Bar + that, if clicked, drops down a panel with information about a feature + relevant to that user at that time +* About Welcome - shown on startup for new users in about:welcome content page +* Snippets - short messages that appear on New Tab Page to highlight products, + features and initiatives +* Infobars - Shown at the top of browser content area these can be per tab + (switching tabs hides it) or global (persistent across tabs) +* Spotlight - This is a window level modal, all other interactions are prevented, + the user is given a primary and a secondary button to interact with the modal. +* PrivateBrowsing - Message shown inside about:PrivateBrowsing content page + +Detailed Docs +------------- + +* Read more about `trigger listeners and user action schemas`__. + +.. __: /toolkit/components/messaging-system/docs + +.. In theory, we ought to be able to use the :glob: directive here to +.. automatically generate the list below. For unknown reasons, however, +.. `mach doc` _sometimes_ gets confused and refuses to find patterns like +.. `*.md`. +.. toctree:: + :maxdepth: 2 + + simple-cfr-template + debugging-docs + first-run + targeting-attributes + targeting-guide diff --git a/browser/components/newtab/content-src/asrouter/docs/message-routing-overview.png b/browser/components/newtab/content-src/asrouter/docs/message-routing-overview.png Binary files differnew file mode 100644 index 0000000000..0ec2ec3c14 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/docs/message-routing-overview.png diff --git a/browser/components/newtab/content-src/asrouter/docs/simple-cfr-template.rst b/browser/components/newtab/content-src/asrouter/docs/simple-cfr-template.rst new file mode 100644 index 0000000000..a1edf4cc8a --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/docs/simple-cfr-template.rst @@ -0,0 +1,37 @@ +Simple CFR Template +-------------------- + +The “Simple CFR Template” is a two-stage UI (a chiclet notification and a door-hanger) +that shows up on a configurable `trigger condition`__, such as when the user visits a +particular web page. + +.. __: /toolkit/components/messaging-system/docs/TriggerActionSchemas + +Warning! Before reading, you should consider whether a `Messaging Experiment is relevant for your needs`__. + +.. __: https://docs.google.com/document/d/1S45a_nFn8QRM8gvsxCM6HHROrIQlQQl6fUlJ2j63PGI/edit + +.. image:: ./cfr_doorhanger_screenshot.png + :align: center + :alt: Simple CFR Template 2 stage + +Doorhanger Configuration +========================= + +Stage 1 – Chiclet +++++++++++++++++++ + +* **chiclet_label**: The text that shows up in the chiclet. 20 characters max. +* **chiclet_color**: The background color of the chiclet as a HEX code. + + +Stage 2 – Door-hanger +++++++++++++++++++++++ + +* **title**: Title text at the top of the door hanger. +* **body**: A longer paragraph of text. +* **icon**: An image (please provide a URL or the image file up to 96x96px). +* **primary_button_label**: The label of the button. +* **primary_button_action**: The special action triggered by clicking on the button. Choose any of the available `button actions`__. Common examples include opening a section of about:preferences, or opening a URL. + +.. __: /toolkit/components/messaging-system/docs/SpecialMessageActionSchemas diff --git a/browser/components/newtab/content-src/asrouter/docs/targeting-attributes.md b/browser/components/newtab/content-src/asrouter/docs/targeting-attributes.md new file mode 100644 index 0000000000..8fadcea0fd --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/docs/targeting-attributes.md @@ -0,0 +1,986 @@ +# Targeting attributes + +When you create ASRouter messages such as snippets, contextual feature recommendations, or onboarding cards, you may choose to include **targeting information** with those messages. + +Targeting information must be captured in [an expression](./targeting-guide.md) that has access to the following attributes. You may combine and compare any of these attributes as needed. + +Please note that some targeting attributes require stricter controls on the telemetry than can be colleted, so when in doubt, ask for review. + +## Available attributes + +* [activeNotifications](#activenotifications) +* [addonsInfo](#addonsinfo) +* [addressesSaved](#addressessaved) +* [attachedFxAOAuthClients](#attachedfxaoauthclients) +* [attributionData](#attributiondata) +* [backgroundTaskName](#backgroundtaskname) +* [blockedCountByType](#blockedcountbytype) +* [browserSettings](#browsersettings) +* [colorwaysActive](#colorwaysActive) +* [creditCardsSaved](#creditcardssaved) +* [currentDate](#currentdate) +* [defaultPDFHandler](#defaultpdfhandler) +* [devToolsOpenedCount](#devtoolsopenedcount) +* [distributionId](#distributionId) +* [doesAppNeedPin](#doesappneedpin) +* [doesAppNeedPrivatePin](#doesappneedprivatepin) +* [firefoxVersion](#firefoxversion) +* [fxViewButtonAreaType](#fxviewbuttonareatype) +* [hasAccessedFxAPanel](#hasaccessedfxapanel) +* [hasActiveEnterprisePolicies](#hasactiveenterprisepolicies) +* [hasMigratedBookmarks](#hasmigratedbookmarks) +* [hasMigratedHistory](#hasmigratedhistory) +* [hasMigratedPasswords](#hasmigratedpasswords) +* [hasPinnedTabs](#haspinnedtabs) +* [homePageSettings](#homepagesettings) +* [inMr2022Holdback](#inmr2022holdback) +* [isBackgroundTaskMode](#isbackgroundtaskmode) +* [isChinaRepack](#ischinarepack) +* [isDefaultBrowser](#isdefaultbrowser) +* [isDefaultHandler](#isdefaulthandler) +* [isDeviceMigration](#isdevicemigration) +* [isFxAEnabled](#isfxaenabled) +* [isFxASignedIn](#isFxASignedIn) +* [isMajorUpgrade](#ismajorupgrade) +* [isRTAMO](#isrtamo) +* [isWhatsNewPanelEnabled](#iswhatsnewpanelenabled) +* [locale](#locale) +* [localeLanguageCode](#localelanguagecode) +* [messageImpressions](#messageimpressions) +* [needsUpdate](#needsupdate) +* [newtabSettings](#newtabsettings) +* [pinnedSites](#pinnedsites) +* [platformName](#platformname) +* [previousSessionEnd](#previoussessionend) +* [profileAgeCreated](#profileagecreated) +* [profileAgeReset](#profileagereset) +* [profileRestartCount](#profilerestartcount) +* [providerCohorts](#providercohorts) +* [recentBookmarks](#recentbookmarks) +* [region](#region) +* [screenImpressions](#screenImpressions) +* [searchEngines](#searchengines) +* [sync](#sync) +* [topFrecentSites](#topfrecentsites) +* [totalBlockedCount](#totalblockedcount) +* [totalBookmarksCount](#totalbookmarkscount) +* [userEnabledActiveColorway](#userEnabledActiveColorway) +* [userId](#userid) +* [userMonthlyActivity](#usermonthlyactivity) +* [userPrefersReducedMotion](#userprefersreducedmotion) +* [useEmbeddedMigrationWizard][#useembeddedmigrationwizard] +* [userPrefs](#userprefs) +* [usesFirefoxSync](#usesfirefoxsync) +* [xpinstallEnabled](#xpinstallEnabled) + +## Detailed usage + +### `addonsInfo` +Provides information about the add-ons the user has installed. + +Note that the `name`, `userDisabled`, and `installDate` is only available if `isFullData` is `true` (this is usually not the case right at start-up). + +**Due to an existing bug, `userDisabled` is not currently available** + +#### Examples +* Has the user installed the unicorn addon? +```java +addonsInfo.addons["unicornaddon@mozilla.org"] +``` + +* Has the user installed and disabled the unicorn addon? +```java +addonsInfo.isFullData && addonsInfo.addons["unicornaddon@mozilla.org"].userDisabled +``` + +#### Definition +```ts +declare const addonsInfo: Promise<AddonsInfoResponse>; +interface AddonsInfoResponse { + // Does this include extra information requiring I/O? + isFullData: boolean; + // addonId should be something like activity-stream@mozilla.org + [addonId: string]: { + // Version of the add-on + version: string; + // (string) e.g. "extension" + type: AddonType; + // Version of the add-on + isSystem: boolean; + // Is the add-on a webextension? + isWebExtension: boolean; + // The name of the add-on + name: string; + // Is the add-on disabled? + // CURRENTLY UNAVAILABLE due to an outstanding bug + userDisabled: boolean; + // When was it installed? e.g. "2018-03-10T03:41:06.000Z" + installDate: string; + }; +} +``` +### `attributionData` + +An object containing information on exactly how Firefox was downloaded + +#### Examples +* Was the browser installed via the `"back_to_school"` campaign? +```java +attributionData && attributionData.campaign == "back_to_school" +``` + +#### Definition +```ts +declare const attributionData: AttributionCode; +interface AttributionCode { + // Descriptor for where the download started from + campaign: string, + // A source, like addons.mozilla.org, or google.com + source: string, + // The medium for the download, like if this was referral + medium: string, + // Additional content, like an addonID for instance + content: string +} +``` + +### `browserSettings` + +* `update`, which has information about Firefox update channel + +#### Examples + +* Is updating enabled? +```java +browserSettings.update.enabled +``` + +* Is beta channel? +```js +browserSettings.update.channel == 'beta' +``` + +#### Definition + +```ts +declare const browserSettings: { + attribution: undefined | { + // Referring partner domain, when install happens via a known partner + // e.g. google.com + source: string; + // category of the source, such as "organic" for a search engine + // e.g. organic + medium: string; + // identifier of the particular campaign that led to the download of the product + // e.g. back_to_school + campaign: string; + // identifier to indicate the particular link within a campaign + // e.g. https://mozilla.org/some-page + content: string; + }, + update: { + // Is auto-downloading enabled? + autoDownload: boolean; + // What release channel, e.g. "nightly" + channel: string; + // Is updating enabled? + enabled: boolean; + } +} +``` + +### `currentDate` + +The current date at the moment message targeting is checked. + +#### Examples +* Is the current date after Oct 3, 2018? +```java +currentDate > "Wed Oct 03 2018 00:00:00"|date +``` + +#### Definition + +```ts +declare const currentDate; ECMA262DateString; +// ECMA262DateString = Date.toString() +type ECMA262DateString = string; +``` + +### `devToolsOpenedCount` +Number of usages of the web console. + +#### Examples +* Has the user opened the web console more than 10 times? +```java +devToolsOpenedCount > 10 +``` + +#### Definition +```ts +declare const devToolsOpenedCount: number; +``` + +### `isDefaultBrowser` + +Is Firefox the user's default browser? + +#### Definition + +```ts +declare const isDefaultBrowser: boolean; +``` + +### `isDefaultHandler` + +Is Firefox the user's default handler for various file extensions? + +Windows-only. + +#### Definition + +```ts +declare const isDefaultHandler: { + pdf: boolean; + html: boolean; +}; +``` + +#### Examples +* Is Firefox the default PDF handler? +```ts +isDefaultHandler.pdf +``` + +### `defaultPDFHandler` + +Information about the user's default PDF handler + +Windows-only. + +#### Definition + +```ts +declare const defaultPDFHandler: { + // Does the user have a default PDF handler registered? + registered: boolean; + + // Is the default PDF handler a known browser? + knownBrowser: boolean; +}; +``` + +### `firefoxVersion` + +The major Firefox version of the browser + +#### Examples +* Is the version of the browser greater than 63? +```java +firefoxVersion > 63 +``` + +#### Definition + +```ts +declare const firefoxVersion: number; +``` + +### `locale` +The current locale of the browser including country code, e.g. `en-US`. + +#### Examples +* Is the locale of the browser either English (US) or German (Germany)? +```java +locale in ["en-US", "de-DE"] +``` + +#### Definition +```ts +declare const locale: string; +``` + +### `localeLanguageCode` +The current locale of the browser NOT including country code, e.g. `en`. +This is useful for matching all countries of a particular language. + +#### Examples +* Is the locale of the browser any English locale? +```java +localeLanguageCode == "en" +``` + +#### Definition +```ts +declare const localeLanguageCode: string; +``` + +### `needsUpdate` + +Does the client have the latest available version installed + +```ts +declare const needsUpdate: boolean; +``` + +### `pinnedSites` +The sites (including search shortcuts) that are pinned on a user's new tab page. + +#### Examples +* Has the user pinned any site on `foo.com`? +```java +"foo.com" in pinnedSites|mapToProperty("host") +``` + +* Does the user have a pinned `duckduckgo.com` search shortcut? +```java +"duckduckgo.com" in pinnedSites[.searchTopSite == true]|mapToProperty("host") +``` + +#### Definition +```ts +interface PinnedSite { + // e.g. https://foo.mozilla.com/foo/bar + url: string; + // e.g. foo.mozilla.com + host: string; + // is the pin a search shortcut? + searchTopSite: boolean; +} +declare const pinnedSites: Array<PinnedSite> +``` + +### `previousSessionEnd` + +Timestamp of the previously closed session. + +#### Definition +```ts +declare const previousSessionEnd: UnixEpochNumber; +// UnixEpochNumber is UNIX Epoch timestamp, e.g. 1522843725924 +type UnixEpochNumber = number; +``` + +### `profileAgeCreated` + +The date the profile was created as a UNIX Epoch timestamp. + +#### Definition + +```ts +declare const profileAgeCreated: UnixEpochNumber; +// UnixEpochNumber is UNIX Epoch timestamp, e.g. 1522843725924 +type UnixEpochNumber = number; +``` + +### `profileAgeReset` + +The date the profile was reset as a UNIX Epoch timestamp (if it was reset). + +#### Examples +* Was the profile never reset? +```java +!profileAgeReset +``` + +#### Definition +```ts +// profileAgeReset can be undefined if the profile was never reset +// UnixEpochNumber is number, e.g. 1522843725924 +declare const profileAgeReset: undefined | UnixEpochNumber; +// UnixEpochNumber is UNIX Epoch timestamp, e.g. 1522843725924 +type UnixEpochNumber = number; +``` + +### `providerCohorts` + +Information about cohort settings (from prefs, including shield studies) for each provider. + +#### Examples +* Is the user in the "foo_test" cohort for snippets? +```java +providerCohorts.snippets == "foo_test" +``` + +#### Definition + +```ts +declare const providerCohorts: { + [providerId: string]: string; +} +``` + +### `region` + +Country code retrieved from `location.services.mozilla.com`. Can be `""` if request did not finish or encountered an error. + +#### Examples +* Is the user in Canada? +```java +region == "CA" +``` + +#### Definition + +```ts +declare const region: string; +``` + +### `searchEngines` + +Information about the current and available search engines. + +#### Examples +* Is the current default search engine set to google? +```java +searchEngines.current == "google" +``` + +#### Definition + +```ts +declare const searchEngines: Promise<SearchEnginesResponse>; +interface SearchEnginesResponse: { + current: SearchEngineId; + installed: Array<SearchEngineId>; +} +// This is an identifier for a search engine such as "google" or "amazondotcom" +type SearchEngineId = string; +``` + +### `sync` + +Information about synced devices. + +#### Examples +* Is at least 1 mobile device synced to this profile? +```java +sync.mobileDevices > 0 +``` + +#### Definition + +```ts +declare const sync: { + desktopDevices: number; + mobileDevices: number; + totalDevices: number; +} +``` + +### `topFrecentSites` + +Information about the browser's top 25 frecent sites. + +**Please note this is a restricted targeting property that influences what telemetry is allowed to be collected may not be used without review** + + +#### Examples +* Is mozilla.com in the user's top frecent sites with a frececy greater than 400? +```java +"mozilla.com" in topFrecentSites[.frecency >= 400]|mapToProperty("host") +``` + +#### Definition +```ts +declare const topFrecentSites: Promise<Array<TopSite>> +interface TopSite { + // e.g. https://foo.mozilla.com/foo/bar + url: string; + // e.g. foo.mozilla.com + host: string; + frecency: number; + lastVisitDate: UnixEpochNumber; +} +// UnixEpochNumber is UNIX Epoch timestamp, e.g. 1522843725924 +type UnixEpochNumber = number; +``` + +### `totalBookmarksCount` + +Total number of bookmarks. + +#### Definition + +```ts +declare const totalBookmarksCount: number; +``` + +### `usesFirefoxSync` + +Does the user use Firefox sync? + +#### Definition + +```ts +declare const usesFirefoxSync: boolean; +``` + +### `isFxAEnabled` + +Does the user have Firefox sync enabled? The service could potentially be turned off [for enterprise builds](https://searchfox.org/mozilla-central/rev/b59a99943de4dd314bae4e44ab43ce7687ccbbec/browser/components/enterprisepolicies/Policies.jsm#327). + +#### Definition + +```ts +declare const isFxAEnabled: boolean; +``` + +### `isFxASignedIn` + +Is the user signed in to a Firefox Account? + +#### Definition + +```ts +declare const isFxASignedIn: Promise<boolean> +``` + +### `creditCardsSaved` + +The number of credit cards the user has saved for Forms and Autofill. + +#### Examples +```java +creditCardsSaved > 1 +``` + +#### Definition + +```ts +declare const creditCardsSaved: Promise<number> +``` + +### `addressesSaved` + +The number of addresses the user has saved for Forms and Autofill. + +#### Examples +```java +addressesSaved > 1 +``` + +#### Definition + +```ts +declare const addressesSaved: Promise<number> +``` + +### `xpinstallEnabled` + +Pref used by system administrators to disallow add-ons from installed altogether. + +#### Definition + +```ts +declare const xpinstallEnabled: boolean; +``` + +### `hasPinnedTabs` + +Does the user have any pinned tabs in any windows. + +#### Definition + +```ts +declare const hasPinnedTabs: boolean; +``` + +### `hasAccessedFxAPanel` + +Boolean pref that gets set the first time the user opens the FxA toolbar panel + +#### Definition + +```ts +declare const hasAccessedFxAPanel: boolean; +``` + +### `isWhatsNewPanelEnabled` + +Boolean pref that controls if the What's New panel feature is enabled + +#### Definition + +```ts +declare const isWhatsNewPanelEnabled: boolean; +``` + +### `totalBlockedCount` + +Total number of events from the content blocking database + +#### Definition + +```ts +declare const totalBlockedCount: number; +``` + +### `recentBookmarks` + +An array of GUIDs of recent bookmarks as provided by [`NewTabUtils.getRecentBookmarks`](https://searchfox.org/mozilla-central/rev/c5c002f81f08a73e04868e0c2bf0eb113f200b03/toolkit/modules/NewTabUtils.sys.mjs#1059) + +#### Definition + +```ts +interface Bookmark { + bookmarkGuid: string; + url: string; + title: string; + ... +} +declare const recentBookmarks: Array<Bookmark> +``` + +### `userPrefs` + +Information about user facing prefs configurable from `about:preferences`. + +#### Examples +```java +userPrefs.cfrFeatures == false +``` + +#### Definition + +```ts +declare const userPrefs: { + cfrFeatures: boolean; + cfrAddons: boolean; + snippets: boolean; +} +``` + +### `attachedFxAOAuthClients` + +Information about connected services associated with the FxA Account. +Return an empty array if no account is found or an error occurs. + +#### Definition + +``` +interface OAuthClient { + // OAuth client_id of the service + // https://docs.telemetry.mozilla.org/datasets/fxa_metrics/attribution.html#service-attribution + id: string; + lastAccessedDaysAgo: number; +} + +declare const attachedFxAOAuthClients: Promise<OAuthClient[]> +``` + +#### Examples +```javascript +{ + id: "7377719276ad44ee", + name: "Pocket", + lastAccessTime: 1513599164000 +} +``` + +### `platformName` + +[Platform information](https://searchfox.org/mozilla-central/rev/c5c002f81f08a73e04868e0c2bf0eb113f200b03/toolkit/modules/AppConstants.sys.mjs#153). + +#### Definition + +``` +declare const platformName = "linux" | "win" | "macosx" | "android" | "other"; +``` + +### `messageImpressions` + +Dictionary that maps message ids to impression timestamps. Timestamps are stored in +consecutive order. Can be used to detect first impression of a message, number of +impressions. Can be used in targeting to show a message if another message has been +seen. +Impressions are used for frequency capping so we only store them if the message has +`frequency` configured. +Impressions for badges might not work as expected: we add a badge for every opened +window so the number of impressions stored might be higher than expected. Additionally +not all badges have `frequency` cap so `messageImpressions` might not be defined. +Badge impressions should not be used for targeting. + +#### Definition + +``` +declare const messageImpressions: { [key: string]: Array<UnixEpochNumber> }; +``` + +### `blockedCountByType` + +Returns a breakdown by category of all blocked resources in the past 42 days. + +#### Definition + +``` +declare const messageImpressions: { [key: string]: number }; +``` + +#### Examples + +```javascript +Object { + trackerCount: 0, + cookieCount: 34, + cryptominerCount: 0, + fingerprinterCount: 3, + socialCount: 2 +} +``` + +### `isChinaRepack` + +Does the user use [the partner repack distributed by Mozilla Online](https://github.com/mozilla-partners/mozillaonline), +a wholly owned subsidiary of the Mozilla Corporation that operates in China. + +#### Definition + +```ts +declare const isChinaRepack: boolean; +``` + +### `userId` + +A unique user id generated by Normandy (note that this is not clientId). + +#### Definition + +```ts +declare const userId: string; +``` + +### `profileRestartCount` + +A session counter that shows how many times the browser was started. +More info about the details in [the telemetry docs](https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/concepts/sessions.html). + +#### Definition + +```ts +declare const profileRestartCount: number; +``` + +### `homePageSettings` + +An object reflecting the current settings of the browser home page (about:home) + +#### Definition + +```ts +declare const homePageSettings: { + isDefault: boolean; + isLocked: boolean; + isWebExt: boolean; + isCustomUrl: boolean; + urls: Array<URL>; +} + +interface URL { + url: string; + host: string; +} +``` + +#### Examples + +* Default about:home +```javascript +Object { + isDefault: true, + isLocked: false, + isCustomUrl: false, + isWebExt: false, + urls: [ + { url: "about:home", host: "" } + ], +} +``` + +* Default about:home with locked preference +```javascript +Object { + isDefault: true, + isLocked: true, + isCustomUrl: false, + isWebExt: false, + urls: [ + { url: "about:home", host: "" } + ], +} +``` + +* Custom URL +```javascript +Object { + isDefault: false, + isLocked: false, + isCustomUrl: true, + isWebExt: false, + urls: [ + { url: "https://www.google.com", host: "google.com" } + ], +} +``` + +* Custom URLs +```javascript +Object { + isDefault: false, + isLocked: false, + isCustomUrl: true, + isWebExt: false, + urls: [ + { url: "https://www.google.com", host: "google.com" }, + { url: "https://www.youtube.com", host: "youtube.com" } + ], +} +``` + +* Web extension +```javascript +Object { + isDefault: false, + isLocked: false, + isCustomUrl: false, + isWebExt: true, + urls: [ + { url: "moz-extension://123dsa43213acklncd/home.html", host: "" } + ], +} +``` + +### `newtabSettings` + +An object reflecting the current settings of the browser newtab page (about:newtab) + +#### Definition + +```ts +declare const newtabSettings: { + isDefault: boolean; + isWebExt: boolean; + isCustomUrl: boolean; + url: string; + host: string; +} +``` + +#### Examples + +* Default about:newtab +```javascript +Object { + isDefault: true, + isCustomUrl: false, + isWebExt: false, + url: "about:newtab", + host: "", +} +``` + +* Custom URL +```javascript +Object { + isDefault: false, + isCustomUrl: true, + isWebExt: false, + url: "https://www.google.com", + host: "google.com", +} +``` + +* Web extension +```javascript +Object { + isDefault: false, + isCustomUrl: false, + isWebExt: true, + url: "moz-extension://123dsa43213acklncd/home.html", + host: "", +} +``` + +### `activeNotifications` + +True when an infobar style message is displayed or when the awesomebar is +expanded to show a message (for example onboarding tips). + +### `isMajorUpgrade` + +A boolean. `true` if the browser just updated to a new major version. + +### `hasActiveEnterprisePolicies` + +A boolean. `true` if any Enterprise Policies are active. + +### `userMonthlyActivity` + +Returns an array of entries in the form `[int, unixTimestamp]` for each day of +user activity where the first entry is the total urls visited for that day. + +### `doesAppNeedPin` + +Checks if Firefox app can be and isn't pinned to OS taskbar/dock. + +### `doesAppNeedPrivatePin` + +Checks if Firefox Private Browsing Mode can be and isn't pinned to OS taskbar/dock. Currently this only works on certain Windows versions. + +### `isBackgroundTaskMode` + +Checks if this invocation is running in background task mode. + +### `backgroundTaskName` + +A non-empty string task name if this invocation is running in background task +mode, or `null` if this invocation is not running in background task mode. + +### `userPrefersReducedMotion` + +Checks if user prefers reduced motion as indicated by the value of a media query for `prefers-reduced-motion`. + +### `inMr2022Holdback` + +A boolean. `true` when the user is in the Major Release 2022 holdback study. + +### `distributionId` + +A string containing the id of the distribution, or the empty string if there +is no distribution associated with the build. + +### `fxViewButtonAreaType` + +A string of the name of the container where the Firefox View button is shown, null if the button has been removed. + +### `hasMigratedBookmarks` + +A boolean. `true` if the user ever used the Migration Wizard to migrate bookmarks. + +### `hasMigratedHistory` + +A boolean. `true` if the user ever used the Migration Wizard to migrate history. + +### `hasMigratedPasswords` + +A boolean. `true` if the user ever used the Migration Wizard to migrate passwords. + +### `useEmbeddedMigrationWizard` + +A boolean. `true` if the user is configured to use the embedded Migration Wizard in about:welcome. + +### `isRTAMO` + +A boolean. `true` when [RTAMO](first-run.md#return-to-amo-rtamo) has been used to download Firefox, `false` otherwise. + +### `isDeviceMigration` + +A boolean. `true` when [support.mozilla.org](https://support.mozilla.org) has been used to download the browser as part of a "migration" campaign, for device migration guidance, `false` otherwise. +### `screenImpressions` + +An array that maps about:welcome screen IDs to their most recent impression timestamp. Should only be used for unique screen IDs to avoid unintentionally targeting messages with identical screen IDs. +#### Definition + +``` +declare const screenImpressions: { [key: string]: Array<UnixEpochNumber> }; +``` diff --git a/browser/components/newtab/content-src/asrouter/docs/targeting-guide.md b/browser/components/newtab/content-src/asrouter/docs/targeting-guide.md new file mode 100644 index 0000000000..3172cece81 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/docs/targeting-guide.md @@ -0,0 +1,37 @@ +# Guide to targeting with JEXL + +For a more in-depth explanation of JEXL syntax you can read the [Normady project docs](https://mozilla.github.io/normandy/user/filters.html?highlight=jexl). + +## How to write JEXL targeting expressions +A message needs to contain the `targeting` property (JEXL string) which is evaluated against the provided attributes. +Examples: + +```javascript +{ + "id": "7864", + "content": {...}, + // simple equality check + "targeting": "usesFirefoxSync == true" +} + +{ + "id": "7865", + "content": {...}, + // using JEXL transforms and combining two attributes + "targeting": "usesFirefoxSync == true && profileAgeCreated > '2018-01-07'|date" +} + +{ + "id": "7866", + "content": {...}, + // targeting addon information + "targeting": "addonsInfo.addons['activity-stream@mozilla.org'].name == 'Activity Stream'" +} + +{ + "id": "7866", + "content": {...}, + // targeting based on time + "targeting": "currentDate > '2018-08-08'|date" +} +``` diff --git a/browser/components/newtab/content-src/asrouter/docs/telemetry-screenshot.png b/browser/components/newtab/content-src/asrouter/docs/telemetry-screenshot.png Binary files differnew file mode 100644 index 0000000000..b27b4ab958 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/docs/telemetry-screenshot.png diff --git a/browser/components/newtab/content-src/asrouter/rich-text-strings.js b/browser/components/newtab/content-src/asrouter/rich-text-strings.js new file mode 100644 index 0000000000..3bcba7c386 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/rich-text-strings.js @@ -0,0 +1,44 @@ +/* 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 { FluentBundle, FluentResource } from "@fluent/bundle"; + +/** + * Properties that allow rich text MUST be added to this list. + * key: the localization_id that should be used + * value: a property or array of properties on the message.content object + */ +const RICH_TEXT_CONFIG = { + text: ["text", "scene1_text"], + success_text: "success_text", + error_text: "error_text", + scene2_text: "scene2_text", + amo_html: "amo_html", + privacy_html: "scene2_privacy_html", + disclaimer_html: "scene2_disclaimer_html", +}; + +export const RICH_TEXT_KEYS = Object.keys(RICH_TEXT_CONFIG); + +/** + * Generates an array of messages suitable for fluent's localization provider + * including all needed strings for rich text. + * @param {object} content A .content object from an ASR message (i.e. message.content) + * @returns {FluentBundle[]} A array containing the fluent message context + */ +export function generateBundles(content) { + const bundle = new FluentBundle("en-US"); + + RICH_TEXT_KEYS.forEach(key => { + const attrs = RICH_TEXT_CONFIG[key]; + const attrsToTry = Array.isArray(attrs) ? [...attrs] : [attrs]; + let string = ""; + while (!string && attrsToTry.length) { + const attr = attrsToTry.pop(); + string = content[attr]; + } + bundle.addResource(new FluentResource(`${key} = ${string}`)); + }); + return [bundle]; +} diff --git a/browser/components/newtab/content-src/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json b/browser/components/newtab/content-src/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json new file mode 100644 index 0000000000..109a9d6cd1 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json @@ -0,0 +1,312 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "resource://activity-stream/schemas/BackgroundTaskMessagingExperiment.schema.json", + "title": "Messaging Experiment", + "description": "A Firefox Messaging System message.", + "if": { + "type": "object", + "properties": { + "template": { + "const": "multi" + } + }, + "required": [ + "template" + ] + }, + "then": { + "$ref": "resource://activity-stream/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/MultiMessage" + }, + "else": { + "$ref": "resource://activity-stream/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/TemplatedMessage" + }, + "$defs": { + "ToastNotification": { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///ToastNotification.schema.json", + "title": "ToastNotification", + "description": "A template for toast notifications displayed by the Alert service.", + "allOf": [ + { + "$ref": "resource://activity-stream/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/Message" + } + ], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "title": { + "$ref": "resource://activity-stream/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of toast notification title" + }, + "body": { + "$ref": "resource://activity-stream/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of toast notification body" + }, + "icon_url": { + "description": "The URL of the image used as an icon of the toast notification.", + "type": "string", + "format": "moz-url-format" + }, + "image_url": { + "description": "The URL of an image to be displayed as part of the notification.", + "type": "string", + "format": "moz-url-format" + }, + "launch_url": { + "description": "The URL to launch when the notification or an action button is clicked.", + "type": "string", + "format": "moz-url-format" + }, + "requireInteraction": { + "type": "boolean", + "description": "Whether the toast notification should remain active until the user clicks or dismisses it, rather than closing automatically." + }, + "tag": { + "type": "string", + "description": "An identifying tag for the toast notification." + }, + "data": { + "type": "object", + "description": "Arbitrary data associated with the toast notification." + }, + "actions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "$ref": "resource://activity-stream/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/localizableText", + "description": "The action text to be shown to the user." + }, + "action": { + "type": "string", + "description": "Opaque identifer that identifies action." + }, + "iconURL": { + "type": "string", + "format": "uri", + "description": "URL of an icon to display with the action." + }, + "windowsSystemActivationType": { + "type": "boolean", + "description": "Whether to have Windows process the given `action`." + } + }, + "required": [ + "action", + "title" + ], + "additionalProperties": true + } + } + }, + "additionalProperties": true, + "required": [ + "title", + "body" + ] + }, + "template": { + "type": "string", + "const": "toast_notification" + } + }, + "required": [ + "content", + "targeting", + "template", + "trigger" + ], + "additionalProperties": true + }, + "Message": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The message identifier" + }, + "groups": { + "description": "Array of preferences used to control `enabled` status of the group. If any is `false` the group is disabled.", + "type": "array", + "items": { + "type": "string", + "description": "Preference name" + } + }, + "template": { + "type": "string", + "description": "Which messaging template this message is using.", + "enum": [ + "toast_notification" + ] + }, + "frequency": { + "type": "object", + "description": "An object containing frequency cap information for a message.", + "properties": { + "lifetime": { + "type": "integer", + "description": "The maximum lifetime impressions for a message.", + "minimum": 1, + "maximum": 100 + }, + "custom": { + "type": "array", + "description": "An array of custom frequency cap definitions.", + "items": { + "description": "A frequency cap definition containing time and max impression information", + "type": "object", + "properties": { + "period": { + "type": "integer", + "description": "Period of time in milliseconds (e.g. 86400000 for one day)" + }, + "cap": { + "type": "integer", + "description": "The maximum impressions for the message within the defined period.", + "minimum": 1, + "maximum": 100 + } + }, + "required": [ + "period", + "cap" + ] + } + } + } + }, + "priority": { + "description": "The priority of the message. If there are two competing messages to show, the one with the highest priority will be shown", + "type": "integer" + }, + "order": { + "description": "The order in which messages should be shown. Messages will be shown in increasing order.", + "type": "integer" + }, + "targeting": { + "description": "A JEXL expression representing targeting information", + "type": "string" + }, + "trigger": { + "description": "An action to trigger potentially showing the message", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A string identifying the trigger action" + }, + "params": { + "type": "array", + "description": "An optional array of string parameters for the trigger action", + "items": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + } + } + }, + "required": [ + "id" + ] + }, + "provider": { + "description": "An identifier for the provider of this message, such as \"cfr\" or \"preview\".", + "type": "string" + } + }, + "additionalProperties": true, + "dependentRequired": { + "content": [ + "id", + "template" + ], + "template": [ + "id", + "content" + ] + } + }, + "localizedText": { + "type": "object", + "properties": { + "string_id": { + "description": "Id of localized string to be rendered.", + "type": "string" + } + }, + "required": [ + "string_id" + ] + }, + "localizableText": { + "description": "Either a raw string or an object containing the string_id of the localized text", + "oneOf": [ + { + "type": "string", + "description": "The string to be rendered." + }, + { + "$ref": "resource://activity-stream/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/localizedText" + } + ] + }, + "TemplatedMessage": { + "description": "An FxMS message of one of a variety of types.", + "type": "object", + "allOf": [ + { + "$ref": "resource://activity-stream/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/Message" + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": [ + "toast_notification" + ] + } + }, + "required": [ + "template" + ] + }, + "then": { + "$ref": "resource://activity-stream/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/ToastNotification" + } + } + ] + }, + "MultiMessage": { + "description": "An object containing an array of messages.", + "type": "object", + "properties": { + "template": { + "type": "string", + "const": "multi" + }, + "messages": { + "type": "array", + "description": "An array of messages.", + "items": { + "$ref": "resource://activity-stream/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/TemplatedMessage" + } + } + }, + "required": [ + "template", + "messages" + ] + } + } +} diff --git a/browser/components/newtab/content-src/asrouter/schemas/FxMSCommon.schema.json b/browser/components/newtab/content-src/asrouter/schemas/FxMSCommon.schema.json new file mode 100644 index 0000000000..51dbd3efa6 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/schemas/FxMSCommon.schema.json @@ -0,0 +1,128 @@ +{ + "description": "Common elements used across FxMS schemas", + "$id": "file:///FxMSCommon.schema.json", + "$defs": { + "Message": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The message identifier" + }, + "groups": { + "description": "Array of preferences used to control `enabled` status of the group. If any is `false` the group is disabled.", + "type": "array", + "items": { + "type": "string", + "description": "Preference name" + } + }, + "template": { + "type": "string", + "description": "Which messaging template this message is using." + }, + "frequency": { + "type": "object", + "description": "An object containing frequency cap information for a message.", + "properties": { + "lifetime": { + "type": "integer", + "description": "The maximum lifetime impressions for a message.", + "minimum": 1, + "maximum": 100 + }, + "custom": { + "type": "array", + "description": "An array of custom frequency cap definitions.", + "items": { + "description": "A frequency cap definition containing time and max impression information", + "type": "object", + "properties": { + "period": { + "type": "integer", + "description": "Period of time in milliseconds (e.g. 86400000 for one day)" + }, + "cap": { + "type": "integer", + "description": "The maximum impressions for the message within the defined period.", + "minimum": 1, + "maximum": 100 + } + }, + "required": ["period", "cap"] + } + } + } + }, + "priority": { + "description": "The priority of the message. If there are two competing messages to show, the one with the highest priority will be shown", + "type": "integer" + }, + "order": { + "description": "The order in which messages should be shown. Messages will be shown in increasing order.", + "type": "integer" + }, + "targeting": { + "description": "A JEXL expression representing targeting information", + "type": "string" + }, + "trigger": { + "description": "An action to trigger potentially showing the message", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A string identifying the trigger action" + }, + "params": { + "type": "array", + "description": "An optional array of string parameters for the trigger action", + "items": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + } + } + }, + "required": ["id"] + }, + "provider": { + "description": "An identifier for the provider of this message, such as \"cfr\" or \"preview\".", + "type": "string" + } + }, + "additionalProperties": true, + "dependentRequired": { + "content": ["id", "template"], + "template": ["id", "content"] + } + }, + "localizedText": { + "type": "object", + "properties": { + "string_id": { + "description": "Id of localized string to be rendered.", + "type": "string" + } + }, + "required": ["string_id"] + }, + "localizableText": { + "description": "Either a raw string or an object containing the string_id of the localized text", + "oneOf": [ + { + "type": "string", + "description": "The string to be rendered." + }, + { + "$ref": "#/$defs/localizedText" + } + ] + } + } +} diff --git a/browser/components/newtab/content-src/asrouter/schemas/MessagingExperiment.schema.json b/browser/components/newtab/content-src/asrouter/schemas/MessagingExperiment.schema.json new file mode 100644 index 0000000000..abd36cf6cf --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/schemas/MessagingExperiment.schema.json @@ -0,0 +1,1640 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "resource://activity-stream/schemas/MessagingExperiment.schema.json", + "title": "Messaging Experiment", + "description": "A Firefox Messaging System message.", + "if": { + "type": "object", + "properties": { + "template": { + "const": "multi" + } + }, + "required": [ + "template" + ] + }, + "then": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/MultiMessage" + }, + "else": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/TemplatedMessage" + }, + "$defs": { + "CFRUrlbarChiclet": { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///CFRUrlbarChiclet.schema.json", + "title": "CFRUrlbarChiclet", + "description": "A template with a chiclet button with text.", + "allOf": [ + { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/Message" + } + ], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "category": { + "type": "string", + "description": "Attribute used for different groups of messages from the same provider" + }, + "layout": { + "type": "string", + "description": "Describes how content should be displayed.", + "enum": [ + "chiclet_open_url" + ] + }, + "bucket_id": { + "type": "string", + "description": "A bucket identifier for the addon. This is used in order to anonymize telemetry for history-sensitive targeting." + }, + "notification_text": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "The text in the small blue chicklet that appears in the URL bar. This can be a reference to a localized string in Firefox or just a plain string." + }, + "active_color": { + "type": "string", + "description": "Background color of the button" + }, + "action": { + "type": "object", + "properties": { + "url": { + "description": "The page to open when the button is clicked.", + "type": "string", + "format": "moz-url-format" + }, + "where": { + "description": "Should it open in a new tab or the current tab", + "type": "string", + "enum": [ + "current", + "tabshifted" + ] + } + }, + "additionalProperties": true, + "required": [ + "url", + "where" + ] + } + }, + "additionalProperties": true, + "required": [ + "layout", + "category", + "bucket_id", + "notification_text", + "action" + ] + }, + "template": { + "type": "string", + "const": "cfr_urlbar_chiclet" + } + }, + "required": [ + "targeting", + "trigger" + ] + }, + "ExtensionDoorhanger": { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///ExtensionDoorhanger.schema.json", + "title": "ExtensionDoorhanger", + "description": "A template with a heading, addon icon, title and description. No markup allowed.", + "allOf": [ + { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/Message" + } + ], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "category": { + "type": "string", + "description": "Attribute used for different groups of messages from the same provider" + }, + "layout": { + "type": "string", + "description": "Attribute used for different groups of messages from the same provider", + "enum": [ + "short_message", + "icon_and_message", + "addon_recommendation" + ] + }, + "anchor_id": { + "type": "string", + "description": "A DOM element ID that the pop-over will be anchored." + }, + "alt_anchor_id": { + "type": "string", + "description": "An alternate DOM element ID that the pop-over will be anchored." + }, + "bucket_id": { + "type": "string", + "description": "A bucket identifier for the addon. This is used in order to anonymize telemetry for history-sensitive targeting." + }, + "skip_address_bar_notifier": { + "type": "boolean", + "description": "Skip the 'Recommend' notifier and show directly." + }, + "persistent_doorhanger": { + "type": "boolean", + "description": "Prevent the doorhanger from being dismissed if user interacts with the page or switches between applications." + }, + "show_in_private_browsing": { + "type": "boolean", + "description": "Whether to allow the message to be shown in private browsing mode. Defaults to false." + }, + "notification_text": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "The text in the small blue chicklet that appears in the URL bar. This can be a reference to a localized string in Firefox or just a plain string." + }, + "info_icon": { + "type": "object", + "description": "The small icon displayed in the top right corner of the pop-over. Should be 19x19px, svg or png. Defaults to a small question mark.", + "properties": { + "label": { + "oneOf": [ + { + "type": "object", + "properties": { + "attributes": { + "type": "object", + "properties": { + "tooltiptext": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "Text for button tooltip used to provide information about the doorhanger." + } + }, + "required": [ + "tooltiptext" + ] + } + }, + "required": [ + "attributes" + ] + }, + { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizedText" + } + ] + }, + "sumo_path": { + "type": "string", + "description": "Last part of the path in the URL to the support page with the information about the doorhanger.", + "examples": [ + "extensionpromotions", + "extensionrecommendations" + ] + } + } + }, + "learn_more": { + "type": "string", + "description": "Last part of the path in the SUMO URL to the support page with the information about the doorhanger.", + "examples": [ + "extensionpromotions", + "extensionrecommendations" + ] + }, + "heading_text": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "The larger heading text displayed in the pop-over. This can be a reference to a localized string in Firefox or just a plain string." + }, + "icon": { + "$ref": "file:///ExtensionDoorhanger.schema.json#/$defs/linkUrl", + "description": "The icon displayed in the pop-over. Should be 32x32px or 64x64px and png/svg." + }, + "icon_dark_theme": { + "type": "string", + "description": "Pop-over icon, dark theme variant. Should be 32x32px or 64x64px and png/svg." + }, + "icon_class": { + "type": "string", + "description": "CSS class of the pop-over icon." + }, + "addon": { + "description": "Addon information including AMO URL.", + "type": "object", + "properties": { + "id": { + "$ref": "file:///ExtensionDoorhanger.schema.json#/$defs/plainText", + "description": "Unique addon ID" + }, + "title": { + "$ref": "file:///ExtensionDoorhanger.schema.json#/$defs/plainText", + "description": "Addon name" + }, + "author": { + "$ref": "file:///ExtensionDoorhanger.schema.json#/$defs/plainText", + "description": "Addon author" + }, + "icon": { + "$ref": "file:///ExtensionDoorhanger.schema.json#/$defs/linkUrl", + "description": "The icon displayed in the pop-over. Should be 64x64px and png/svg." + }, + "rating": { + "type": "string", + "description": "Star rating" + }, + "users": { + "type": "string", + "description": "Installed users" + }, + "amo_url": { + "$ref": "file:///ExtensionDoorhanger.schema.json#/$defs/linkUrl", + "description": "Link that offers more information related to the addon." + } + }, + "required": [ + "title", + "author", + "icon", + "amo_url" + ] + }, + "text": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "The body text displayed in the pop-over. This can be a reference to a localized string in Firefox or just a plain string." + }, + "descriptionDetails": { + "description": "Additional information and steps on how to use", + "type": "object", + "properties": { + "steps": { + "description": "Array of string_ids", + "type": "array", + "items": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizedText", + "description": "Id of string to localized addon description" + } + } + }, + "required": [ + "steps" + ] + }, + "buttons": { + "description": "The label and functionality for the buttons in the pop-over.", + "type": "object", + "properties": { + "primary": { + "type": "object", + "properties": { + "label": { + "type": "object", + "oneOf": [ + { + "properties": { + "value": { + "$ref": "file:///ExtensionDoorhanger.schema.json#/$defs/plainText", + "description": "Button label override used when a localized version is not available." + }, + "attributes": { + "type": "object", + "properties": { + "accesskey": { + "type": "string", + "description": "A single character to be used as a shortcut key for the secondary button. This should be one of the characters that appears in the button label." + } + }, + "required": [ + "accesskey" + ], + "description": "Button attributes." + } + }, + "required": [ + "value", + "attributes" + ] + }, + { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizedText" + } + ], + "description": "Id of localized string or message override." + }, + "action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Action dispatched by the button." + }, + "data": { + "properties": { + "url": { + "type": "string", + "$comment": "This is dynamically generated from the addon.id. See CFRPageActions.jsm", + "description": "URL used in combination with the primary action dispatched." + } + } + } + } + } + } + }, + "secondary": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "object", + "oneOf": [ + { + "properties": { + "value": { + "allOf": [ + { + "$ref": "file:///ExtensionDoorhanger.schema.json#/$defs/plainText" + }, + { + "description": "Button label override used when a localized version is not available." + } + ] + }, + "attributes": { + "type": "object", + "properties": { + "accesskey": { + "type": "string", + "description": "A single character to be used as a shortcut key for the secondary button. This should be one of the characters that appears in the button label." + } + }, + "required": [ + "accesskey" + ], + "description": "Button attributes." + } + }, + "required": [ + "value", + "attributes" + ] + }, + { + "properties": { + "string_id": { + "allOf": [ + { + "$ref": "file:///ExtensionDoorhanger.schema.json#/$defs/plainText" + }, + { + "description": "Id of localized string for button" + } + ] + } + }, + "required": [ + "string_id" + ] + } + ], + "description": "Id of localized string or message override." + }, + "action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Action dispatched by the button." + }, + "data": { + "properties": { + "url": { + "allOf": [ + { + "$ref": "file:///ExtensionDoorhanger.schema.json#/$defs/linkUrl" + }, + { + "description": "URL used in combination with the primary action dispatched." + } + ] + } + } + } + } + } + } + } + } + } + } + }, + "additionalProperties": true, + "required": [ + "layout", + "bucket_id", + "heading_text", + "text", + "buttons" + ], + "if": { + "properties": { + "skip_address_bar_notifier": { + "anyOf": [ + { + "const": "false" + }, + { + "const": null + } + ] + } + } + }, + "then": { + "required": [ + "category", + "notification_text" + ] + } + }, + "template": { + "type": "string", + "enum": [ + "cfr_doorhanger", + "milestone_message" + ] + } + }, + "additionalProperties": true, + "required": [ + "targeting", + "trigger" + ], + "$defs": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "linkUrl": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + } + }, + "InfoBar": { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///InfoBar.schema.json", + "title": "InfoBar", + "description": "A template with an image, test and buttons.", + "allOf": [ + { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/Message" + } + ], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Should the message be global (persisted across tabs) or local (disappear when switching to a different tab).", + "enum": [ + "global", + "tab" + ] + }, + "text": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "The text show in the notification box." + }, + "priority": { + "description": "Infobar priority level https://searchfox.org/mozilla-central/rev/3aef835f6cb12e607154d56d68726767172571e4/toolkit/content/widgets/notificationbox.js#387", + "type": "number", + "minumum": 0, + "exclusiveMaximum": 10 + }, + "buttons": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "The text label of the button." + }, + "primary": { + "type": "boolean", + "description": "Is this the primary button?" + }, + "accessKey": { + "type": "string", + "description": "Keyboard shortcut letter." + }, + "action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Action dispatched by the button." + }, + "data": { + "type": "object" + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + "supportPage": { + "type": "string", + "description": "A page title on SUMO to link to" + } + }, + "required": [ + "label", + "action" + ], + "additionalProperties": true + } + } + }, + "additionalProperties": true, + "required": [ + "text", + "buttons" + ] + }, + "template": { + "type": "string", + "const": "infobar" + } + }, + "additionalProperties": true, + "required": [ + "targeting", + "trigger" + ], + "$defs": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "linkUrl": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + } + }, + "NewtabPromoMessage": { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///NewtabPromoMessage.schema.json", + "title": "PBNewtabPromoMessage", + "description": "Message shown on the private browsing newtab page.", + "allOf": [ + { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/Message" + } + ], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "hideDefault": { + "type": "boolean", + "description": "Should we hide the default promo after the experiment promo is dismissed." + }, + "infoEnabled": { + "type": "boolean", + "description": "Should we show the info section." + }, + "infoIcon": { + "type": "string", + "description": "Icon shown in the left side of the info section. Default is the private browsing icon." + }, + "infoTitle": { + "type": "string", + "description": "Is the title in the info section enabled." + }, + "infoTitleEnabled": { + "type": "boolean", + "description": "Is the title in the info section enabled." + }, + "infoBody": { + "type": "string", + "description": "Text content in the info section." + }, + "infoLinkText": { + "type": "string", + "description": "Text for the link in the info section." + }, + "infoLinkUrl": { + "type": "string", + "description": "URL for the info section link.", + "format": "moz-url-format" + }, + "promoEnabled": { + "type": "boolean", + "description": "Should we show the promo section." + }, + "promoType": { + "type": "string", + "description": "Promo type used to determine if promo should show to a given user", + "enum": [ + "FOCUS", + "VPN", + "PIN", + "COOKIE_BANNERS", + "OTHER" + ] + }, + "promoSectionStyle": { + "type": "string", + "description": "Sets the position of the promo section. Possible values are: top, below-search, bottom. Default bottom.", + "enum": [ + "top", + "below-search", + "bottom" + ] + }, + "promoTitle": { + "type": "string", + "description": "The text content of the promo section." + }, + "promoTitleEnabled": { + "type": "boolean", + "description": "Should we show text content in the promo section." + }, + "promoLinkText": { + "type": "string", + "description": "The text of the link in the promo box." + }, + "promoHeader": { + "type": "string", + "description": "The title of the promo section." + }, + "promoButton": { + "type": "object", + "properties": { + "action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Action dispatched by the button." + }, + "data": { + "type": "object" + } + }, + "required": [ + "type" + ], + "additionalProperties": true + } + }, + "required": [ + "action" + ] + }, + "promoLinkType": { + "type": "string", + "description": "Type of promo link type. Possible values: link, button. Default is link.", + "enum": [ + "link", + "button" + ] + }, + "promoImageLarge": { + "type": "string", + "description": "URL for image used on the left side of the promo box, larger, showcases some feature. Default off.", + "format": "uri" + }, + "promoImageSmall": { + "type": "string", + "description": "URL for image used on the right side of the promo box, smaller, usually a logo. Default off.", + "format": "uri" + } + }, + "additionalProperties": true, + "allOf": [ + { + "if": { + "properties": { + "promoEnabled": { + "const": true + } + }, + "required": [ + "promoEnabled" + ] + }, + "then": { + "required": [ + "promoButton" + ] + } + }, + { + "if": { + "properties": { + "infoEnabled": { + "const": true + } + }, + "required": [ + "infoEnabled" + ] + }, + "then": { + "required": [ + "infoLinkText" + ], + "if": { + "properties": { + "infoTitleEnabled": { + "const": true + } + }, + "required": [ + "infoTitleEnabled" + ] + }, + "then": { + "required": [ + "infoTitle" + ] + } + } + } + ] + }, + "template": { + "type": "string", + "const": "pb_newtab" + } + }, + "additionalProperties": true, + "required": [ + "targeting" + ] + }, + "ProtectionsPanelMessage": { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///ProtectionsPanelMessage.schema.json", + "title": "ProtectionsPanelMessage", + "description": "A message shown in the protections panel.", + "allOf": [ + { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/Message" + } + ], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "title": { + "description": "The message title.", + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizableText" + }, + "body": { + "description": "The body of the message.", + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizableText" + }, + "link_text": { + "description": "The text of the call to action link.", + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizableText" + }, + "cta_type": { + "description": "The type of URL open action.", + "type": "string", + "enum": [ + "OPEN_URL", + "OPEN_PROTECTION_REPORT", + "OPEN_ABOUT_PAGE" + ] + }, + "cta_url": { + "description": "The URL to open when the call to action is clicked", + "type": "string", + "format": "moz-url-format" + }, + "cta_where": { + "description": "How to open the cta.", + "type": "string", + "enum": [ + "current", + "tabshifted", + "tab", + "save", + "window" + ] + } + }, + "dependantSchemas": { + "link_text": [ + "cta_type", + "cta_url" + ], + "cta_type": [ + "link_text" + ], + "cta_url": [ + "link_text" + ], + "cta_where": [ + "link_text" + ] + }, + "additionalProperties": false, + "required": [ + "title", + "body" + ] + }, + "template": { + "type": "string", + "const": "protections_panel" + }, + "trigger": { + "description": "An action to trigger potentially showing the message. The action ID `protectionsPanelOpen` is required.", + "const": { + "id": "protectionsPanelOpen" + } + } + }, + "required": [ + "content", + "template", + "trigger" + ], + "additionalProperties": true + }, + "Spotlight": { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///Spotlight.schema.json", + "title": "Spotlight", + "description": "A template with an image, title, content and two buttons.", + "allOf": [ + { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/Message" + } + ], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "template": { + "type": "string", + "description": "Specify the layout template for the Spotlight", + "const": "multistage" + }, + "backdrop": { + "type": "string", + "description": "Background css behind modal content" + }, + "logo": { + "type": "object", + "properties": { + "imageURL": { + "type": "string", + "description": "URL for image to use with the content" + }, + "imageId": { + "type": "string", + "description": "The ID for a remotely hosted image" + }, + "size": { + "type": "string", + "description": "The logo size." + } + }, + "additionalProperties": true + }, + "screens": { + "type": "array", + "description": "Collection of individual screen content" + }, + "transitions": { + "type": "boolean", + "description": "Show transitions within and between screens" + }, + "disableHistoryUpdates": { + "type": "boolean", + "description": "Don't alter the browser session's history stack - used with messaging surfaces like Feature Callouts" + }, + "startScreen": { + "type": "integer", + "description": "Index of first screen to show from message, defaulting to 0" + } + }, + "additionalProperties": true + }, + "template": { + "type": "string", + "description": "Specify whether the surface is shown as a Spotlight modal or an in-surface Feature Callout dialog", + "enum": [ + "spotlight", + "feature_callout" + ] + } + }, + "additionalProperties": true, + "required": [ + "targeting" + ] + }, + "ToastNotification": { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///ToastNotification.schema.json", + "title": "ToastNotification", + "description": "A template for toast notifications displayed by the Alert service.", + "allOf": [ + { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/Message" + } + ], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "title": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of toast notification title" + }, + "body": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of toast notification body" + }, + "icon_url": { + "description": "The URL of the image used as an icon of the toast notification.", + "type": "string", + "format": "moz-url-format" + }, + "image_url": { + "description": "The URL of an image to be displayed as part of the notification.", + "type": "string", + "format": "moz-url-format" + }, + "launch_url": { + "description": "The URL to launch when the notification or an action button is clicked.", + "type": "string", + "format": "moz-url-format" + }, + "requireInteraction": { + "type": "boolean", + "description": "Whether the toast notification should remain active until the user clicks or dismisses it, rather than closing automatically." + }, + "tag": { + "type": "string", + "description": "An identifying tag for the toast notification." + }, + "data": { + "type": "object", + "description": "Arbitrary data associated with the toast notification." + }, + "actions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "The action text to be shown to the user." + }, + "action": { + "type": "string", + "description": "Opaque identifer that identifies action." + }, + "iconURL": { + "type": "string", + "format": "uri", + "description": "URL of an icon to display with the action." + }, + "windowsSystemActivationType": { + "type": "boolean", + "description": "Whether to have Windows process the given `action`." + } + }, + "required": [ + "action", + "title" + ], + "additionalProperties": true + } + } + }, + "additionalProperties": true, + "required": [ + "title", + "body" + ] + }, + "template": { + "type": "string", + "const": "toast_notification" + } + }, + "required": [ + "content", + "targeting", + "template", + "trigger" + ], + "additionalProperties": true + }, + "ToolbarBadgeMessage": { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///ToolbarBadgeMessage.schema.json", + "title": "ToolbarBadgeMessage", + "description": "A template that specifies to which element in the browser toolbar to add a notification.", + "allOf": [ + { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/Message" + } + ], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "target": { + "type": "string" + }, + "action": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "additionalProperties": true, + "required": [ + "id" + ], + "description": "Optional action to take in addition to showing the notification" + }, + "delay": { + "type": "number", + "description": "Optional delay in ms after which to show the notification" + }, + "badgeDescription": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizedText", + "description": "This is used in combination with the badged button to offer a text based alternative to the visual badging. Example 'New Feature: What's New'" + } + }, + "additionalProperties": true, + "required": [ + "target" + ] + }, + "template": { + "type": "string", + "const": "toolbar_badge" + } + }, + "additionalProperties": true, + "required": [ + "targeting" + ] + }, + "UpdateAction": { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///UpdateAction.schema.json", + "title": "UpdateActionMessage", + "description": "A template for messages that execute predetermined actions.", + "allOf": [ + { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/Message" + } + ], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "action": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "data": { + "type": "object", + "description": "Additional data provided as argument when executing the action", + "properties": { + "url": { + "type": "string", + "description": "URL data to be used as argument to the action" + }, + "expireDelta": { + "type": "number", + "description": "Expiration timestamp to be used as argument to the action" + } + } + } + }, + "additionalProperties": true, + "description": "Optional action to take in addition to showing the notification", + "required": [ + "id", + "data" + ] + } + }, + "additionalProperties": true, + "required": [ + "action" + ] + }, + "template": { + "type": "string", + "const": "update_action" + } + }, + "required": [ + "targeting" + ] + }, + "WhatsNewMessage": { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///WhatsNewMessage.schema.json", + "title": "WhatsNewMessage", + "description": "A template for the messages that appear in the What's New panel.", + "allOf": [ + { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/Message" + } + ], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "layout": { + "description": "Different message layouts", + "enum": [ + "tracking-protections" + ] + }, + "bucket_id": { + "type": "string", + "description": "A bucket identifier for the addon. This is used in order to anonymize telemetry for history-sensitive targeting." + }, + "published_date": { + "type": "integer", + "description": "The date/time (number of milliseconds elapsed since January 1, 1970 00:00:00 UTC) the message was published." + }, + "title": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of What's New message title" + }, + "subtitle": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of What's New message subtitle" + }, + "body": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of What's New message body" + }, + "link_text": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "(optional) Id of localized string or message override of What's New message link text" + }, + "cta_url": { + "description": "Target URL for the What's New message.", + "type": "string", + "format": "moz-url-format" + }, + "cta_type": { + "description": "Type of url open action", + "enum": [ + "OPEN_URL", + "OPEN_ABOUT_PAGE", + "OPEN_PROTECTION_REPORT" + ] + }, + "cta_where": { + "description": "How to open the cta: new window, tab, focused, unfocused.", + "enum": [ + "current", + "tabshifted", + "tab", + "save", + "window" + ] + }, + "icon_url": { + "description": "(optional) URL for the What's New message icon.", + "type": "string", + "format": "uri" + }, + "icon_alt": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizableText", + "description": "Alt text for image." + } + }, + "additionalProperties": true, + "required": [ + "published_date", + "title", + "body", + "cta_url", + "bucket_id" + ] + }, + "template": { + "type": "string", + "const": "whatsnew_panel_message" + } + }, + "required": [ + "order" + ], + "additionalProperties": true + }, + "Message": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The message identifier" + }, + "groups": { + "description": "Array of preferences used to control `enabled` status of the group. If any is `false` the group is disabled.", + "type": "array", + "items": { + "type": "string", + "description": "Preference name" + } + }, + "template": { + "type": "string", + "description": "Which messaging template this message is using.", + "enum": [ + "cfr_urlbar_chiclet", + "cfr_doorhanger", + "milestone_message", + "infobar", + "pb_newtab", + "protections_panel", + "spotlight", + "feature_callout", + "toast_notification", + "toolbar_badge", + "update_action", + "whatsnew_panel_message" + ] + }, + "frequency": { + "type": "object", + "description": "An object containing frequency cap information for a message.", + "properties": { + "lifetime": { + "type": "integer", + "description": "The maximum lifetime impressions for a message.", + "minimum": 1, + "maximum": 100 + }, + "custom": { + "type": "array", + "description": "An array of custom frequency cap definitions.", + "items": { + "description": "A frequency cap definition containing time and max impression information", + "type": "object", + "properties": { + "period": { + "type": "integer", + "description": "Period of time in milliseconds (e.g. 86400000 for one day)" + }, + "cap": { + "type": "integer", + "description": "The maximum impressions for the message within the defined period.", + "minimum": 1, + "maximum": 100 + } + }, + "required": [ + "period", + "cap" + ] + } + } + } + }, + "priority": { + "description": "The priority of the message. If there are two competing messages to show, the one with the highest priority will be shown", + "type": "integer" + }, + "order": { + "description": "The order in which messages should be shown. Messages will be shown in increasing order.", + "type": "integer" + }, + "targeting": { + "description": "A JEXL expression representing targeting information", + "type": "string" + }, + "trigger": { + "description": "An action to trigger potentially showing the message", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A string identifying the trigger action" + }, + "params": { + "type": "array", + "description": "An optional array of string parameters for the trigger action", + "items": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + } + } + }, + "required": [ + "id" + ] + }, + "provider": { + "description": "An identifier for the provider of this message, such as \"cfr\" or \"preview\".", + "type": "string" + } + }, + "additionalProperties": true, + "dependentRequired": { + "content": [ + "id", + "template" + ], + "template": [ + "id", + "content" + ] + } + }, + "localizedText": { + "type": "object", + "properties": { + "string_id": { + "description": "Id of localized string to be rendered.", + "type": "string" + } + }, + "required": [ + "string_id" + ] + }, + "localizableText": { + "description": "Either a raw string or an object containing the string_id of the localized text", + "oneOf": [ + { + "type": "string", + "description": "The string to be rendered." + }, + { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/localizedText" + } + ] + }, + "TemplatedMessage": { + "description": "An FxMS message of one of a variety of types.", + "type": "object", + "allOf": [ + { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/Message" + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": [ + "cfr_urlbar_chiclet" + ] + } + }, + "required": [ + "template" + ] + }, + "then": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/CFRUrlbarChiclet" + } + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": [ + "cfr_doorhanger", + "milestone_message" + ] + } + }, + "required": [ + "template" + ] + }, + "then": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/ExtensionDoorhanger" + } + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": [ + "infobar" + ] + } + }, + "required": [ + "template" + ] + }, + "then": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/InfoBar" + } + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": [ + "pb_newtab" + ] + } + }, + "required": [ + "template" + ] + }, + "then": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/NewtabPromoMessage" + } + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": [ + "protections_panel" + ] + } + }, + "required": [ + "template" + ] + }, + "then": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/ProtectionsPanelMessage" + } + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": [ + "spotlight", + "feature_callout" + ] + } + }, + "required": [ + "template" + ] + }, + "then": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/Spotlight" + } + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": [ + "toast_notification" + ] + } + }, + "required": [ + "template" + ] + }, + "then": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/ToastNotification" + } + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": [ + "toolbar_badge" + ] + } + }, + "required": [ + "template" + ] + }, + "then": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/ToolbarBadgeMessage" + } + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": [ + "update_action" + ] + } + }, + "required": [ + "template" + ] + }, + "then": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/UpdateAction" + } + }, + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": [ + "whatsnew_panel_message" + ] + } + }, + "required": [ + "template" + ] + }, + "then": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/WhatsNewMessage" + } + } + ] + }, + "MultiMessage": { + "description": "An object containing an array of messages.", + "type": "object", + "properties": { + "template": { + "type": "string", + "const": "multi" + }, + "messages": { + "type": "array", + "description": "An array of messages.", + "items": { + "$ref": "resource://activity-stream/schemas/MessagingExperiment.schema.json#/$defs/TemplatedMessage" + } + } + }, + "required": [ + "template", + "messages" + ] + } + } +} diff --git a/browser/components/newtab/content-src/asrouter/schemas/corpus/ReachExperiments.messages.json b/browser/components/newtab/content-src/asrouter/schemas/corpus/ReachExperiments.messages.json new file mode 100644 index 0000000000..1ccfefe478 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/schemas/corpus/ReachExperiments.messages.json @@ -0,0 +1,15 @@ +[ + { + "trigger": { + "id": "defaultBrowserCheck" + }, + "targeting": "source == 'startup' && !isMajorUpgrade && !activeNotifications && totalBookmarksCount == 5" + }, + { + "groups": ["eco"], + "trigger": { + "id": "defaultBrowserCheck" + }, + "targeting": "source == 'startup' && !isMajorUpgrade && !activeNotifications && totalBookmarksCount == 5" + } +] diff --git a/browser/components/newtab/content-src/asrouter/schemas/extract-test-corpus.js b/browser/components/newtab/content-src/asrouter/schemas/extract-test-corpus.js new file mode 100644 index 0000000000..f8d3f5ac81 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/schemas/extract-test-corpus.js @@ -0,0 +1,65 @@ +/* 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 strict"; + +const { CFRMessageProvider } = ChromeUtils.importESModule( + "resource://activity-stream/lib/CFRMessageProvider.sys.mjs" +); +const { OnboardingMessageProvider } = ChromeUtils.import( + "resource://activity-stream/lib/OnboardingMessageProvider.jsm" +); +const { PanelTestProvider } = ChromeUtils.importESModule( + "resource://activity-stream/lib/PanelTestProvider.sys.mjs" +); + +const CWD = Services.dirsvc.get("CurWorkD", Ci.nsIFile).path; +const CORPUS_DIR = PathUtils.join(CWD, "corpus"); + +const CORPUS = [ + { + name: "CFRMessageProvider.messages.json", + provider: CFRMessageProvider, + }, + { + name: "OnboardingMessageProvider.messages.json", + provider: OnboardingMessageProvider, + }, + { + name: "PanelTestProvider.messages.json", + provider: PanelTestProvider, + }, + { + name: "PanelTestProvider_toast_notification.messages.json", + provider: PanelTestProvider, + filter: message => message.template === "toast_notification", + }, +]; + +let exit = false; +async function main() { + try { + await IOUtils.makeDirectory(CORPUS_DIR); + + for (const entry of CORPUS) { + const { name, provider } = entry; + const filter = entry.filter ?? (() => true); + const messages = await provider.getMessages(); + const json = `${JSON.stringify(messages.filter(filter), undefined, 2)}\n`; + + const path = PathUtils.join(CORPUS_DIR, name); + await IOUtils.writeUTF8(path, json); + } + } finally { + exit = true; + } +} + +main(); + +// We need to spin the event loop here, otherwise everything goes out of scope. +Services.tm.spinEventLoopUntil( + "extract-test-corpus.js: waiting for completion", + () => exit +); diff --git a/browser/components/newtab/content-src/asrouter/schemas/make-schemas.py b/browser/components/newtab/content-src/asrouter/schemas/make-schemas.py new file mode 100755 index 0000000000..7fbf815aa0 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/schemas/make-schemas.py @@ -0,0 +1,475 @@ +#!/usr/bin/env python3 +# 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/. + +"""Firefox Messaging System Messaging Experiment schema generator + +The Firefox Messaging System handles several types of messages. This program +patches and combines those schemas into a single schema +(MessagingExperiment.schema.json) which is used to validate messaging +experiments coming from Nimbus. + +Definitions from FxMsCommon.schema.json are bundled into this schema. This +allows all of the FxMS schemas to reference common definitions, e.g. +`localizableText` for translatable strings, via referencing the common schema. +The bundled schema will be re-written so that the references now point at the +top-level, generated schema. + +Additionally, all self-references in each messaging schema will be rewritten +into absolute references, referencing each sub-schemas `$id`. This is requried +due to the JSONSchema validation library used by Experimenter not fully +supporting self-references and bundled schema. +""" + +import json +import sys +from argparse import ArgumentParser +from itertools import chain +from pathlib import Path +from typing import Any, Dict, List, NamedTuple, Union +from urllib.parse import urlparse + +import jsonschema + + +class SchemaDefinition(NamedTuple): + """A definition of a schema that is to be bundled.""" + + #: The $id of the generated schema. + schema_id: str + + #: The path of the generated schema. + schema_path: Path + + #: The message types that will be bundled into the schema. + message_types: Dict[str, Path] + + #: What common definitions to bundle into the schema. + #: + #: If `True`, all definitions will be bundled. + #: If `False`, no definitons will be bundled. + #: If a list, only the named definitions will be bundled. + bundle_common: Union[bool, List[str]] + + #: The testing corpus for the schema. + test_corpus: Dict[str, Path] + + +SCHEMA_DIR = Path("..", "templates") + +SCHEMAS = [ + SchemaDefinition( + schema_id="resource://activity-stream/schemas/MessagingExperiment.schema.json", + schema_path=Path("MessagingExperiment.schema.json"), + message_types={ + "CFRUrlbarChiclet": ( + SCHEMA_DIR / "CFR" / "templates" / "CFRUrlbarChiclet.schema.json" + ), + "ExtensionDoorhanger": ( + SCHEMA_DIR / "CFR" / "templates" / "ExtensionDoorhanger.schema.json" + ), + "InfoBar": SCHEMA_DIR / "CFR" / "templates" / "InfoBar.schema.json", + "NewtabPromoMessage": ( + SCHEMA_DIR / "PBNewtab" / "NewtabPromoMessage.schema.json" + ), + "ProtectionsPanelMessage": ( + SCHEMA_DIR / "OnboardingMessage" / "ProtectionsPanelMessage.schema.json" + ), + "Spotlight": SCHEMA_DIR / "OnboardingMessage" / "Spotlight.schema.json", + "ToastNotification": ( + SCHEMA_DIR / "ToastNotification" / "ToastNotification.schema.json" + ), + "ToolbarBadgeMessage": ( + SCHEMA_DIR / "OnboardingMessage" / "ToolbarBadgeMessage.schema.json" + ), + "UpdateAction": ( + SCHEMA_DIR / "OnboardingMessage" / "UpdateAction.schema.json" + ), + "WhatsNewMessage": ( + SCHEMA_DIR / "OnboardingMessage" / "WhatsNewMessage.schema.json" + ), + }, + bundle_common=True, + test_corpus={ + "ReachExperiments": Path("corpus", "ReachExperiments.messages.json"), + # These are generated via extract-test-corpus.js + "CFRMessageProvider": Path("corpus", "CFRMessageProvider.messages.json"), + "OnboardingMessageProvider": Path( + "corpus", "OnboardingMessageProvider.messages.json" + ), + "PanelTestProvider": Path("corpus", "PanelTestProvider.messages.json"), + }, + ), + SchemaDefinition( + schema_id=( + "resource://activity-stream/schemas/" + "BackgroundTaskMessagingExperiment.schema.json" + ), + schema_path=Path("BackgroundTaskMessagingExperiment.schema.json"), + message_types={ + "ToastNotification": ( + SCHEMA_DIR / "ToastNotification" / "ToastNotification.schema.json" + ), + }, + bundle_common=True, + # These are generated via extract-test-corpus.js + test_corpus={ + # Just the "toast_notification" messages. + "PanelTestProvider": Path( + "corpus", "PanelTestProvider_toast_notification.messages.json" + ), + }, + ), +] + +COMMON_SCHEMA_NAME = "FxMSCommon.schema.json" +COMMON_SCHEMA_PATH = Path(COMMON_SCHEMA_NAME) + + +class NestedRefResolver(jsonschema.RefResolver): + """A custom ref resolver that handles bundled schema. + + This is the resolver used by Experimenter. + """ + + def __init__(self, schema): + super().__init__(base_uri=None, referrer=None) + + if "$id" in schema: + self.store[schema["$id"]] = schema + + if "$defs" in schema: + for dfn in schema["$defs"].values(): + if "$id" in dfn: + self.store[dfn["$id"]] = dfn + + +def read_schema(path): + """Read a schema from disk and parse it as JSON.""" + with path.open("r") as f: + return json.load(f) + + +def extract_template_values(template): + """Extract the possible template values (either via JSON Schema enum or const).""" + enum = template.get("enum") + if enum: + return enum + + const = template.get("const") + if const: + return [const] + + +def patch_schema(schema, bundled_id, schema_id=None): + """Patch the given schema. + + The JSON schema validator that Experimenter uses + (https://pypi.org/project/jsonschema/) does not support relative references, + nor does it support bundled schemas. We rewrite the schema so that all + relative refs are transformed into absolute refs via the schema's `$id`. + + Additionally, we merge in the contents of FxMSCommon.schema.json, so all + refs relative to that schema will be transformed to become relative to this + schema. + + See-also: https://github.com/python-jsonschema/jsonschema/issues/313 + """ + if schema_id is None: + schema_id = schema["$id"] + + def patch_impl(schema): + ref = schema.get("$ref") + + if ref: + uri = urlparse(ref) + if ( + uri.scheme == "" + and uri.netloc == "" + and uri.path == "" + and uri.fragment != "" + ): + schema["$ref"] = f"{schema_id}#{uri.fragment}" + elif (uri.scheme, uri.path) == ("file", f"/{COMMON_SCHEMA_NAME}"): + schema["$ref"] = f"{bundled_id}#{uri.fragment}" + + # If `schema` is object-like, inspect each of its indivual properties + # and patch them. + properties = schema.get("properties") + if properties: + for prop in properties.keys(): + patch_impl(properties[prop]) + + # If `schema` is array-like, inspect each of its items and patch them. + items = schema.get("items") + if items: + patch_impl(items) + + # Patch each `if`, `then`, `else`, and `not` sub-schema that is present. + for key in ("if", "then", "else", "not"): + if key in schema: + patch_impl(schema[key]) + + # Patch the items of each `oneOf`, `allOf`, and `anyOf` sub-schema that + # is present. + for key in ("oneOf", "allOf", "anyOf"): + subschema = schema.get(key) + if subschema: + for i, alternate in enumerate(subschema): + patch_impl(alternate) + + # Patch the top-level type defined in the schema. + patch_impl(schema) + + # Patch each named definition in the schema. + for key in ("$defs", "definitions"): + defns = schema.get(key) + if defns: + for defn_name, defn_value in defns.items(): + patch_impl(defn_value) + + return schema + + +def bundle_schema(schema_def: SchemaDefinition): + """Create a bundled schema based on the schema definition.""" + # Patch each message type schema to resolve all self-references to be + # absolute and rewrite # references to FxMSCommon.schema.json to be relative + # to the new schema (because we are about to bundle its definitions). + defs = { + name: patch_schema(read_schema(path), bundled_id=schema_def.schema_id) + for name, path in schema_def.message_types.items() + } + + # Bundle the definitions from FxMSCommon.schema.json into this schema. + if schema_def.bundle_common: + + def dfn_filter(name): + if schema_def.bundle_common is True: + return True + + return name in schema_def.bundle_common + + common_schema = patch_schema( + read_schema(COMMON_SCHEMA_PATH), + bundled_id=schema_def.schema_id, + schema_id=schema_def.schema_id, + ) + + # patch_schema mutates the given schema, so we read a new copy in for + # each bundle operation. + defs.update( + { + name: dfn + for name, dfn in common_schema["$defs"].items() + if dfn_filter(name) + } + ) + + # Ensure all bundled schemas have an $id so that $refs inside the + # bundled schema work correctly (i.e, they will reference the subschema + # and not the bundle). + for name in schema_def.message_types.keys(): + subschema = defs[name] + if "$id" not in subschema: + raise ValueError(f"Schema {name} is missing an $id") + + props = subschema["properties"] + if "template" not in props: + raise ValueError(f"Schema {name} is missing a template") + + template = props["template"] + if "enum" not in template and "const" not in template: + raise ValueError(f"Schema {name} should have const or enum template") + + templates = { + name: extract_template_values(defs[name]["properties"]["template"]) + for name in schema_def.message_types.keys() + } + + # Ensure that each schema has a unique set of template values. + for a in templates.keys(): + a_keys = set(templates[a]) + + for b in templates.keys(): + if a == b: + continue + + b_keys = set(templates[b]) + intersection = a_keys.intersection(b_keys) + + if len(intersection): + raise ValueError( + f"Schema {a} and {b} have overlapping template values: " + f"{', '.join(intersection)}" + ) + + all_templates = list(chain.from_iterable(templates.values())) + + # Enforce that one of the templates must match (so that one of the if + # branches will match). + defs["Message"]["properties"]["template"]["enum"] = all_templates + defs["TemplatedMessage"] = { + "description": "An FxMS message of one of a variety of types.", + "type": "object", + "allOf": [ + # Ensure each message has all the fields defined in the base + # Message type. + # + # This is slightly redundant because each message should + # already inherit from this message type, but it is easier + # to add this requirement here than to verify that each + # message's schema is properly inheriting. + {"$ref": f"{schema_def.schema_id}#/$defs/Message"}, + # For each message type, create a subschema that says if the + # template field matches a value for a message type defined + # in MESSAGE_TYPES, then the message must also match the + # schema for that message type. + # + # This is done using `allOf: [{ if, then }]` instead of `oneOf: []` + # because it provides better error messages. Using `if-then` + # will only show validation errors for the sub-schema that + # matches template, whereas using `oneOf` will show + # validation errors for *all* sub-schemas, which makes + # debugging messages much harder. + *( + { + "if": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": templates[message_type], + }, + }, + "required": ["template"], + }, + "then": {"$ref": f"{schema_def.schema_id}#/$defs/{message_type}"}, + } + for message_type in schema_def.message_types + ), + ], + } + defs["MultiMessage"] = { + "description": "An object containing an array of messages.", + "type": "object", + "properties": { + "template": {"type": "string", "const": "multi"}, + "messages": { + "type": "array", + "description": "An array of messages.", + "items": {"$ref": f"{schema_def.schema_id}#/$defs/TemplatedMessage"}, + }, + }, + "required": ["template", "messages"], + } + + # Generate the combined schema. + return { + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": schema_def.schema_id, + "title": "Messaging Experiment", + "description": "A Firefox Messaging System message.", + # A message must be one of: + # - An object that contains id, template, and content fields + # - An object that contains none of the above fields (empty message) + # - An array of messages like the above + "if": { + "type": "object", + "properties": {"template": {"const": "multi"}}, + "required": ["template"], + }, + "then": { + "$ref": f"{schema_def.schema_id}#/$defs/MultiMessage", + }, + "else": { + "$ref": f"{schema_def.schema_id}#/$defs/TemplatedMessage", + }, + "$defs": defs, + } + + +def check_diff(schema_def: SchemaDefinition, schema: Dict[str, Any]): + """Check the generated schema matches the on-disk schema.""" + print(f" Checking {schema_def.schema_path} for differences...") + + with schema_def.schema_path.open("r") as f: + on_disk = json.load(f) + + if on_disk != schema: + print(f"{schema_def.schema_path} does not match generated schema:") + print("Generated schema:") + json.dump(schema, sys.stdout, indent=2) + print("\n\nOn Disk schema:") + json.dump(on_disk, sys.stdout, indent=2) + print("\n\n") + + raise ValueError("Schemas do not match!") + + +def validate_corpus(schema_def: SchemaDefinition, schema: Dict[str, Any]): + """Check that the schema validates. + + This uses the same validation configuration that is used in Experimenter. + """ + print(" Validating messages with Experimenter JSON Schema validator...") + + resolver = NestedRefResolver(schema) + + for provider, provider_path in schema_def.test_corpus.items(): + print(f" Validating messages from {provider}:") + + try: + with provider_path.open("r") as f: + messages = json.load(f) + except FileNotFoundError as e: + if not provider_path.parent.exists(): + new_exc = Exception( + f"Could not find {provider_path}: Did you run " + "`mach xpcshell extract-test-corpus.js` ?" + ) + raise new_exc from e + + raise e + + for i, message in enumerate(messages): + template = message.get("template", "(no template)") + msg_id = message.get("id", f"index {i}") + + print( + f" Validating {msg_id} {template} message with {schema_def.schema_path}..." + ) + jsonschema.validate(instance=message, schema=schema, resolver=resolver) + + print() + + +def main(check=False): + """Generate Nimbus feature schemas for Firefox Messaging System.""" + for schema_def in SCHEMAS: + print(f"Generating {schema_def.schema_path} ...") + schema = bundle_schema(schema_def) + + if check: + print(f"Checking {schema_def.schema_path} ...") + check_diff(schema_def, schema) + validate_corpus(schema_def, schema) + else: + with schema_def.schema_path.open("wb") as f: + print(f"Writing {schema_def.schema_path} ...") + f.write(json.dumps(schema, indent=2).encode("utf-8")) + f.write(b"\n") + + +if __name__ == "__main__": + parser = ArgumentParser(description=main.__doc__) + parser.add_argument( + "--check", + action="store_true", + help="Check that the generated schemas have not changed and run validation tests.", + default=False, + ) + args = parser.parse_args() + + main(args.check) diff --git a/browser/components/newtab/content-src/asrouter/schemas/message-format.md b/browser/components/newtab/content-src/asrouter/schemas/message-format.md new file mode 100644 index 0000000000..debcce0572 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/schemas/message-format.md @@ -0,0 +1,101 @@ +## Activity Stream Router message format + +Field name | Type | Required | Description | Example / Note +--- | --- | --- | --- | --- +`id` | `string` | Yes | A unique identifier for the message that should not conflict with any other previous message | `ONBOARDING_1` +`template` | `string` | Yes | An id matching an existing Activity Stream Router template | [See example](https://github.com/mozilla/activity-stream/blob/33669c67c2269078a6d3d6d324fb48175d98f634/system-addon/content-src/message-center/templates/SimpleSnippet.jsx) +`content` | `object` | Yes | An object containing all variables/props to be rendered in the template. Subset of allowed tags detailed below. | [See example below](#html-subset) +`bundled` | `integer` | No | The number of messages of the same template this one should be shown with | [See example below](#a-bundled-message-example) +`order` | `integer` | No | If bundled with other messages of the same template, which order should this one be placed in? Defaults to 0 if no order is desired | [See example below](#a-bundled-message-example) +`campaign` | `string` | No | Campaign id that the message belongs to | `RustWebAssembly` +`targeting` | `string` `JEXL` | No | A [JEXL expression](http://normandy.readthedocs.io/en/latest/user/filter_expressions.html#jexl-basics) with all targeting information needed in order to decide if the message is shown | Not yet implemented, [Examples](#targeting-attributes) +`trigger` | `string` | No | An event or condition upon which the message will be immediately shown. This can be combined with `targeting`. Messages that define a trigger will not be shown during non-trigger-based passive message rotation. +`trigger.params` | `[string]` | No | A set of hostnames passed down as parameters to the trigger condition. Used to restrict the number of domains where the trigger/message is valid. | [See example below](#trigger-params) +`trigger.patterns` | `[string]` | No | A set of patterns that match multiple hostnames passed down as parameters to the trigger condition. Used to restrict the number of domains where the trigger/message is valid. | [See example below](#trigger-patterns) +`frequency` | `object` | No | A definition for frequency cap information for the message +`frequency.lifetime` | `integer` | No | The maximum number of lifetime impressions for the message. +`frequency.custom` | `array` | No | An array of frequency cap definition objects including `period`, a time period in milliseconds, and `cap`, a max number of impressions for that period. + +### Message example +```javascript +{ + id: "ONBOARDING_1", + template: "simple_snippet", + content: { + title: "Find it faster", + body: "Access all of your favorite search engines with a click. Search the whole Web or just one website from the search box." + }, + targeting: "usesFirefoxSync && !addonsInfo.addons['activity-stream@mozilla.org']", + frequency: { + lifetime: 20, + custom: [{period: 86400000, cap: 5}, {period: 3600000, cap: 1}] + } +} +``` + +### A Bundled Message example +The following 2 messages have a `bundled` property, indicating that they should be shown together, since they have the same template. The number `2` indicates that this message should be shown in a bundle of 2 messages of the same template. The order property defines that ONBOARDING_2 should be shown after ONBOARDING_3 in the bundle. +```javascript +{ + id: "ONBOARDING_2", + template: "onboarding", + bundled: 2, + order: 2, + content: { + title: "Private Browsing", + body: "Browse by yourself. Private Browsing with Tracking Protection blocks online trackers that follow you around the web." + }, + targeting: "", + trigger: "firstRun" +} +{ + id: "ONBOARDING_3", + template: "onboarding", + bundled: 2, + order: 1, + content: { + title: "Find it faster", + body: "Access all of your favorite search engines with a click. Search the whole Web or just one website from the search box." + }, + targeting: "", + trigger: "firstRun" +} +``` + +### HTML subset +The following tags are allowed in the content of the snippet: `i, b, u, strong, em, br`. + +Links cannot be rendered using regular anchor tags because [Fluent does not allow for href attributes](https://github.com/projectfluent/fluent.js/blob/a03d3aa833660f8c620738b26c80e46b1a4edb05/fluent-dom/src/overlay.js#L13). They will be wrapped in custom tags, for example `<cta>link</cta>` and the url will be provided as part of the payload: +``` +{ + "id": "7899", + "content": { + "text": "Use the CMD (CTRL) + T keyboard shortcut to <cta>open a new tab quickly!</cta>", + "links": { + "cta": { + "url": "https://support.mozilla.org/en-US/kb/keyboard-shortcuts-perform-firefox-tasks-quickly" + } + } + } +} +``` +If a tag that is not on the allowed is used, the text content will be extracted and displayed. + +Grouping multiple allowed elements is not possible, only the first level will be used: `<u><b>text</b></u>` will be interpreted as `<u>text</u>`. + +### Trigger params +A set of hostnames that need to exactly match the location of the selected tab in order for the trigger to execute. +``` +["github.com", "wwww.github.com"] +``` +More examples in the [CFRMessageProvider](https://github.com/mozilla/activity-stream/blob/e76ce12fbaaac1182aa492b84fc038f78c3acc33/lib/CFRMessageProvider.jsm#L40-L47). + +### Trigger patterns +A set of patterns that can match multiple hostnames. When the location of the selected tab matches one of the patterns it can execute a trigger. +``` +["*://*.github.com"] // can match `github.com` but also match `https://gist.github.com/` +``` +More [MatchPattern examples](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns#Examples). + +### Targeting attributes +(This section has moved to [targeting-attributes.md](../docs/targeting-attributes.md)). diff --git a/browser/components/newtab/content-src/asrouter/schemas/message-group.schema.json b/browser/components/newtab/content-src/asrouter/schemas/message-group.schema.json new file mode 100644 index 0000000000..421acf159a --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/schemas/message-group.schema.json @@ -0,0 +1,64 @@ +{ + "title": "MessageGroup", + "description": "Configuration object for groups of Messaging System messages", + "type": "object", + "version": "1.0.0", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for the message that should not conflict with any other previous message." + }, + "enabled": { + "type": "boolean", + "description": "Enables or disables all messages associated with this group." + }, + "userPreferences": { + "type": "array", + "description": "Collection of preferences that control if the group is enabled.", + "items": { + "type": "string", + "description": "Preference name" + } + }, + "frequency": { + "type": "object", + "description": "An object containing frequency cap information for a message.", + "properties": { + "lifetime": { + "type": "integer", + "description": "The maximum lifetime impressions for a message.", + "minimum": 1, + "maximum": 100 + }, + "custom": { + "type": "array", + "description": "An array of custom frequency cap definitions.", + "items": { + "description": "A frequency cap definition containing time and max impression information", + "type": "object", + "properties": { + "period": { + "type": "integer", + "description": "Period of time in milliseconds (e.g. 86400000 for one day)" + }, + "cap": { + "type": "integer", + "description": "The maximum impressions for the message within the defined period.", + "minimum": 1, + "maximum": 100 + } + }, + "required": ["period", "cap"] + } + } + } + }, + "type": { + "type": "string", + "description": "Local auto-generated group or remote group configuration from RS.", + "enum": ["remote-settings", "local", "default"] + } + }, + "required": ["id", "enabled", "type"], + "additionalProperties": true +} diff --git a/browser/components/newtab/content-src/asrouter/schemas/provider-response.schema.json b/browser/components/newtab/content-src/asrouter/schemas/provider-response.schema.json new file mode 100644 index 0000000000..f0a92705be --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/schemas/provider-response.schema.json @@ -0,0 +1,67 @@ +{ + "title": "ProviderResponse", + "description": "A response object for remote providers of AS Router", + "type": "object", + "version": "6.1.0", + "properties": { + "messages": { + "type": "array", + "description": "An array of router messages", + "items": { + "title": "RouterMessage", + "description": "A definition of an individual message", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for the message that should not conflict with any other previous message" + }, + "template": { + "type": "string", + "description": "An id matching an existing Activity Stream Router template", + "enum": ["simple_snippet"] + }, + "bundled": { + "type": "integer", + "description": "The number of messages of the same template this one should be shown with (optional)" + }, + "order": { + "type": "integer", + "minimum": 0, + "description": "If bundled with other messages of the same template, which order should this one be placed in? (optional - defaults to 0)" + }, + "content": { + "type": "object", + "description": "An object containing all variables/props to be rendered in the template. See individual template schemas for details." + }, + "targeting": { + "type": "string", + "description": "A JEXL expression representing targeting information" + }, + "trigger": { + "type": "object", + "description": "An action to trigger potentially showing the message", + "properties": { + "id": { + "type": "string", + "description": "A string identifying the trigger action", + "enum": ["firstRun", "openURL"] + }, + "params": { + "type": "array", + "description": "An optional array of string parameters for the trigger action", + "items": { + "type": "string", + "description": "A parameter for the trigger action" + } + } + }, + "required": ["id"] + } + }, + "required": ["id", "template", "content"] + } + } + }, + "required": ["messages"] +} diff --git a/browser/components/newtab/content-src/asrouter/template-utils.js b/browser/components/newtab/content-src/asrouter/template-utils.js new file mode 100644 index 0000000000..004234f45f --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/template-utils.js @@ -0,0 +1,22 @@ +/* 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/. */ + +export function safeURI(url) { + if (!url) { + return ""; + } + const { protocol } = new URL(url); + const isAllowed = [ + "http:", + "https:", + "data:", + "resource:", + "chrome:", + ].includes(protocol); + if (!isAllowed) { + // eslint-disable-next-line no-console + console.warn(`The protocol ${protocol} is not allowed for template URLs.`); + } + return isAllowed ? url : ""; +} diff --git a/browser/components/newtab/content-src/asrouter/templates/CFR/templates/CFRUrlbarChiclet.schema.json b/browser/components/newtab/content-src/asrouter/templates/CFR/templates/CFRUrlbarChiclet.schema.json new file mode 100644 index 0000000000..ff5dff535a --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/CFR/templates/CFRUrlbarChiclet.schema.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///CFRUrlbarChiclet.schema.json", + "title": "CFRUrlbarChiclet", + "description": "A template with a chiclet button with text.", + "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "category": { + "type": "string", + "description": "Attribute used for different groups of messages from the same provider" + }, + "layout": { + "type": "string", + "description": "Describes how content should be displayed.", + "enum": ["chiclet_open_url"] + }, + "bucket_id": { + "type": "string", + "description": "A bucket identifier for the addon. This is used in order to anonymize telemetry for history-sensitive targeting." + }, + "notification_text": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "The text in the small blue chicklet that appears in the URL bar. This can be a reference to a localized string in Firefox or just a plain string." + }, + "active_color": { + "type": "string", + "description": "Background color of the button" + }, + "action": { + "type": "object", + "properties": { + "url": { + "description": "The page to open when the button is clicked.", + "type": "string", + "format": "moz-url-format" + }, + "where": { + "description": "Should it open in a new tab or the current tab", + "type": "string", + "enum": ["current", "tabshifted"] + } + }, + "additionalProperties": true, + "required": ["url", "where"] + } + }, + "additionalProperties": true, + "required": [ + "layout", + "category", + "bucket_id", + "notification_text", + "action" + ] + }, + "template": { + "type": "string", + "const": "cfr_urlbar_chiclet" + } + }, + "required": ["targeting", "trigger"] +} diff --git a/browser/components/newtab/content-src/asrouter/templates/CFR/templates/ExtensionDoorhanger.schema.json b/browser/components/newtab/content-src/asrouter/templates/CFR/templates/ExtensionDoorhanger.schema.json new file mode 100644 index 0000000000..1bb157b5fb --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/CFR/templates/ExtensionDoorhanger.schema.json @@ -0,0 +1,320 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///ExtensionDoorhanger.schema.json", + "title": "ExtensionDoorhanger", + "description": "A template with a heading, addon icon, title and description. No markup allowed.", + "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "category": { + "type": "string", + "description": "Attribute used for different groups of messages from the same provider" + }, + "layout": { + "type": "string", + "description": "Attribute used for different groups of messages from the same provider", + "enum": ["short_message", "icon_and_message", "addon_recommendation"] + }, + "anchor_id": { + "type": "string", + "description": "A DOM element ID that the pop-over will be anchored." + }, + "alt_anchor_id": { + "type": "string", + "description": "An alternate DOM element ID that the pop-over will be anchored." + }, + "bucket_id": { + "type": "string", + "description": "A bucket identifier for the addon. This is used in order to anonymize telemetry for history-sensitive targeting." + }, + "skip_address_bar_notifier": { + "type": "boolean", + "description": "Skip the 'Recommend' notifier and show directly." + }, + "persistent_doorhanger": { + "type": "boolean", + "description": "Prevent the doorhanger from being dismissed if user interacts with the page or switches between applications." + }, + "show_in_private_browsing": { + "type": "boolean", + "description": "Whether to allow the message to be shown in private browsing mode. Defaults to false." + }, + "notification_text": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "The text in the small blue chicklet that appears in the URL bar. This can be a reference to a localized string in Firefox or just a plain string." + }, + "info_icon": { + "type": "object", + "description": "The small icon displayed in the top right corner of the pop-over. Should be 19x19px, svg or png. Defaults to a small question mark.", + "properties": { + "label": { + "oneOf": [ + { + "type": "object", + "properties": { + "attributes": { + "type": "object", + "properties": { + "tooltiptext": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "Text for button tooltip used to provide information about the doorhanger." + } + }, + "required": ["tooltiptext"] + } + }, + "required": ["attributes"] + }, + { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizedText" + } + ] + }, + "sumo_path": { + "type": "string", + "description": "Last part of the path in the URL to the support page with the information about the doorhanger.", + "examples": ["extensionpromotions", "extensionrecommendations"] + } + } + }, + "learn_more": { + "type": "string", + "description": "Last part of the path in the SUMO URL to the support page with the information about the doorhanger.", + "examples": ["extensionpromotions", "extensionrecommendations"] + }, + "heading_text": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "The larger heading text displayed in the pop-over. This can be a reference to a localized string in Firefox or just a plain string." + }, + "icon": { + "$ref": "#/$defs/linkUrl", + "description": "The icon displayed in the pop-over. Should be 32x32px or 64x64px and png/svg." + }, + "icon_dark_theme": { + "type": "string", + "description": "Pop-over icon, dark theme variant. Should be 32x32px or 64x64px and png/svg." + }, + "icon_class": { + "type": "string", + "description": "CSS class of the pop-over icon." + }, + "addon": { + "description": "Addon information including AMO URL.", + "type": "object", + "properties": { + "id": { + "$ref": "#/$defs/plainText", + "description": "Unique addon ID" + }, + "title": { + "$ref": "#/$defs/plainText", + "description": "Addon name" + }, + "author": { + "$ref": "#/$defs/plainText", + "description": "Addon author" + }, + "icon": { + "$ref": "#/$defs/linkUrl", + "description": "The icon displayed in the pop-over. Should be 64x64px and png/svg." + }, + "rating": { + "type": "string", + "description": "Star rating" + }, + "users": { + "type": "string", + "description": "Installed users" + }, + "amo_url": { + "$ref": "#/$defs/linkUrl", + "description": "Link that offers more information related to the addon." + } + }, + "required": ["title", "author", "icon", "amo_url"] + }, + "text": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "The body text displayed in the pop-over. This can be a reference to a localized string in Firefox or just a plain string." + }, + "descriptionDetails": { + "description": "Additional information and steps on how to use", + "type": "object", + "properties": { + "steps": { + "description": "Array of string_ids", + "type": "array", + "items": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizedText", + "description": "Id of string to localized addon description" + } + } + }, + "required": ["steps"] + }, + "buttons": { + "description": "The label and functionality for the buttons in the pop-over.", + "type": "object", + "properties": { + "primary": { + "type": "object", + "properties": { + "label": { + "type": "object", + "oneOf": [ + { + "properties": { + "value": { + "$ref": "#/$defs/plainText", + "description": "Button label override used when a localized version is not available." + }, + "attributes": { + "type": "object", + "properties": { + "accesskey": { + "type": "string", + "description": "A single character to be used as a shortcut key for the secondary button. This should be one of the characters that appears in the button label." + } + }, + "required": ["accesskey"], + "description": "Button attributes." + } + }, + "required": ["value", "attributes"] + }, + { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizedText" + } + ], + "description": "Id of localized string or message override." + }, + "action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Action dispatched by the button." + }, + "data": { + "properties": { + "url": { + "type": "string", + "$comment": "This is dynamically generated from the addon.id. See CFRPageActions.jsm", + "description": "URL used in combination with the primary action dispatched." + } + } + } + } + } + } + }, + "secondary": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "object", + "oneOf": [ + { + "properties": { + "value": { + "allOf": [ + { "$ref": "#/$defs/plainText" }, + { + "description": "Button label override used when a localized version is not available." + } + ] + }, + "attributes": { + "type": "object", + "properties": { + "accesskey": { + "type": "string", + "description": "A single character to be used as a shortcut key for the secondary button. This should be one of the characters that appears in the button label." + } + }, + "required": ["accesskey"], + "description": "Button attributes." + } + }, + "required": ["value", "attributes"] + }, + { + "properties": { + "string_id": { + "allOf": [ + { "$ref": "#/$defs/plainText" }, + { + "description": "Id of localized string for button" + } + ] + } + }, + "required": ["string_id"] + } + ], + "description": "Id of localized string or message override." + }, + "action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Action dispatched by the button." + }, + "data": { + "properties": { + "url": { + "allOf": [ + { "$ref": "#/$defs/linkUrl" }, + { + "description": "URL used in combination with the primary action dispatched." + } + ] + } + } + } + } + } + } + } + } + } + } + }, + "additionalProperties": true, + "required": ["layout", "bucket_id", "heading_text", "text", "buttons"], + "if": { + "properties": { + "skip_address_bar_notifier": { + "anyOf": [{ "const": "false" }, { "const": null }] + } + } + }, + "then": { + "required": ["category", "notification_text"] + } + }, + "template": { + "type": "string", + "enum": ["cfr_doorhanger", "milestone_message"] + } + }, + "additionalProperties": true, + "required": ["targeting", "trigger"], + "$defs": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "linkUrl": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/CFR/templates/InfoBar.schema.json b/browser/components/newtab/content-src/asrouter/templates/CFR/templates/InfoBar.schema.json new file mode 100644 index 0000000000..ca0c0745bb --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/CFR/templates/InfoBar.schema.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///InfoBar.schema.json", + "title": "InfoBar", + "description": "A template with an image, test and buttons.", + "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Should the message be global (persisted across tabs) or local (disappear when switching to a different tab).", + "enum": ["global", "tab"] + }, + "text": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "The text show in the notification box." + }, + "priority": { + "description": "Infobar priority level https://searchfox.org/mozilla-central/rev/3aef835f6cb12e607154d56d68726767172571e4/toolkit/content/widgets/notificationbox.js#387", + "type": "number", + "minumum": 0, + "exclusiveMaximum": 10 + }, + "buttons": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "The text label of the button." + }, + "primary": { + "type": "boolean", + "description": "Is this the primary button?" + }, + "accessKey": { + "type": "string", + "description": "Keyboard shortcut letter." + }, + "action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Action dispatched by the button." + }, + "data": { + "type": "object" + } + }, + "required": ["type"], + "additionalProperties": true + }, + "supportPage": { + "type": "string", + "description": "A page title on SUMO to link to" + } + }, + "required": ["label", "action"], + "additionalProperties": true + } + } + }, + "additionalProperties": true, + "required": ["text", "buttons"] + }, + "template": { + "type": "string", + "const": "infobar" + } + }, + "additionalProperties": true, + "required": ["targeting", "trigger"], + "$defs": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "linkUrl": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.jsx new file mode 100644 index 0000000000..f324a69853 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.jsx @@ -0,0 +1,153 @@ +/* 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 { SimpleSnippet } from "../SimpleSnippet/SimpleSnippet"; + +class EOYSnippetBase extends React.PureComponent { + constructor(props) { + super(props); + this.handleSubmit = this.handleSubmit.bind(this); + } + + /** + * setFrequencyValue - `frequency` form parameter value should be `monthly` + * if `monthly-checkbox` is selected or `single` otherwise + */ + setFrequencyValue() { + const frequencyCheckbox = this.refs.form.querySelector("#monthly-checkbox"); + if (frequencyCheckbox.checked) { + this.refs.form.querySelector("[name='frequency']").value = "monthly"; + } + } + + handleSubmit(event) { + event.preventDefault(); + this.props.sendClick(event); + this.setFrequencyValue(); + if (!this.props.content.do_not_autoblock) { + this.props.onBlock(); + } + this.refs.form.submit(); + } + + renderDonations() { + const fieldNames = ["first", "second", "third", "fourth"]; + const numberFormat = new Intl.NumberFormat( + this.props.content.locale || navigator.language, + { + style: "currency", + currency: this.props.content.currency_code, + minimumFractionDigits: 0, + } + ); + // Default to `second` button + const { selected_button } = this.props.content; + const btnStyle = { + color: this.props.content.button_color, + backgroundColor: this.props.content.button_background_color, + }; + const donationURLParams = []; + const paramsStartIndex = this.props.content.donation_form_url.indexOf("?"); + for (const entry of new URLSearchParams( + this.props.content.donation_form_url.slice(paramsStartIndex) + ).entries()) { + donationURLParams.push(entry); + } + + return ( + <form + className="EOYSnippetForm" + action={this.props.content.donation_form_url} + method={this.props.form_method} + onSubmit={this.handleSubmit} + data-metric="EOYSnippetForm" + ref="form" + > + {donationURLParams.map(([key, value], idx) => ( + <input type="hidden" name={key} value={value} key={idx} /> + ))} + {fieldNames.map((field, idx) => { + const button_name = `donation_amount_${field}`; + const amount = this.props.content[button_name]; + return ( + <React.Fragment key={idx}> + <input + type="radio" + name="amount" + value={amount} + id={field} + defaultChecked={button_name === selected_button} + /> + <label htmlFor={field} className="donation-amount"> + {numberFormat.format(amount)} + </label> + </React.Fragment> + ); + })} + + <div className="monthly-checkbox-container"> + <input id="monthly-checkbox" type="checkbox" /> + <label htmlFor="monthly-checkbox"> + {this.props.content.monthly_checkbox_label_text} + </label> + </div> + + <input type="hidden" name="frequency" value="single" /> + <input + type="hidden" + name="currency" + value={this.props.content.currency_code} + /> + <input + type="hidden" + name="presets" + value={fieldNames.map( + field => this.props.content[`donation_amount_${field}`] + )} + /> + <button + style={btnStyle} + type="submit" + className="ASRouterButton primary donation-form-url" + > + {this.props.content.button_label} + </button> + </form> + ); + } + + render() { + const textStyle = { + color: this.props.content.text_color, + backgroundColor: this.props.content.background_color, + }; + const customElement = ( + <em style={{ backgroundColor: this.props.content.highlight_color }} /> + ); + return ( + <SimpleSnippet + {...this.props} + className={this.props.content.test} + customElements={{ em: customElement }} + textStyle={textStyle} + extraContent={this.renderDonations()} + /> + ); + } +} + +export const EOYSnippet = props => { + const extendedContent = { + monthly_checkbox_label_text: "Make my donation monthly", + locale: "en-US", + currency_code: "usd", + selected_button: "donation_amount_second", + ...props.content, + }; + + return ( + <EOYSnippetBase {...props} content={extendedContent} form_method="GET" /> + ); +}; diff --git a/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json new file mode 100644 index 0000000000..d9b6728067 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json @@ -0,0 +1,171 @@ +{ + "title": "EOYSnippet", + "description": "Fundraising Snippet", + "version": "1.1.0", + "type": "object", + "definitions": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "richText": { + "description": "Text with HTML subset allowed: i, b, u, strong, em, br", + "type": "string" + }, + "link_url": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + }, + "properties": { + "donation_form_url": { + "type": "string", + "description": "Url to the donation form." + }, + "currency_code": { + "type": "string", + "description": "The code for the currency. Examle gbp, cad, usd.", + "default": "usd" + }, + "locale": { + "type": "string", + "description": "String for the locale code.", + "default": "en-US" + }, + "text": { + "allOf": [ + { "$ref": "#/definitions/richText" }, + { + "description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br" + } + ] + }, + "text_color": { + "type": "string", + "description": "Modify the text message color" + }, + "background_color": { + "type": "string", + "description": "Snippet background color." + }, + "highlight_color": { + "type": "string", + "description": "Paragraph em highlight color." + }, + "donation_amount_first": { + "type": "number", + "description": "First button amount." + }, + "donation_amount_second": { + "type": "number", + "description": "Second button amount." + }, + "donation_amount_third": { + "type": "number", + "description": "Third button amount." + }, + "donation_amount_fourth": { + "type": "number", + "description": "Fourth button amount." + }, + "selected_button": { + "type": "string", + "description": "Default donation_amount_second. Donation amount button that's selected by default.", + "default": "donation_amount_second" + }, + "icon": { + "type": "string", + "description": "Snippet icon. 64x64px. SVG or PNG preferred." + }, + "icon_dark_theme": { + "type": "string", + "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred." + }, + "icon_alt_text": { + "type": "string", + "description": "Alt text for accessibility", + "default": "" + }, + "title": { + "allOf": [ + { "$ref": "#/definitions/plainText" }, + { "description": "Snippet title displayed before snippet text" } + ] + }, + "title_icon": { + "type": "string", + "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale." + }, + "title_icon_dark_theme": { + "type": "string", + "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale." + }, + "button_label": { + "allOf": [ + { "$ref": "#/definitions/plainText" }, + { + "description": "Text for a button next to main snippet text that links to button_url. Requires button_url." + } + ] + }, + "button_color": { + "type": "string", + "description": "The text color of the button. Valid CSS color." + }, + "button_background_color": { + "type": "string", + "description": "The background color of the button. Valid CSS color." + }, + "block_button_text": { + "type": "string", + "description": "Tooltip text used for dismiss button." + }, + "monthly_checkbox_label_text": { + "type": "string", + "description": "Label text for monthly checkbox.", + "default": "Make my donation monthly" + }, + "test": { + "type": "string", + "description": "Different styles for the snippet. Options are bold and takeover." + }, + "do_not_autoblock": { + "type": "boolean", + "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked" + }, + "links": { + "additionalProperties": { + "url": { + "allOf": [ + { "$ref": "#/definitions/link_url" }, + { "description": "The url where the link points to." } + ] + }, + "metric": { + "type": "string", + "description": "Custom event name sent with telemetry event." + }, + "args": { + "type": "string", + "description": "Additional parameters for link action, example which specific menu the button should open" + } + } + } + }, + "additionalProperties": false, + "required": [ + "text", + "donation_form_url", + "donation_amount_first", + "donation_amount_second", + "donation_amount_third", + "donation_amount_fourth", + "button_label", + "currency_code" + ], + "dependencies": { + "button_color": ["button_label"], + "button_background_color": ["button_label"] + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/_EOYSnippet.scss b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/_EOYSnippet.scss new file mode 100644 index 0000000000..d9911ff02c --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/_EOYSnippet.scss @@ -0,0 +1,55 @@ +.EOYSnippetForm { + margin: 10px 0 8px; + align-self: start; + font-size: 14px; + display: flex; + align-items: center; + + .donation-amount, + .donation-form-url { + white-space: nowrap; + font-size: 14px; + padding: 8px 20px; + border-radius: 2px; + } + + .donation-amount { + color: var(--newtab-text-primary-color); + margin-inline-end: 18px; + border: $input-border; + padding: 5px 14px; + background: var(--newtab-background-color-secondary); + cursor: pointer; + } + + input { + &[type='radio'] { + opacity: 0; + margin-inline-end: -18px; + + &:checked + .donation-amount { + // Use a text color for the background to achieve an inverted look. + background: var(--newtab-text-secondary-color); + color: var(--newtab-background-color-secondary); + border: $border-secondary; + } + + // accessibility + &:checked:focus + .donation-amount, + &:not(:checked):focus + .donation-amount { + border: 1px dotted var(--newtab-primary-action-background); + } + } + } + + .monthly-checkbox-container { + display: flex; + width: 100%; + } + + .donation-form-url { + margin-inline-start: 18px; + align-self: flex-end; + display: flex; + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.jsx new file mode 100644 index 0000000000..1d8197d675 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.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 from "react"; +import { SubmitFormSnippet } from "../SubmitFormSnippet/SubmitFormSnippet.jsx"; + +export const FXASignupSnippet = props => { + const userAgent = window.navigator.userAgent.match(/Firefox\/([0-9]+)\./); + const firefox_version = userAgent ? parseInt(userAgent[1], 10) : 0; + const extendedContent = { + scene1_button_label: "Learn more", + retry_button_label: "Try again", + scene2_email_placeholder_text: "Your email here", + scene2_button_label: "Sign me up", + scene2_dismiss_button_text: "Dismiss", + ...props.content, + hidden_inputs: { + action: "email", + context: "fx_desktop_v3", + entrypoint: "snippets", + utm_source: "snippet", + utm_content: firefox_version, + utm_campaign: props.content.utm_campaign, + utm_term: props.content.utm_term, + ...props.content.hidden_inputs, + }, + }; + + return ( + <SubmitFormSnippet + {...props} + content={extendedContent} + form_action={"https://accounts.firefox.com/"} + form_method="GET" + /> + ); +}; diff --git a/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.schema.json new file mode 100644 index 0000000000..315aaba7a0 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.schema.json @@ -0,0 +1,196 @@ +{ + "title": "FXASignupSnippet", + "description": "A snippet template for FxA sign up/sign in", + "version": "1.2.0", + "type": "object", + "definitions": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "richText": { + "description": "Text with HTML subset allowed: i, b, u, strong, em, br", + "type": "string" + }, + "link_url": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + }, + "properties": { + "scene1_title": { + "allof": [ + { "$ref": "#/definitions/plainText" }, + { "description": "snippet title displayed before snippet text" } + ] + }, + "scene1_text": { + "allOf": [ + { "$ref": "#/definitions/richText" }, + { + "description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br" + } + ] + }, + "scene1_section_title_icon": { + "type": "string", + "description": "Section title icon for scene 1. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display." + }, + "scene1_section_title_icon_dark_theme": { + "type": "string", + "description": "Section title icon for scene 1, dark theme variant. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display." + }, + "scene1_section_title_text": { + "type": "string", + "description": "Section title text for scene 1. scene1_section_title_icon must also be specified to display." + }, + "scene1_section_title_url": { + "allOf": [ + { "$ref": "#/definitions/link_url" }, + { "description": "A url, scene1_section_title_text links to this" } + ] + }, + "scene2_title": { + "allOf": [ + { "$ref": "#/definitions/plainText" }, + { + "description": "Title displayed before text in scene 2. Should be plain text." + } + ] + }, + "scene2_text": { + "allOf": [ + { "$ref": "#/definitions/richText" }, + { + "description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br" + } + ] + }, + "scene1_icon": { + "type": "string", + "description": "Snippet icon. 64x64px. SVG or PNG preferred." + }, + "scene1_icon_dark_theme": { + "type": "string", + "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred." + }, + "scene1_title_icon": { + "type": "string", + "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale." + }, + "scene1_title_icon_dark_theme": { + "type": "string", + "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale." + }, + "scene2_email_placeholder_text": { + "type": "string", + "description": "Value to show while input is empty.", + "default": "Your email here" + }, + "scene2_button_label": { + "type": "string", + "description": "Label for form submit button", + "default": "Sign me up" + }, + "scene2_dismiss_button_text": { + "type": "string", + "description": "Label for the dismiss button when the sign-up form is expanded.", + "default": "Dismiss" + }, + "hidden_inputs": { + "type": "object", + "description": "Each entry represents a hidden input, key is used as value for the name property.", + "properties": { + "action": { + "type": "string", + "enum": ["email"] + }, + "context": { + "type": "string", + "enum": ["fx_desktop_v3"] + }, + "entrypoint": { + "type": "string", + "enum": ["snippets"] + }, + "utm_content": { + "type": "number", + "description": "Firefox version number" + }, + "utm_source": { + "type": "string", + "enum": ["snippet"] + }, + "utm_campaign": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_campaign." + }, + "utm_term": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_term." + }, + "additionalProperties": false + } + }, + "scene1_button_label": { + "allOf": [ + { "$ref": "#/definitions/plainText" }, + { + "description": "Text for a button next to main snippet text that links to button_url. Requires button_url." + } + ], + "default": "Learn more" + }, + "scene1_button_color": { + "type": "string", + "description": "The text color of the button. Valid CSS color." + }, + "scene1_button_background_color": { + "type": "string", + "description": "The background color of the button. Valid CSS color." + }, + "retry_button_label": { + "allOf": [ + { "$ref": "#/definitions/plainText" }, + { + "description": "Text for the button in the event of a submission error/failure." + } + ], + "default": "Try again" + }, + "do_not_autoblock": { + "type": "boolean", + "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked", + "default": false + }, + "utm_campaign": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_campaign." + }, + "utm_term": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_term." + }, + "links": { + "additionalProperties": { + "url": { + "allOf": [ + { "$ref": "#/definitions/link_url" }, + { "description": "The url where the link points to." } + ] + }, + "metric": { + "type": "string", + "description": "Custom event name sent with telemetry event." + } + } + } + }, + "additionalProperties": false, + "required": ["scene1_text", "scene2_text", "scene1_button_label"], + "dependencies": { + "scene1_button_color": ["scene1_button_label"], + "scene1_button_background_color": ["scene1_button_label"] + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/FirstRun/addUtmParams.js b/browser/components/newtab/content-src/asrouter/templates/FirstRun/addUtmParams.js new file mode 100644 index 0000000000..6fc4d2283a --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/FirstRun/addUtmParams.js @@ -0,0 +1,32 @@ +/* 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/. */ + +/** + * BASE_PARAMS keys/values can be modified from outside this file + */ +export const BASE_PARAMS = { + utm_source: "activity-stream", + utm_campaign: "firstrun", + utm_medium: "referral", +}; + +/** + * Takes in a url as a string or URL object and returns a URL object with the + * utm_* parameters added to it. If a URL object is passed in, the paraemeters + * are added to it (the return value can be ignored in that case as it's the + * same object). + */ +export function addUtmParams(url, utmTerm) { + let returnUrl = url; + if (typeof returnUrl === "string") { + returnUrl = new URL(url); + } + for (let [key, value] of Object.entries(BASE_PARAMS)) { + if (!returnUrl.searchParams.has(key)) { + returnUrl.searchParams.append(key, value); + } + } + returnUrl.searchParams.append("utm_term", utmTerm); + return returnUrl; +} diff --git a/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.jsx new file mode 100644 index 0000000000..27c1684762 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.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"; +import { SubmitFormSnippet } from "../SubmitFormSnippet/SubmitFormSnippet.jsx"; + +export const NewsletterSnippet = props => { + const extendedContent = { + scene1_button_label: "Learn more", + retry_button_label: "Try again", + scene2_email_placeholder_text: "Your email here", + scene2_button_label: "Sign me up", + scene2_dismiss_button_text: "Dismiss", + scene2_newsletter: "mozilla-foundation", + ...props.content, + hidden_inputs: { + newsletters: props.content.scene2_newsletter || "mozilla-foundation", + fmt: "H", + lang: props.content.locale || "en-US", + source_url: `https://snippets.mozilla.com/show/${props.id}`, + ...props.content.hidden_inputs, + }, + }; + + return ( + <SubmitFormSnippet + {...props} + content={extendedContent} + form_action={"https://basket.mozilla.org/subscribe.json"} + form_method="POST" + /> + ); +}; diff --git a/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.schema.json new file mode 100644 index 0000000000..c77261c191 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.schema.json @@ -0,0 +1,186 @@ +{ + "title": "NewsletterSnippet", + "description": "A snippet template for send to device mobile download", + "version": "1.2.0", + "type": "object", + "definitions": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "richText": { + "description": "Text with HTML subset allowed: i, b, u, strong, em, br", + "type": "string" + }, + "link_url": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + }, + "properties": { + "locale": { + "type": "string", + "description": "Two to five character string for the locale code", + "default": "en-US" + }, + "scene1_title": { + "allof": [ + { "$ref": "#/definitions/plainText" }, + { "description": "snippet title displayed before snippet text" } + ] + }, + "scene1_text": { + "allOf": [ + { "$ref": "#/definitions/richText" }, + { + "description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br" + } + ] + }, + "scene1_section_title_icon": { + "type": "string", + "description": "Section title icon for scene 1. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display." + }, + "scene1_section_title_icon_dark_theme": { + "type": "string", + "description": "Section title icon for scene 1, dark theme variant. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display." + }, + "scene1_section_title_text": { + "type": "string", + "description": "Section title text for scene 1. scene1_section_title_icon must also be specified to display." + }, + "scene1_section_title_url": { + "allOf": [ + { "$ref": "#/definitions/link_url" }, + { "description": "A url, scene1_section_title_text links to this" } + ] + }, + "scene2_title": { + "allOf": [ + { "$ref": "#/definitions/plainText" }, + { + "description": "Title displayed before text in scene 2. Should be plain text." + } + ] + }, + "scene2_text": { + "allOf": [ + { "$ref": "#/definitions/richText" }, + { + "description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br" + } + ] + }, + "scene1_icon": { + "type": "string", + "description": "Snippet icon. 64x64px. SVG or PNG preferred." + }, + "scene1_icon_dark_theme": { + "type": "string", + "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred." + }, + "scene1_title_icon": { + "type": "string", + "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale." + }, + "scene1_title_icon_dark_theme": { + "type": "string", + "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale." + }, + "scene2_email_placeholder_text": { + "type": "string", + "description": "Value to show while input is empty.", + "default": "Your email here" + }, + "scene2_button_label": { + "type": "string", + "description": "Label for form submit button", + "default": "Sign me up" + }, + "scene2_privacy_html": { + "type": "string", + "description": "(send to device) Html for disclaimer and link underneath input box." + }, + "scene2_dismiss_button_text": { + "type": "string", + "description": "Label for the dismiss button when the sign-up form is expanded.", + "default": "Dismiss" + }, + "hidden_inputs": { + "type": "object", + "description": "Each entry represents a hidden input, key is used as value for the name property.", + "properties": { + "fmt": { + "type": "string", + "description": "", + "default": "H" + } + } + }, + "scene1_button_label": { + "allOf": [ + { "$ref": "#/definitions/plainText" }, + { + "description": "Text for a button next to main snippet text that links to button_url. Requires button_url." + } + ], + "default": "Learn more" + }, + "scene1_button_color": { + "type": "string", + "description": "The text color of the button. Valid CSS color." + }, + "scene1_button_background_color": { + "type": "string", + "description": "The background color of the button. Valid CSS color." + }, + "retry_button_label": { + "allOf": [ + { "$ref": "#/definitions/plainText" }, + { + "description": "Text for the button in the event of a submission error/failure." + } + ], + "default": "Try again" + }, + "do_not_autoblock": { + "type": "boolean", + "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked", + "default": false + }, + "success_text": { + "type": "string", + "description": "Message shown on successful registration." + }, + "error_text": { + "type": "string", + "description": "Message shown if registration failed." + }, + "scene2_newsletter": { + "type": "string", + "description": "Newsletter/basket id user is subscribing to.", + "default": "mozilla-foundation" + }, + "links": { + "additionalProperties": { + "url": { + "allOf": [ + { "$ref": "#/definitions/link_url" }, + { "description": "The url where the link points to." } + ] + }, + "metric": { + "type": "string", + "description": "Custom event name sent with telemetry event." + } + } + } + }, + "additionalProperties": false, + "required": ["scene1_text", "scene2_text", "scene1_button_label"], + "dependencies": { + "scene1_button_color": ["scene1_button_label"], + "scene1_button_background_color": ["scene1_button_label"] + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ProtectionsPanelMessage.schema.json b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ProtectionsPanelMessage.schema.json new file mode 100644 index 0000000000..8ef9b802e1 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ProtectionsPanelMessage.schema.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///ProtectionsPanelMessage.schema.json", + "title": "ProtectionsPanelMessage", + "description": "A message shown in the protections panel.", + "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "title": { + "description": "The message title.", + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText" + }, + "body": { + "description": "The body of the message.", + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText" + }, + "link_text": { + "description": "The text of the call to action link.", + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText" + }, + "cta_type": { + "description": "The type of URL open action.", + "type": "string", + "enum": ["OPEN_URL", "OPEN_PROTECTION_REPORT", "OPEN_ABOUT_PAGE"] + }, + "cta_url": { + "description": "The URL to open when the call to action is clicked", + "type": "string", + "format": "moz-url-format" + }, + "cta_where": { + "description": "How to open the cta.", + "type": "string", + "enum": ["current", "tabshifted", "tab", "save", "window"] + } + }, + "dependantSchemas": { + "link_text": ["cta_type", "cta_url"], + "cta_type": ["link_text"], + "cta_url": ["link_text"], + "cta_where": ["link_text"] + }, + "additionalProperties": false, + "required": ["title", "body"] + }, + "template": { + "type": "string", + "const": "protections_panel" + }, + "trigger": { + "description": "An action to trigger potentially showing the message. The action ID `protectionsPanelOpen` is required.", + "const": { + "id": "protectionsPanelOpen" + } + } + }, + "required": ["content", "template", "trigger"], + "additionalProperties": true +} diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/Spotlight.schema.json b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/Spotlight.schema.json new file mode 100644 index 0000000000..5d5b98f594 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/Spotlight.schema.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///Spotlight.schema.json", + "title": "Spotlight", + "description": "A template with an image, title, content and two buttons.", + "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "template": { + "type": "string", + "description": "Specify the layout template for the Spotlight", + "const": "multistage" + }, + "backdrop": { + "type": "string", + "description": "Background css behind modal content" + }, + "logo": { + "type": "object", + "properties": { + "imageURL": { + "type": "string", + "description": "URL for image to use with the content" + }, + "imageId": { + "type": "string", + "description": "The ID for a remotely hosted image" + }, + "size": { + "type": "string", + "description": "The logo size." + } + }, + "additionalProperties": true + }, + "screens": { + "type": "array", + "description": "Collection of individual screen content" + }, + "transitions": { + "type": "boolean", + "description": "Show transitions within and between screens" + }, + "disableHistoryUpdates": { + "type": "boolean", + "description": "Don't alter the browser session's history stack - used with messaging surfaces like Feature Callouts" + }, + "startScreen": { + "type": "integer", + "description": "Index of first screen to show from message, defaulting to 0" + } + }, + "additionalProperties": true + }, + "template": { + "type": "string", + "description": "Specify whether the surface is shown as a Spotlight modal or an in-surface Feature Callout dialog", + "enum": ["spotlight", "feature_callout"] + } + }, + "additionalProperties": true, + "required": ["targeting"] +} diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json new file mode 100644 index 0000000000..4ec7dc9522 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///ToolbarBadgeMessage.schema.json", + "title": "ToolbarBadgeMessage", + "description": "A template that specifies to which element in the browser toolbar to add a notification.", + "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "target": { + "type": "string" + }, + "action": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "additionalProperties": true, + "required": ["id"], + "description": "Optional action to take in addition to showing the notification" + }, + "delay": { + "type": "number", + "description": "Optional delay in ms after which to show the notification" + }, + "badgeDescription": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizedText", + "description": "This is used in combination with the badged button to offer a text based alternative to the visual badging. Example 'New Feature: What's New'" + } + }, + "additionalProperties": true, + "required": ["target"] + }, + "template": { + "type": "string", + "const": "toolbar_badge" + } + }, + "additionalProperties": true, + "required": ["targeting"] +} diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json new file mode 100644 index 0000000000..c5a466a6e5 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///UpdateAction.schema.json", + "title": "UpdateActionMessage", + "description": "A template for messages that execute predetermined actions.", + "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "action": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "data": { + "type": "object", + "description": "Additional data provided as argument when executing the action", + "properties": { + "url": { + "type": "string", + "description": "URL data to be used as argument to the action" + }, + "expireDelta": { + "type": "number", + "description": "Expiration timestamp to be used as argument to the action" + } + } + } + }, + "additionalProperties": true, + "description": "Optional action to take in addition to showing the notification", + "required": ["id", "data"] + } + }, + "additionalProperties": true, + "required": ["action"] + }, + "template": { + "type": "string", + "const": "update_action" + } + }, + "required": ["targeting"] +} diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json new file mode 100644 index 0000000000..26e795d068 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///WhatsNewMessage.schema.json", + "title": "WhatsNewMessage", + "description": "A template for the messages that appear in the What's New panel.", + "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "layout": { + "description": "Different message layouts", + "enum": ["tracking-protections"] + }, + "bucket_id": { + "type": "string", + "description": "A bucket identifier for the addon. This is used in order to anonymize telemetry for history-sensitive targeting." + }, + "published_date": { + "type": "integer", + "description": "The date/time (number of milliseconds elapsed since January 1, 1970 00:00:00 UTC) the message was published." + }, + "title": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of What's New message title" + }, + "subtitle": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of What's New message subtitle" + }, + "body": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of What's New message body" + }, + "link_text": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "(optional) Id of localized string or message override of What's New message link text" + }, + "cta_url": { + "description": "Target URL for the What's New message.", + "type": "string", + "format": "moz-url-format" + }, + "cta_type": { + "description": "Type of url open action", + "enum": ["OPEN_URL", "OPEN_ABOUT_PAGE", "OPEN_PROTECTION_REPORT"] + }, + "cta_where": { + "description": "How to open the cta: new window, tab, focused, unfocused.", + "enum": ["current", "tabshifted", "tab", "save", "window"] + }, + "icon_url": { + "description": "(optional) URL for the What's New message icon.", + "type": "string", + "format": "uri" + }, + "icon_alt": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "Alt text for image." + } + }, + "additionalProperties": true, + "required": ["published_date", "title", "body", "cta_url", "bucket_id"] + }, + "template": { + "type": "string", + "const": "whatsnew_panel_message" + } + }, + "required": ["order"], + "additionalProperties": true +} diff --git a/browser/components/newtab/content-src/asrouter/templates/PBNewtab/NewtabPromoMessage.schema.json b/browser/components/newtab/content-src/asrouter/templates/PBNewtab/NewtabPromoMessage.schema.json new file mode 100644 index 0000000000..3719419428 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/PBNewtab/NewtabPromoMessage.schema.json @@ -0,0 +1,153 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///NewtabPromoMessage.schema.json", + "title": "PBNewtabPromoMessage", + "description": "Message shown on the private browsing newtab page.", + "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "hideDefault": { + "type": "boolean", + "description": "Should we hide the default promo after the experiment promo is dismissed." + }, + "infoEnabled": { + "type": "boolean", + "description": "Should we show the info section." + }, + "infoIcon": { + "type": "string", + "description": "Icon shown in the left side of the info section. Default is the private browsing icon." + }, + "infoTitle": { + "type": "string", + "description": "Is the title in the info section enabled." + }, + "infoTitleEnabled": { + "type": "boolean", + "description": "Is the title in the info section enabled." + }, + "infoBody": { + "type": "string", + "description": "Text content in the info section." + }, + "infoLinkText": { + "type": "string", + "description": "Text for the link in the info section." + }, + "infoLinkUrl": { + "type": "string", + "description": "URL for the info section link.", + "format": "moz-url-format" + }, + "promoEnabled": { + "type": "boolean", + "description": "Should we show the promo section." + }, + "promoType": { + "type": "string", + "description": "Promo type used to determine if promo should show to a given user", + "enum": ["FOCUS", "VPN", "PIN", "COOKIE_BANNERS", "OTHER"] + }, + "promoSectionStyle": { + "type": "string", + "description": "Sets the position of the promo section. Possible values are: top, below-search, bottom. Default bottom.", + "enum": ["top", "below-search", "bottom"] + }, + "promoTitle": { + "type": "string", + "description": "The text content of the promo section." + }, + "promoTitleEnabled": { + "type": "boolean", + "description": "Should we show text content in the promo section." + }, + "promoLinkText": { + "type": "string", + "description": "The text of the link in the promo box." + }, + "promoHeader": { + "type": "string", + "description": "The title of the promo section." + }, + "promoButton": { + "type": "object", + "properties": { + "action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Action dispatched by the button." + }, + "data": { + "type": "object" + } + }, + "required": ["type"], + "additionalProperties": true + } + }, + "required": ["action"] + }, + "promoLinkType": { + "type": "string", + "description": "Type of promo link type. Possible values: link, button. Default is link.", + "enum": ["link", "button"] + }, + "promoImageLarge": { + "type": "string", + "description": "URL for image used on the left side of the promo box, larger, showcases some feature. Default off.", + "format": "uri" + }, + "promoImageSmall": { + "type": "string", + "description": "URL for image used on the right side of the promo box, smaller, usually a logo. Default off.", + "format": "uri" + } + }, + "additionalProperties": true, + "allOf": [ + { + "if": { + "properties": { + "promoEnabled": { "const": true } + }, + "required": ["promoEnabled"] + }, + "then": { + "required": ["promoButton"] + } + }, + { + "if": { + "properties": { + "infoEnabled": { "const": true } + }, + "required": ["infoEnabled"] + }, + "then": { + "required": ["infoLinkText"], + "if": { + "properties": { + "infoTitleEnabled": { "const": true } + }, + "required": ["infoTitleEnabled"] + }, + "then": { + "required": ["infoTitle"] + } + } + } + ] + }, + "template": { + "type": "string", + "const": "pb_newtab" + } + }, + "additionalProperties": true, + "required": ["targeting"] +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.jsx new file mode 100644 index 0000000000..0929b8f711 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.jsx @@ -0,0 +1,76 @@ +/* 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 { isEmailOrPhoneNumber } from "./isEmailOrPhoneNumber"; +import React from "react"; +import { SubmitFormSnippet } from "../SubmitFormSnippet/SubmitFormSnippet.jsx"; + +function validateInput(value, content) { + const type = isEmailOrPhoneNumber(value, content); + return type ? "" : "Must be an email or a phone number."; +} + +function processFormData(input, message) { + const { content } = message; + const type = content.include_sms + ? isEmailOrPhoneNumber(input.value, content) + : "email"; + const formData = new FormData(); + let url; + if (type === "phone") { + url = "https://basket.mozilla.org/news/subscribe_sms/"; + formData.append("mobile_number", input.value); + formData.append("msg_name", content.message_id_sms); + formData.append("country", content.country); + } else if (type === "email") { + url = "https://basket.mozilla.org/news/subscribe/"; + formData.append("email", input.value); + formData.append("newsletters", content.message_id_email); + formData.append( + "source_url", + encodeURIComponent(`https://snippets.mozilla.com/show/${message.id}`) + ); + } + formData.append("lang", content.locale); + return { formData, url }; +} + +function addDefaultValues(props) { + return { + ...props, + content: { + scene1_button_label: "Learn more", + retry_button_label: "Try again", + scene2_dismiss_button_text: "Dismiss", + scene2_button_label: "Send", + scene2_input_placeholder: "Your email here", + locale: "en-US", + country: "us", + message_id_email: "", + include_sms: false, + ...props.content, + }, + }; +} + +export const SendToDeviceSnippet = props => { + const propsWithDefaults = addDefaultValues(props); + + return ( + <SubmitFormSnippet + {...propsWithDefaults} + form_method="POST" + className="send_to_device_snippet" + inputType={propsWithDefaults.content.include_sms ? "text" : "email"} + validateInput={ + propsWithDefaults.content.include_sms ? validateInput : null + } + processFormData={processFormData} + /> + ); +}; + +export const SendToDeviceScene2Snippet = props => { + return <SendToDeviceSnippet expandedAlt={true} {...props} />; +}; diff --git a/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.schema.json new file mode 100644 index 0000000000..34567443f4 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.schema.json @@ -0,0 +1,243 @@ +{ + "title": "SendToDeviceSnippet", + "description": "A snippet template for send to device mobile download", + "version": "1.2.0", + "type": "object", + "definitions": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "richText": { + "description": "Text with HTML subset allowed: i, b, u, strong, em, br", + "type": "string" + }, + "link_url": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + }, + "properties": { + "locale": { + "type": "string", + "description": "Two to five character string for the locale code", + "default": "en-US" + }, + "country": { + "type": "string", + "description": "Two character string for the country code (used for SMS)", + "default": "us" + }, + "scene1_title": { + "allof": [ + { "$ref": "#/definitions/plainText" }, + { "description": "snippet title displayed before snippet text" } + ] + }, + "scene1_text": { + "allOf": [ + { "$ref": "#/definitions/richText" }, + { + "description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br" + } + ] + }, + "scene1_section_title_icon": { + "type": "string", + "description": "Section title icon for scene 1. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display." + }, + "scene1_section_title_icon_dark_theme": { + "type": "string", + "description": "Section title icon for scene 1, dark theme variant. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display." + }, + "scene1_section_title_text": { + "type": "string", + "description": "Section title text for scene 1. scene1_section_title_icon must also be specified to display." + }, + "scene1_section_title_url": { + "allOf": [ + { "$ref": "#/definitions/link_url" }, + { "description": "A url, scene1_section_title_text links to this" } + ] + }, + "scene2_title": { + "allOf": [ + { "$ref": "#/definitions/plainText" }, + { + "description": "Title displayed before text in scene 2. Should be plain text." + } + ] + }, + "scene2_text": { + "allOf": [ + { "$ref": "#/definitions/richText" }, + { + "description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br" + } + ] + }, + "scene1_icon": { + "type": "string", + "description": "Snippet icon. 64x64px. SVG or PNG preferred." + }, + "scene1_icon_dark_theme": { + "type": "string", + "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred." + }, + "scene2_icon": { + "type": "string", + "description": "(send to device) Image to display above the form. Dark theme variant. 98x98px. SVG or PNG preferred." + }, + "scene2_icon_dark_theme": { + "type": "string", + "description": "(send to device) Image to display above the form. 98x98px. SVG or PNG preferred." + }, + "scene1_title_icon": { + "type": "string", + "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale." + }, + "scene1_title_icon_dark_theme": { + "type": "string", + "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale." + }, + "scene2_button_label": { + "type": "string", + "description": "Label for form submit button", + "default": "Send" + }, + "scene2_input_placeholder": { + "type": "string", + "description": "(send to device) Value to show while input is empty.", + "default": "Your email here" + }, + "scene2_disclaimer_html": { + "type": "string", + "description": "(send to device) Html for disclaimer and link underneath input box." + }, + "scene2_dismiss_button_text": { + "type": "string", + "description": "Label for the dismiss button when the sign-up form is expanded.", + "default": "Dismiss" + }, + "hidden_inputs": { + "type": "object", + "description": "Each entry represents a hidden input, key is used as value for the name property.", + "properties": { + "action": { + "type": "string", + "enum": ["email"] + }, + "context": { + "type": "string", + "enum": ["fx_desktop_v3"] + }, + "entrypoint": { + "type": "string", + "enum": ["snippets"] + }, + "utm_content": { + "type": "string", + "description": "Firefox version number" + }, + "utm_source": { + "type": "string", + "enum": ["snippet"] + }, + "utm_campaign": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_campaign." + }, + "utm_term": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_term." + }, + "additionalProperties": false + } + }, + "scene1_button_label": { + "allOf": [ + { "$ref": "#/definitions/plainText" }, + { + "description": "Text for a button next to main snippet text that links to button_url. Requires button_url." + } + ], + "default": "Learn more" + }, + "scene1_button_color": { + "type": "string", + "description": "The text color of the button. Valid CSS color." + }, + "scene1_button_background_color": { + "type": "string", + "description": "The background color of the button. Valid CSS color." + }, + "retry_button_label": { + "allOf": [ + { "$ref": "#/definitions/plainText" }, + { + "description": "Text for the button in the event of a submission error/failure." + } + ], + "default": "Try again" + }, + "do_not_autoblock": { + "type": "boolean", + "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked", + "default": false + }, + "success_title": { + "type": "string", + "description": "(send to device) Title shown before text on successful registration." + }, + "success_text": { + "type": "string", + "description": "Message shown on successful registration." + }, + "error_text": { + "type": "string", + "description": "Message shown if registration failed." + }, + "include_sms": { + "type": "boolean", + "description": "(send to device) Allow users to send an SMS message with the form?", + "default": false + }, + "message_id_sms": { + "type": "string", + "description": "(send to device) Newsletter/basket id representing the SMS message to be sent." + }, + "message_id_email": { + "type": "string", + "description": "(send to device) Newsletter/basket id representing the email message to be sent. Must be a value from the 'Slug' column here: https://basket.mozilla.org/news/." + }, + "utm_campaign": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_campaign." + }, + "utm_term": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_term." + }, + "links": { + "additionalProperties": { + "url": { + "allOf": [ + { "$ref": "#/definitions/link_url" }, + { "description": "The url where the link points to." } + ] + }, + "metric": { + "type": "string", + "description": "Custom event name sent with telemetry event." + } + } + } + }, + "additionalProperties": false, + "required": ["scene1_text", "scene2_text", "scene1_button_label"], + "dependencies": { + "scene1_button_color": ["scene1_button_label"], + "scene1_button_background_color": ["scene1_button_label"] + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/isEmailOrPhoneNumber.js b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/isEmailOrPhoneNumber.js new file mode 100644 index 0000000000..44ef622227 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/isEmailOrPhoneNumber.js @@ -0,0 +1,39 @@ +/* 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/. */ + +/** + * Checks if a given string is an email or phone number or neither + * @param {string} val The user input + * @param {ASRMessageContent} content .content property on ASR message + * @returns {"email"|"phone"|""} The type of the input + */ +export function isEmailOrPhoneNumber(val, content) { + const { locale } = content; + // http://emailregex.com/ + const email_re = + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + const check_email = email_re.test(val); + let check_phone; // depends on locale + switch (locale) { + case "en-US": + case "en-CA": + // allow 10-11 digits in case user wants to enter country code + check_phone = val.length >= 10 && val.length <= 11 && !isNaN(val); + break; + case "de": + // allow between 2 and 12 digits for german phone numbers + check_phone = val.length >= 2 && val.length <= 12 && !isNaN(val); + break; + // this case should never be hit, but good to have a fallback just in case + default: + check_phone = !isNaN(val); + break; + } + if (check_email) { + return "email"; + } else if (check_phone) { + return "phone"; + } + return ""; +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.jsx new file mode 100644 index 0000000000..2641d51e86 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.jsx @@ -0,0 +1,133 @@ +/* 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 { Button } from "../../components/Button/Button"; +import { RichText } from "../../components/RichText/RichText"; +import { safeURI } from "../../template-utils"; +import { SnippetBase } from "../../components/SnippetBase/SnippetBase"; + +const DEFAULT_ICON_PATH = "chrome://branding/content/icon64.png"; +// Alt text placeholder in case the prop from the server isn't available +const ICON_ALT_TEXT = ""; + +export class SimpleBelowSearchSnippet extends React.PureComponent { + constructor(props) { + super(props); + this.onButtonClick = this.onButtonClick.bind(this); + } + + renderText() { + const { props } = this; + return props.content.text ? ( + <RichText + text={props.content.text} + customElements={this.props.customElements} + localization_id="text" + links={props.content.links} + sendClick={props.sendClick} + /> + ) : null; + } + + renderTitle() { + const { title } = this.props.content; + return title ? ( + <h3 className={"title title-inline"}> + {title} + <br /> + </h3> + ) : null; + } + + async onButtonClick() { + if (this.props.provider !== "preview") { + this.props.sendUserActionTelemetry({ + event: "CLICK_BUTTON", + id: this.props.UISurface, + }); + } + const { button_url } = this.props.content; + // If button_url is defined handle it as OPEN_URL action + const type = this.props.content.button_action || (button_url && "OPEN_URL"); + await this.props.onAction({ + type, + data: { args: this.props.content.button_action_args || button_url }, + }); + if (!this.props.content.do_not_autoblock) { + this.props.onBlock(); + } + } + + _shouldRenderButton() { + return ( + this.props.content.button_action || + this.props.onButtonClick || + this.props.content.button_url + ); + } + + renderButton() { + const { props } = this; + if (!this._shouldRenderButton()) { + return null; + } + + return ( + <Button + onClick={props.onButtonClick || this.onButtonClick} + color={props.content.button_color} + backgroundColor={props.content.button_background_color} + > + {props.content.button_label} + </Button> + ); + } + + render() { + const { props } = this; + let className = "SimpleBelowSearchSnippet"; + let containerName = "below-search-snippet"; + + if (props.className) { + className += ` ${props.className}`; + } + if (this._shouldRenderButton()) { + className += " withButton"; + containerName += " withButton"; + } + + return ( + <div className={containerName}> + <div className="snippet-hover-wrapper"> + <SnippetBase + {...props} + className={className} + textStyle={this.props.textStyle} + > + <img + src={safeURI(props.content.icon) || DEFAULT_ICON_PATH} + className="icon icon-light-theme" + alt={props.content.icon_alt_text || ICON_ALT_TEXT} + /> + <img + src={ + safeURI(props.content.icon_dark_theme || props.content.icon) || + DEFAULT_ICON_PATH + } + className="icon icon-dark-theme" + alt={props.content.icon_alt_text || ICON_ALT_TEXT} + /> + <div className="textContainer"> + {this.renderTitle()} + <p className="body">{this.renderText()}</p> + {this.props.extraContent} + </div> + {<div className="buttonContainer">{this.renderButton()}</div>} + </SnippetBase> + </div> + </div> + ); + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json new file mode 100644 index 0000000000..06368257f0 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json @@ -0,0 +1,114 @@ +{ + "title": "SimpleBelowSearchSnippet", + "description": "A simple template with an icon, rich text and an optional button. It gets inserted below the Activity Stream search box.", + "version": "1.2.0", + "type": "object", + "definitions": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "richText": { + "description": "Text with HTML subset allowed: i, b, u, strong, em, br", + "type": "string" + }, + "link_url": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + }, + "properties": { + "title": { + "allOf": [ + { "$ref": "#/definitions/plainText" }, + { "description": "Snippet title displayed before snippet text" } + ] + }, + "text": { + "allOf": [ + { "$ref": "#/definitions/richText" }, + { + "description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br" + } + ] + }, + "icon": { + "type": "string", + "description": "Snippet icon. 64x64px. SVG or PNG preferred." + }, + "icon_dark_theme": { + "type": "string", + "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred." + }, + "icon_alt_text": { + "type": "string", + "description": "Alt text describing icon for screen readers", + "default": "" + }, + "block_button_text": { + "type": "string", + "description": "Tooltip text used for dismiss button.", + "default": "Remove this" + }, + "button_action": { + "type": "string", + "description": "The type of action the button should trigger." + }, + "button_url": { + "allOf": [ + { "$ref": "#/definitions/link_url" }, + { "description": "A url, button_label links to this" } + ] + }, + "button_action_args": { + "description": "Additional parameters for button action, example which specific menu the button should open" + }, + "button_label": { + "allOf": [ + { "$ref": "#/definitions/plainText" }, + { + "description": "Text for a button next to main snippet text that links to button_url. Requires button_url." + } + ] + }, + "button_color": { + "type": "string", + "description": "The text color of the button. Valid CSS color." + }, + "button_background_color": { + "type": "string", + "description": "The background color of the button. Valid CSS color." + }, + "do_not_autoblock": { + "type": "boolean", + "description": "Used to prevent blocking the snippet after the CTA link has been clicked" + }, + "links": { + "additionalProperties": { + "url": { + "allOf": [ + { "$ref": "#/definitions/link_url" }, + { "description": "The url where the link points to." } + ] + }, + "metric": { + "type": "string", + "description": "Custom event name sent with telemetry event." + }, + "args": { + "type": "string", + "description": "Additional parameters for link action, example which specific menu the button should open" + } + } + } + }, + "additionalProperties": false, + "required": ["text"], + "dependencies": { + "button_action": ["button_label"], + "button_url": ["button_label"], + "button_color": ["button_label"], + "button_background_color": ["button_label"] + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/_SimpleBelowSearchSnippet.scss b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/_SimpleBelowSearchSnippet.scss new file mode 100644 index 0000000000..9d902b4cbb --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/_SimpleBelowSearchSnippet.scss @@ -0,0 +1,190 @@ + +.below-search-snippet { + margin: 0 auto 16px; + + &.withButton { + margin: auto; + min-height: 60px; + background-color: transparent; + + .snippet-hover-wrapper { + min-height: 60px; + border-radius: 4px; + + &:hover { + background-color: var(--newtab-element-hover-color); + + .blockButton { + display: block; + opacity: 1; + } + } + } + } +} + +.SimpleBelowSearchSnippet { + background-color: transparent; + border: 0; + box-shadow: none; + position: relative; + margin: auto; + z-index: auto; + + @media (min-width: $break-point-large) { + width: 736px; + } + + &.active { + background-color: var(--newtab-element-hover-color); + border-radius: 4px; + } + + .innerWrapper { + align-items: center; + background-color: transparent; + border-radius: 4px; + box-shadow: $shadow-card; + flex-direction: column; + padding: 16px; + text-align: center; + width: 100%; + + @media (min-width: $break-point-medium) { + align-items: flex-start; + background-color: transparent; + border-radius: 4px; + box-shadow: none; + flex-direction: row; + padding: 0; + text-align: inherit; + width: 696px; + } + + @media (max-width: 865px) { + margin-inline-start: 0; + } + + // There is an off-by-one gap between breakpoints; this is to prevent weirdness at exactly 610px. + @media (max-width: $break-point-medium - 1px) { + margin: auto; + } + } + + .blockButton { + display: block; + inset-inline-end: 10px; + opacity: 1; + top: 50%; + + &:focus { + box-shadow: $shadow-primary; + border-radius: 2px; + } + } + + .title { + font-size: inherit; + margin: 0; + } + + .title-inline { + display: inline; + } + + .textContainer { + margin: 10px; + margin-inline-start: 0; + padding-inline-end: 20px; + } + + .icon { + margin-top: 8px; + margin-inline-start: 12px; + height: 32px; + width: 32px; + + @media (min-width: $break-point-medium) { + height: 24px; + width: 24px; + } + + @media (max-width: $break-point-medium) { + margin: auto; + } + } + + &.withButton { + line-height: 20px; + margin-bottom: 10px; + min-height: 60px; + background-color: transparent; + + .innerWrapper { + // There is an off-by-one gap between breakpoints; this is to prevent weirdness at exactly 1121px. + @media (max-width: $break-point-widest + 1px) { + margin: 0 40px; + } + } + + .blockButton { + display: block; + inset-inline-end: -10%; + opacity: 0; + margin: auto; + top: unset; + + &:focus { + opacity: 1; + box-shadow: none; + } + + // There is an off-by-one gap between breakpoints; this is to prevent weirdness at exactly 1121px. + @media (max-width: $break-point-widest + 1px) { + inset-inline-end: 2%; + } + } + + .icon { + width: 42px; + height: 42px; + flex-shrink: 0; + margin: auto 0; + margin-inline-end: 10px; + + @media (max-width: $break-point-medium) { + margin: auto; + } + } + + .buttonContainer { + margin: auto; + margin-inline-end: 0; + + @media (max-width: $break-point-medium) { + margin: auto; + } + } + } + + button { + @media (max-width: $break-point-medium) { + margin: auto; + } + } + + .body { + display: inline; + position: sticky; + transform: translateY(-50%); + margin: 8px 0 0; + + @media (min-width: $break-point-medium) { + margin: 12px 0; + } + + a { + font-weight: 600; + } + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx new file mode 100644 index 0000000000..96570e2dbd --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx @@ -0,0 +1,222 @@ +/* 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 { Button } from "../../components/Button/Button"; +import ConditionalWrapper from "../../components/ConditionalWrapper/ConditionalWrapper"; +import React from "react"; +import { RichText } from "../../components/RichText/RichText"; +import { safeURI } from "../../template-utils"; +import { SnippetBase } from "../../components/SnippetBase/SnippetBase"; + +const DEFAULT_ICON_PATH = "chrome://branding/content/icon64.png"; +// Alt text placeholder in case the prop from the server isn't available +const ICON_ALT_TEXT = ""; + +export class SimpleSnippet extends React.PureComponent { + constructor(props) { + super(props); + this.onButtonClick = this.onButtonClick.bind(this); + } + + onButtonClick() { + if (this.props.provider !== "preview") { + this.props.sendUserActionTelemetry({ + event: "CLICK_BUTTON", + id: this.props.UISurface, + }); + } + const { button_url, button_entrypoint_value, button_entrypoint_name } = + this.props.content; + // If button_url is defined handle it as OPEN_URL action + const type = this.props.content.button_action || (button_url && "OPEN_URL"); + // Assign the snippet referral for the action + const entrypoint = button_entrypoint_name + ? new URLSearchParams([ + [button_entrypoint_name, button_entrypoint_value], + ]).toString() + : button_entrypoint_value; + this.props.onAction({ + type, + data: { + args: this.props.content.button_action_args || button_url, + ...(entrypoint && { entrypoint }), + }, + }); + if (!this.props.content.do_not_autoblock) { + this.props.onBlock(); + } + } + + _shouldRenderButton() { + return ( + this.props.content.button_action || + this.props.onButtonClick || + this.props.content.button_url + ); + } + + renderTitle() { + const { title } = this.props.content; + return title ? ( + <h3 + className={`title ${this._shouldRenderButton() ? "title-inline" : ""}`} + > + {this.renderTitleIcon()} {title} + </h3> + ) : null; + } + + renderTitleIcon() { + const titleIconLight = safeURI(this.props.content.title_icon); + const titleIconDark = safeURI( + this.props.content.title_icon_dark_theme || this.props.content.title_icon + ); + if (!titleIconLight) { + return null; + } + + return ( + <React.Fragment> + <span + className="titleIcon icon-light-theme" + style={{ backgroundImage: `url("${titleIconLight}")` }} + /> + <span + className="titleIcon icon-dark-theme" + style={{ backgroundImage: `url("${titleIconDark}")` }} + /> + </React.Fragment> + ); + } + + renderButton() { + const { props } = this; + if (!this._shouldRenderButton()) { + return null; + } + + return ( + <Button + onClick={props.onButtonClick || this.onButtonClick} + color={props.content.button_color} + backgroundColor={props.content.button_background_color} + > + {props.content.button_label} + </Button> + ); + } + + renderText() { + const { props } = this; + return ( + <RichText + text={props.content.text} + customElements={this.props.customElements} + localization_id="text" + links={props.content.links} + sendClick={props.sendClick} + /> + ); + } + + wrapSectionHeader(url) { + return function (children) { + return <a href={url}>{children}</a>; + }; + } + + wrapSnippetContent(children) { + return <div className="innerContentWrapper">{children}</div>; + } + + renderSectionHeader() { + const { props } = this; + + // an icon and text must be specified to render the section header + if (props.content.section_title_icon && props.content.section_title_text) { + const sectionTitleIconLight = safeURI(props.content.section_title_icon); + const sectionTitleIconDark = safeURI( + props.content.section_title_icon_dark_theme || + props.content.section_title_icon + ); + const sectionTitleURL = props.content.section_title_url; + + return ( + <div className="section-header"> + <h3 className="section-title"> + <ConditionalWrapper + condition={sectionTitleURL} + wrap={this.wrapSectionHeader(sectionTitleURL)} + > + <span + className="icon icon-small-spacer icon-light-theme" + style={{ backgroundImage: `url("${sectionTitleIconLight}")` }} + /> + <span + className="icon icon-small-spacer icon-dark-theme" + style={{ backgroundImage: `url("${sectionTitleIconDark}")` }} + /> + <span className="section-title-text"> + {props.content.section_title_text} + </span> + </ConditionalWrapper> + </h3> + </div> + ); + } + + return null; + } + + render() { + const { props } = this; + const sectionHeader = this.renderSectionHeader(); + let className = "SimpleSnippet"; + + if (props.className) { + className += ` ${props.className}`; + } + if (props.content.tall) { + className += " tall"; + } + if (sectionHeader) { + className += " has-section-header"; + } + + return ( + <div className="snippet-hover-wrapper"> + <SnippetBase + {...props} + className={className} + textStyle={this.props.textStyle} + > + {sectionHeader} + <ConditionalWrapper + condition={sectionHeader} + wrap={this.wrapSnippetContent} + > + <img + src={safeURI(props.content.icon) || DEFAULT_ICON_PATH} + className="icon icon-light-theme" + alt={props.content.icon_alt_text || ICON_ALT_TEXT} + /> + <img + src={ + safeURI(props.content.icon_dark_theme || props.content.icon) || + DEFAULT_ICON_PATH + } + className="icon icon-dark-theme" + alt={props.content.icon_alt_text || ICON_ALT_TEXT} + /> + <div> + {this.renderTitle()} <p className="body">{this.renderText()}</p> + {this.props.extraContent} + </div> + {<div>{this.renderButton()}</div>} + </ConditionalWrapper> + </SnippetBase> + </div> + ); + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json new file mode 100644 index 0000000000..4970b124af --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json @@ -0,0 +1,159 @@ +{ + "title": "SimpleSnippet", + "description": "A simple template with an icon, text, and optional button.", + "version": "1.1.2", + "type": "object", + "definitions": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "richText": { + "description": "Text with HTML subset allowed: i, b, u, strong, em, br", + "type": "string" + }, + "link_url": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + }, + "properties": { + "title": { + "allOf": [ + { "$ref": "#/definitions/plainText" }, + { "description": "Snippet title displayed before snippet text" } + ] + }, + "text": { + "allOf": [ + { "$ref": "#/definitions/richText" }, + { + "description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br" + } + ] + }, + "icon": { + "type": "string", + "description": "Snippet icon. 64x64px. SVG or PNG preferred." + }, + "icon_dark_theme": { + "type": "string", + "description": "Snippet icon, dark theme variant. 64x64px. SVG or PNG preferred." + }, + "icon_alt_text": { + "type": "string", + "description": "Alt text describing icon for screen readers", + "default": "" + }, + "title_icon": { + "type": "string", + "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale." + }, + "title_icon_dark_theme": { + "type": "string", + "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale." + }, + "title_icon_alt_text": { + "type": "string", + "description": "Alt text describing title icon for screen readers", + "default": "" + }, + "button_action": { + "type": "string", + "description": "The type of action the button should trigger." + }, + "button_url": { + "allOf": [ + { "$ref": "#/definitions/link_url" }, + { "description": "A url, button_label links to this" } + ] + }, + "button_action_args": { + "description": "Additional parameters for button action, example which specific menu the button should open" + }, + "button_entrypoint_value": { + "description": "String used for telemetry attribution of clicks", + "type": "string" + }, + "button_entrypoint_name": { + "description": "String used for telemetry attribution of clicks", + "type": "string" + }, + "button_label": { + "allOf": [ + { "$ref": "#/definitions/plainText" }, + { + "description": "Text for a button next to main snippet text that links to button_url. Requires button_url." + } + ] + }, + "button_color": { + "type": "string", + "description": "The text color of the button. Valid CSS color." + }, + "button_background_color": { + "type": "string", + "description": "The background color of the button. Valid CSS color." + }, + "block_button_text": { + "type": "string", + "description": "Tooltip text used for dismiss button.", + "default": "Remove this" + }, + "tall": { + "type": "boolean", + "description": "To be used by fundraising only, increases height to roughly 120px. Defaults to false." + }, + "do_not_autoblock": { + "type": "boolean", + "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked" + }, + "links": { + "additionalProperties": { + "url": { + "allOf": [ + { "$ref": "#/definitions/link_url" }, + { "description": "The url where the link points to." } + ] + }, + "metric": { + "type": "string", + "description": "Custom event name sent with telemetry event." + }, + "args": { + "type": "string", + "description": "Additional parameters for link action, example which specific menu the button should open" + } + } + }, + "section_title_icon": { + "type": "string", + "description": "Section title icon. 16x16px. SVG or PNG preferred. section_title_text must also be specified to display." + }, + "section_title_icon_dark_theme": { + "type": "string", + "description": "Section title icon, dark theme variant. 16x16px. SVG or PNG preferred. section_title_text must also be specified to display." + }, + "section_title_text": { + "type": "string", + "description": "Section title text. section_title_icon must also be specified to display." + }, + "section_title_url": { + "allOf": [ + { "$ref": "#/definitions/link_url" }, + { "description": "A url, section_title_text links to this" } + ] + } + }, + "additionalProperties": false, + "required": ["text"], + "dependencies": { + "button_action": ["button_label"], + "button_url": ["button_label"], + "button_color": ["button_label"], + "button_background_color": ["button_label"], + "section_title_url": ["section_title_text"], + "button_entrypoint_name": ["button_entrypoint_value"] + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/_SimpleSnippet.scss b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/_SimpleSnippet.scss new file mode 100644 index 0000000000..1ee83a5cc9 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/_SimpleSnippet.scss @@ -0,0 +1,131 @@ +$section-header-height: 30px; +$icon-width: 54px; // width of primary icon + margin + +.SimpleSnippet { + &.tall { + padding: 27px 0; + } + + p em { + color: var(--newtab-text-emphasis-text-color); + font-style: normal; + background: var(--newtab-text-emphasis-background); + } + + &.bold { + height: 176px; + + .body { + font-size: 14px; + line-height: 20px; + margin-bottom: 20px; + } + + .icon { + width: 71px; + height: 71px; + } + } + + &.takeover { + height: 344px; + + .body { + font-size: 16px; + line-height: 24px; + margin-bottom: 35px; + } + + .icon { + width: 79px; + height: 79px; + } + } + + .title { + font-size: inherit; + margin: 0; + } + + .title-inline { + display: inline; + } + + .titleIcon { + background-repeat: no-repeat; + background-size: 14px; + background-position: center; + height: 16px; + width: 16px; + margin-top: 2px; + margin-inline-end: 2px; + display: inline-block; + vertical-align: top; + } + + .body { + display: inline; + margin: 0; + } + + &.tall .icon { + margin-inline-end: 20px; + } + + &.bold, + &.takeover { + .donation-form-url, + .donation-amount { + padding-block: 8px; + } + + .icon { + margin-inline-end: 20px; + } + } + + .icon { + align-self: flex-start; + } + + &.has-section-header .innerWrapper { + // account for section header being 100% width + flex-wrap: wrap; + padding-top: 7px; + } + + // wrapper div added if section-header is displayed that allows icon/text/button + // to squish instead of wrapping. this is effectively replicating layout behavior + // when section-header is *not* present. + .innerContentWrapper { + align-items: center; + display: flex; + } + + .section-header { + flex: 0 0 100%; + margin-bottom: 10px; + } + + .section-title { + // color should match that of 'Recommended by Pocket' and 'Highlights' in newtab page + color: var(--newtab-text-primary-color); + display: inline-block; + font-size: 13px; + font-weight: bold; + margin: 0; + + a { + color: var(--newtab-text-primary-color); + font-weight: inherit; + text-decoration: none; + } + + .icon { + height: 16px; + margin-inline-end: 6px; + margin-top: -2px; + width: 16px; + } + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormScene2Snippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormScene2Snippet.schema.json new file mode 100644 index 0000000000..12eeecc084 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormScene2Snippet.schema.json @@ -0,0 +1,167 @@ +{ + "title": "SubmitFormSnippet", + "description": "A template with two states: a SimpleSnippet and another that contains a form", + "version": "1.2.0", + "type": "object", + "definitions": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "richText": { + "description": "Text with HTML subset allowed: i, b, u, strong, em, br", + "type": "string" + }, + "link_url": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + }, + "properties": { + "locale": { + "type": "string", + "description": "Two to five character string for the locale code" + }, + "country": { + "type": "string", + "description": "Two character string for the country code (used for SMS)" + }, + "section_title_icon": { + "type": "string", + "description": "Section title icon. 16x16px. SVG or PNG preferred. section_title_text must also be specified to display." + }, + "section_title_icon_dark_theme": { + "type": "string", + "description": "Section title icon, dark theme variant. 16x16px. SVG or PNG preferred. section_title_text must also be specified to display." + }, + "section_title_text": { + "type": "string", + "description": "Section title text. section_title_icon must also be specified to display." + }, + "section_title_url": { + "allOf": [ + { "$ref": "#/definitions/link_url" }, + { "description": "A url, section_title_text links to this" } + ] + }, + "scene2_text": { + "allOf": [ + { "$ref": "#/definitions/richText" }, + { + "description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br" + } + ] + }, + "form_action": { + "type": "string", + "description": "Endpoint to submit form data." + }, + "success_title": { + "type": "string", + "description": "(send to device) Title shown before text on successful registration." + }, + "success_text": { + "type": "string", + "description": "Message shown on successful registration." + }, + "error_text": { + "type": "string", + "description": "Message shown if registration failed." + }, + "scene2_email_placeholder_text": { + "type": "string", + "description": "Value to show while input is empty." + }, + "scene2_input_placeholder": { + "type": "string", + "description": "(send to device) Value to show while input is empty." + }, + "scene2_button_label": { + "type": "string", + "description": "Label for form submit button" + }, + "scene2_privacy_html": { + "type": "string", + "description": "Information about how the form data is used." + }, + "scene2_disclaimer_html": { + "type": "string", + "description": "(send to device) Html for disclaimer and link underneath input box." + }, + "scene2_icon": { + "type": "string", + "description": "(send to device) Image to display above the form. 98x98px. SVG or PNG preferred." + }, + "scene2_icon_dark_theme": { + "type": "string", + "description": "(send to device) Image to display above the form. Dark theme variant. 98x98px. SVG or PNG preferred." + }, + "scene2_icon_alt_text": { + "type": "string", + "description": "Alt text describing scene2 icon for screen readers", + "default": "" + }, + "scene2_newsletter": { + "type": "string", + "description": "Newsletter/basket id user is subscribing to. Must be a value from the 'Slug' column here: https://basket.mozilla.org/news/. Default 'mozilla-foundation'." + }, + "hidden_inputs": { + "type": "object", + "description": "Each entry represents a hidden input, key is used as value for the name property." + }, + "retry_button_label": { + "allOf": [ + { "$ref": "#/definitions/plainText" }, + { + "description": "Text for the button in the event of a submission error/failure." + } + ], + "default": "Try again" + }, + "do_not_autoblock": { + "type": "boolean", + "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked" + }, + "include_sms": { + "type": "boolean", + "description": "(send to device) Allow users to send an SMS message with the form?" + }, + "message_id_sms": { + "type": "string", + "description": "(send to device) Newsletter/basket id representing the SMS message to be sent." + }, + "message_id_email": { + "type": "string", + "description": "(send to device) Newsletter/basket id representing the email message to be sent. Must be a value from the 'Slug' column here: https://basket.mozilla.org/news/." + }, + "utm_campaign": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_campaign." + }, + "utm_term": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_term." + }, + "links": { + "additionalProperties": { + "url": { + "allOf": [ + { "$ref": "#/definitions/link_url" }, + { "description": "The url where the link points to." } + ] + }, + "metric": { + "type": "string", + "description": "Custom event name sent with telemetry event." + } + } + } + }, + "additionalProperties": false, + "required": ["scene2_text"], + "dependencies": { + "section_title_icon": ["section_title_text"], + "section_title_icon_dark_theme": ["section_title_text"] + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.jsx new file mode 100644 index 0000000000..b9750e0765 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.jsx @@ -0,0 +1,408 @@ +/* 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 { Button } from "../../components/Button/Button"; +import React from "react"; +import { RichText } from "../../components/RichText/RichText"; +import { safeURI } from "../../template-utils"; +import { SimpleSnippet } from "../SimpleSnippet/SimpleSnippet"; +import { SnippetBase } from "../../components/SnippetBase/SnippetBase"; +import ConditionalWrapper from "../../components/ConditionalWrapper/ConditionalWrapper"; + +// Alt text placeholder in case the prop from the server isn't available +const ICON_ALT_TEXT = ""; + +export class SubmitFormSnippet extends React.PureComponent { + constructor(props) { + super(props); + this.expandSnippet = this.expandSnippet.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + this.handleSubmitAttempt = this.handleSubmitAttempt.bind(this); + this.onInputChange = this.onInputChange.bind(this); + this.state = { + expanded: false, + submitAttempted: false, + signupSubmitted: false, + signupSuccess: false, + disableForm: false, + }; + } + + handleSubmitAttempt() { + if (!this.state.submitAttempted) { + this.setState({ submitAttempted: true }); + } + } + + async handleSubmit(event) { + let json; + + if (this.state.disableForm) { + return; + } + + event.preventDefault(); + this.setState({ disableForm: true }); + this.props.sendUserActionTelemetry({ + event: "CLICK_BUTTON", + event_context: "conversion-subscribe-activation", + id: "NEWTAB_FOOTER_BAR_CONTENT", + }); + + if (this.props.form_method.toUpperCase() === "GET") { + this.props.onBlock({ preventDismiss: true }); + this.refs.form.submit(); + return; + } + + const { url, formData } = this.props.processFormData + ? this.props.processFormData(this.refs.mainInput, this.props) + : { url: this.refs.form.action, formData: new FormData(this.refs.form) }; + + try { + const fetchRequest = new Request(url, { + body: formData, + method: "POST", + credentials: "omit", + }); + const response = await fetch(fetchRequest); // eslint-disable-line fetch-options/no-fetch-credentials + json = await response.json(); + } catch (err) { + console.error(err); + } + + if (json && json.status === "ok") { + this.setState({ signupSuccess: true, signupSubmitted: true }); + if (!this.props.content.do_not_autoblock) { + this.props.onBlock({ preventDismiss: true }); + } + this.props.sendUserActionTelemetry({ + event: "CLICK_BUTTON", + event_context: "subscribe-success", + id: "NEWTAB_FOOTER_BAR_CONTENT", + }); + } else { + console.error( + "There was a problem submitting the form", + json || "[No JSON response]" + ); + this.setState({ signupSuccess: false, signupSubmitted: true }); + this.props.sendUserActionTelemetry({ + event: "CLICK_BUTTON", + event_context: "subscribe-error", + id: "NEWTAB_FOOTER_BAR_CONTENT", + }); + } + + this.setState({ disableForm: false }); + } + + expandSnippet() { + this.props.sendUserActionTelemetry({ + event: "CLICK_BUTTON", + event_context: "scene1-button-learn-more", + id: this.props.UISurface, + }); + + this.setState({ + expanded: true, + signupSuccess: false, + signupSubmitted: false, + }); + } + + renderHiddenFormInputs() { + const { hidden_inputs } = this.props.content; + + if (!hidden_inputs) { + return null; + } + + return Object.keys(hidden_inputs).map((key, idx) => ( + <input key={idx} type="hidden" name={key} value={hidden_inputs[key]} /> + )); + } + + renderDisclaimer() { + const { content } = this.props; + if (!content.scene2_disclaimer_html) { + return null; + } + return ( + <p className="disclaimerText"> + <RichText + text={content.scene2_disclaimer_html} + localization_id="disclaimer_html" + links={content.links} + doNotAutoBlock={true} + openNewWindow={true} + sendClick={this.props.sendClick} + /> + </p> + ); + } + + renderFormPrivacyNotice() { + const { content } = this.props; + if (!content.scene2_privacy_html) { + return null; + } + return ( + <p className="privacyNotice"> + <input + type="checkbox" + id="id_privacy" + name="privacy" + required="required" + /> + <label htmlFor="id_privacy"> + <RichText + text={content.scene2_privacy_html} + localization_id="privacy_html" + links={content.links} + doNotAutoBlock={true} + openNewWindow={true} + sendClick={this.props.sendClick} + /> + </label> + </p> + ); + } + + renderSignupSubmitted() { + const { content } = this.props; + const isSuccess = this.state.signupSuccess; + const successTitle = isSuccess && content.success_title; + const bodyText = isSuccess + ? { success_text: content.success_text } + : { error_text: content.error_text }; + const retryButtonText = content.retry_button_label; + return ( + <SnippetBase {...this.props}> + <div className="submissionStatus"> + {successTitle ? ( + <h2 className="submitStatusTitle">{successTitle}</h2> + ) : null} + <p> + <RichText + {...bodyText} + localization_id={isSuccess ? "success_text" : "error_text"} + /> + {isSuccess ? null : ( + <Button onClick={this.expandSnippet}>{retryButtonText}</Button> + )} + </p> + </div> + </SnippetBase> + ); + } + + onInputChange(event) { + if (!this.props.validateInput) { + return; + } + const hasError = this.props.validateInput( + event.target.value, + this.props.content + ); + event.target.setCustomValidity(hasError); + } + + wrapSectionHeader(url) { + return function (children) { + return <a href={url}>{children}</a>; + }; + } + + renderInput() { + const placholder = + this.props.content.scene2_email_placeholder_text || + this.props.content.scene2_input_placeholder; + return ( + <input + ref="mainInput" + type={this.props.inputType || "email"} + className={`mainInput${this.state.submitAttempted ? "" : " clean"}`} + name="email" + required={true} + placeholder={placholder} + onChange={this.props.validateInput ? this.onInputChange : null} + /> + ); + } + + renderForm() { + return ( + <form + action={this.props.form_action} + method={this.props.form_method} + onSubmit={this.handleSubmit} + ref="form" + > + {this.renderHiddenFormInputs()} + <div> + {this.renderInput()} + <button + type="submit" + className="ASRouterButton primary" + onClick={this.handleSubmitAttempt} + ref="formSubmitBtn" + > + {this.props.content.scene2_button_label} + </button> + </div> + {this.renderFormPrivacyNotice() || this.renderDisclaimer()} + </form> + ); + } + + renderScene2Icon() { + const { content } = this.props; + if (!content.scene2_icon) { + return null; + } + + return ( + <div className="scene2Icon"> + <img + src={safeURI(content.scene2_icon)} + className="icon-light-theme" + alt={content.scene2_icon_alt_text || ICON_ALT_TEXT} + /> + <img + src={safeURI(content.scene2_icon_dark_theme || content.scene2_icon)} + className="icon-dark-theme" + alt={content.scene2_icon_alt_text || ICON_ALT_TEXT} + /> + </div> + ); + } + + renderSignupView() { + const { content } = this.props; + const containerClass = `SubmitFormSnippet ${this.props.className}`; + return ( + <SnippetBase + {...this.props} + className={containerClass} + footerDismiss={true} + > + {this.renderScene2Icon()} + <div className="message"> + <p> + {content.scene2_title && ( + <h3 className="scene2Title">{content.scene2_title}</h3> + )}{" "} + {content.scene2_text && ( + <RichText + scene2_text={content.scene2_text} + localization_id="scene2_text" + /> + )} + </p> + </div> + {this.renderForm()} + </SnippetBase> + ); + } + + renderSectionHeader() { + const { props } = this; + + // an icon and text must be specified to render the section header + if (props.content.section_title_icon && props.content.section_title_text) { + const sectionTitleIconLight = safeURI(props.content.section_title_icon); + const sectionTitleIconDark = safeURI( + props.content.section_title_icon_dark_theme || + props.content.section_title_icon + ); + const sectionTitleURL = props.content.section_title_url; + + return ( + <div className="section-header"> + <h3 className="section-title"> + <ConditionalWrapper + wrap={this.wrapSectionHeader(sectionTitleURL)} + condition={sectionTitleURL} + > + <span + className="icon icon-small-spacer icon-light-theme" + style={{ backgroundImage: `url("${sectionTitleIconLight}")` }} + /> + <span + className="icon icon-small-spacer icon-dark-theme" + style={{ backgroundImage: `url("${sectionTitleIconDark}")` }} + /> + <span className="section-title-text"> + {props.content.section_title_text} + </span> + </ConditionalWrapper> + </h3> + </div> + ); + } + + return null; + } + + renderSignupViewAlt() { + const { content } = this.props; + const containerClass = `SubmitFormSnippet ${this.props.className} scene2Alt`; + return ( + <SnippetBase + {...this.props} + className={containerClass} + // Don't show bottom dismiss button + footerDismiss={false} + > + {this.renderSectionHeader()} + {this.renderScene2Icon()} + <div className="message"> + <p> + {content.scene2_text && ( + <RichText + scene2_text={content.scene2_text} + localization_id="scene2_text" + /> + )} + </p> + {this.renderForm()} + </div> + </SnippetBase> + ); + } + + getFirstSceneContent() { + return Object.keys(this.props.content) + .filter(key => key.includes("scene1")) + .reduce((acc, key) => { + acc[key.substr(7)] = this.props.content[key]; + return acc; + }, {}); + } + + render() { + const content = { ...this.props.content, ...this.getFirstSceneContent() }; + + if (this.state.signupSubmitted) { + return this.renderSignupSubmitted(); + } + // Render only scene 2 (signup view). Must check before `renderSignupView` + // to catch the Failure/Try again scenario where we want to return and render + // the scene again. + if (this.props.expandedAlt) { + return this.renderSignupViewAlt(); + } + if (this.state.expanded) { + return this.renderSignupView(); + } + return ( + <SimpleSnippet + {...this.props} + content={content} + onButtonClick={this.expandSnippet} + /> + ); + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json new file mode 100644 index 0000000000..2a5ebda7e0 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json @@ -0,0 +1,235 @@ +{ + "title": "SubmitFormSnippet", + "description": "A template with two states: a SimpleSnippet and another that contains a form", + "version": "1.2.0", + "type": "object", + "definitions": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "richText": { + "description": "Text with HTML subset allowed: i, b, u, strong, em, br", + "type": "string" + }, + "link_url": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + }, + "properties": { + "locale": { + "type": "string", + "description": "Two to five character string for the locale code" + }, + "country": { + "type": "string", + "description": "Two character string for the country code (used for SMS)" + }, + "scene1_title": { + "allof": [ + { "$ref": "#/definitions/plainText" }, + { "description": "snippet title displayed before snippet text" } + ] + }, + "scene1_text": { + "allOf": [ + { "$ref": "#/definitions/richText" }, + { + "description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br" + } + ] + }, + "scene1_section_title_icon": { + "type": "string", + "description": "Section title icon for scene 1. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display." + }, + "scene1_section_title_icon_dark_theme": { + "type": "string", + "description": "Section title icon for scene 1, dark theme variant. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display." + }, + "scene1_section_title_text": { + "type": "string", + "description": "Section title text for scene 1. scene1_section_title_icon must also be specified to display." + }, + "scene1_section_title_url": { + "allOf": [ + { "$ref": "#/definitions/link_url" }, + { "description": "A url, scene1_section_title_text links to this" } + ] + }, + "scene2_title": { + "allOf": [ + { "$ref": "#/definitions/plainText" }, + { + "description": "Title displayed before text in scene 2. Should be plain text." + } + ] + }, + "scene2_text": { + "allOf": [ + { "$ref": "#/definitions/richText" }, + { + "description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br" + } + ] + }, + "scene1_icon": { + "type": "string", + "description": "Snippet icon. 64x64px. SVG or PNG preferred." + }, + "scene1_icon_dark_theme": { + "type": "string", + "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred." + }, + "scene1_icon_alt_text": { + "type": "string", + "description": "Alt text describing scene1 icon for screen readers", + "default": "" + }, + "scene1_title_icon": { + "type": "string", + "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale." + }, + "scene1_title_icon_dark_theme": { + "type": "string", + "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale." + }, + "scene1_title_icon_alt_text": { + "type": "string", + "description": "Alt text describing scene1 title icon for screen readers", + "default": "" + }, + "form_action": { + "type": "string", + "description": "Endpoint to submit form data." + }, + "success_title": { + "type": "string", + "description": "(send to device) Title shown before text on successful registration." + }, + "success_text": { + "type": "string", + "description": "Message shown on successful registration." + }, + "error_text": { + "type": "string", + "description": "Message shown if registration failed." + }, + "scene2_email_placeholder_text": { + "type": "string", + "description": "Value to show while input is empty." + }, + "scene2_input_placeholder": { + "type": "string", + "description": "(send to device) Value to show while input is empty." + }, + "scene2_button_label": { + "type": "string", + "description": "Label for form submit button" + }, + "scene2_privacy_html": { + "type": "string", + "description": "Information about how the form data is used." + }, + "scene2_disclaimer_html": { + "type": "string", + "description": "(send to device) Html for disclaimer and link underneath input box." + }, + "scene2_dismiss_button_text": { + "type": "string", + "description": "Label for the dismiss button when the sign-up form is expanded." + }, + "scene2_icon": { + "type": "string", + "description": "(send to device) Image to display above the form. 98x98px. SVG or PNG preferred." + }, + "scene2_icon_dark_theme": { + "type": "string", + "description": "(send to device) Image to display above the form. Dark theme variant. 98x98px. SVG or PNG preferred." + }, + "scene2_icon_alt_text": { + "type": "string", + "description": "Alt text describing scene2 icon for screen readers", + "default": "" + }, + "scene2_newsletter": { + "type": "string", + "description": "Newsletter/basket id user is subscribing to. Must be a value from the 'Slug' column here: https://basket.mozilla.org/news/. Default 'mozilla-foundation'." + }, + "hidden_inputs": { + "type": "object", + "description": "Each entry represents a hidden input, key is used as value for the name property." + }, + "scene1_button_label": { + "allOf": [ + { "$ref": "#/definitions/plainText" }, + { + "description": "Text for a button next to main snippet text that links to button_url. Requires button_url." + } + ] + }, + "scene1_button_color": { + "type": "string", + "description": "The text color of the button. Valid CSS color." + }, + "scene1_button_background_color": { + "type": "string", + "description": "The background color of the button. Valid CSS color." + }, + "retry_button_label": { + "allOf": [ + { "$ref": "#/definitions/plainText" }, + { + "description": "Text for the button in the event of a submission error/failure." + } + ], + "default": "Try again" + }, + "do_not_autoblock": { + "type": "boolean", + "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked" + }, + "include_sms": { + "type": "boolean", + "description": "(send to device) Allow users to send an SMS message with the form?" + }, + "message_id_sms": { + "type": "string", + "description": "(send to device) Newsletter/basket id representing the SMS message to be sent." + }, + "message_id_email": { + "type": "string", + "description": "(send to device) Newsletter/basket id representing the email message to be sent. Must be a value from the 'Slug' column here: https://basket.mozilla.org/news/." + }, + "utm_campaign": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_campaign." + }, + "utm_term": { + "type": "string", + "description": "(fxa) Value to pass through to GA as utm_term." + }, + "links": { + "additionalProperties": { + "url": { + "allOf": [ + { "$ref": "#/definitions/link_url" }, + { "description": "The url where the link points to." } + ] + }, + "metric": { + "type": "string", + "description": "Custom event name sent with telemetry event." + } + } + } + }, + "additionalProperties": false, + "required": ["scene1_text", "scene2_text", "scene1_button_label"], + "dependencies": { + "scene1_button_color": ["scene1_button_label"], + "scene1_button_background_color": ["scene1_button_label"] + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/_SubmitFormSnippet.scss b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/_SubmitFormSnippet.scss new file mode 100644 index 0000000000..3c1738aef0 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/_SubmitFormSnippet.scss @@ -0,0 +1,176 @@ +.SubmitFormSnippet { + flex-direction: column; + flex: 1 1 100%; + width: 100%; + + .disclaimerText { + margin: 5px 0 0; + font-size: 12px; + color: var(--newtab-text-secondary-color); + } + + p { + margin: 0; + } + + &.send_to_device_snippet { + text-align: center; + + .message { + font-size: 16px; + margin-bottom: 20px; + } + + .scene2Title { + font-size: 24px; + display: block; + } + } + + .ASRouterButton { + &.primary { + flex: 1 1 0; + } + } + + .scene2Icon { + width: 100%; + margin-bottom: 20px; + + img { + width: 98px; + display: inline-block; + } + } + + .scene2Title { + font-size: inherit; + margin: 0 0 10px; + font-weight: bold; + display: inline; + } + + form { + display: flex; + flex-direction: column; + width: 100%; + } + + .message { + font-size: 14px; + align-self: stretch; + flex: 0 0 100%; + margin-bottom: 10px; + } + + .privacyNotice { + font-size: 12px; + color: var(--newtab-text-secondary-color); + margin-top: 10px; + display: flex; + flex: 0 0 100%; + } + + .innerWrapper { + // https://github.com/mozmeao/snippets/blob/2054899350590adcb3c0b0a341c782b0e2f81d0b/activity-stream/newsletter-subscribe.html#L46 + max-width: 736px; + flex-wrap: wrap; + justify-items: center; + padding-top: 40px; + padding-bottom: 40px; + } + + .footer { + width: 100%; + margin: 0 auto; + text-align: right; + background-color: var(--newtab-background-color); + padding: 10px 0; + + .footer-content { + margin: 0 auto; + max-width: 768px; + width: 100%; + text-align: right; + + [dir='rtl'] & { + text-align: left; + } + } + } + + input { + &.mainInput { + border-radius: 2px; + background-color: var(--newtab-background-color-secondary); + border: $input-border; + padding: 0 8px; + height: 100%; + font-size: 14px; + width: 50%; + + &.clean { + &:invalid, + &:required { + box-shadow: none; + } + } + + &:focus { + border: $input-border-active; + box-shadow: var(--newtab-textbox-focus-boxshadow); + } + } + } + + &.scene2Alt { + text-align: start; + + .scene2Icon { + flex: 1; + margin-bottom: 0; + } + + .message { + flex: 5; + margin-bottom: 0; + + p { + margin-bottom: 10px; + } + } + + .section-header { + width: 100%; + + .icon { + width: 16px; + height: 16px; + } + } + + .section-title { + font-size: 13px; + } + + .section-title a { + color: var(--newtab-text-primary-color); + font-weight: inherit; + text-decoration: none; + } + + .innerWrapper { + padding: 0 0 16px; + } + } +} + +.submissionStatus { + text-align: center; + font-size: 14px; + padding: 20px 0; + + .submitStatusTitle { + font-size: 20px; + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/ToastNotification/ToastNotification.schema.json b/browser/components/newtab/content-src/asrouter/templates/ToastNotification/ToastNotification.schema.json new file mode 100644 index 0000000000..c6d917d235 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/ToastNotification/ToastNotification.schema.json @@ -0,0 +1,85 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///ToastNotification.schema.json", + "title": "ToastNotification", + "description": "A template for toast notifications displayed by the Alert service.", + "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "title": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of toast notification title" + }, + "body": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of toast notification body" + }, + "icon_url": { + "description": "The URL of the image used as an icon of the toast notification.", + "type": "string", + "format": "moz-url-format" + }, + "image_url": { + "description": "The URL of an image to be displayed as part of the notification.", + "type": "string", + "format": "moz-url-format" + }, + "launch_url": { + "description": "The URL to launch when the notification or an action button is clicked.", + "type": "string", + "format": "moz-url-format" + }, + "requireInteraction": { + "type": "boolean", + "description": "Whether the toast notification should remain active until the user clicks or dismisses it, rather than closing automatically." + }, + "tag": { + "type": "string", + "description": "An identifying tag for the toast notification." + }, + "data": { + "type": "object", + "description": "Arbitrary data associated with the toast notification." + }, + "actions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "The action text to be shown to the user." + }, + "action": { + "type": "string", + "description": "Opaque identifer that identifies action." + }, + "iconURL": { + "type": "string", + "format": "uri", + "description": "URL of an icon to display with the action." + }, + "windowsSystemActivationType": { + "type": "boolean", + "description": "Whether to have Windows process the given `action`." + } + }, + "required": ["action", "title"], + "additionalProperties": true + } + } + }, + "additionalProperties": true, + "required": ["title", "body"] + }, + "template": { + "type": "string", + "const": "toast_notification" + } + }, + "required": ["content", "targeting", "template", "trigger"], + "additionalProperties": true +} diff --git a/browser/components/newtab/content-src/asrouter/templates/template-manifest.jsx b/browser/components/newtab/content-src/asrouter/templates/template-manifest.jsx new file mode 100644 index 0000000000..57f8afa6f5 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/template-manifest.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 { EOYSnippet } from "./EOYSnippet/EOYSnippet"; +import { FXASignupSnippet } from "./FXASignupSnippet/FXASignupSnippet"; +import { NewsletterSnippet } from "./NewsletterSnippet/NewsletterSnippet"; +import { + SendToDeviceSnippet, + SendToDeviceScene2Snippet, +} from "./SendToDeviceSnippet/SendToDeviceSnippet"; +import { SimpleBelowSearchSnippet } from "./SimpleBelowSearchSnippet/SimpleBelowSearchSnippet"; +import { SimpleSnippet } from "./SimpleSnippet/SimpleSnippet"; + +// Key names matching schema name of templates +export const SnippetsTemplates = { + simple_snippet: SimpleSnippet, + newsletter_snippet: NewsletterSnippet, + fxa_signup_snippet: FXASignupSnippet, + send_to_device_snippet: SendToDeviceSnippet, + send_to_device_scene2_snippet: SendToDeviceScene2Snippet, + eoy_snippet: EOYSnippet, + simple_below_search_snippet: SimpleBelowSearchSnippet, +}; diff --git a/browser/components/newtab/content-src/components/A11yLinkButton/A11yLinkButton.jsx b/browser/components/newtab/content-src/components/A11yLinkButton/A11yLinkButton.jsx new file mode 100644 index 0000000000..3aab52cdff --- /dev/null +++ b/browser/components/newtab/content-src/components/A11yLinkButton/A11yLinkButton.jsx @@ -0,0 +1,18 @@ +/* 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 function A11yLinkButton(props) { + // function for merging classes, if necessary + let className = "a11y-link-button"; + if (props.className) { + className += ` ${props.className}`; + } + return ( + <button type="button" {...props} className={className}> + {props.children} + </button> + ); +} diff --git a/browser/components/newtab/content-src/components/A11yLinkButton/_A11yLinkButton.scss b/browser/components/newtab/content-src/components/A11yLinkButton/_A11yLinkButton.scss new file mode 100644 index 0000000000..c87fc93b60 --- /dev/null +++ b/browser/components/newtab/content-src/components/A11yLinkButton/_A11yLinkButton.scss @@ -0,0 +1,13 @@ + +.a11y-link-button { + border: 0; + padding: 0; + cursor: pointer; + text-align: unset; + color: var(--newtab-primary-action-background); + + &:hover, + &:focus { + text-decoration: underline; + } +} diff --git a/browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx b/browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx new file mode 100644 index 0000000000..3762be9c99 --- /dev/null +++ b/browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx @@ -0,0 +1,1967 @@ +/* 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 { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { ASRouterUtils } from "../../asrouter/asrouter-utils"; +import { connect } from "react-redux"; +import React from "react"; +import { SimpleHashRouter } from "./SimpleHashRouter"; +import { CopyButton } from "./CopyButton"; + +const Row = props => ( + <tr className="message-item" {...props}> + {props.children} + </tr> +); + +function relativeTime(timestamp) { + if (!timestamp) { + return ""; + } + const seconds = Math.floor((Date.now() - timestamp) / 1000); + const minutes = Math.floor((Date.now() - timestamp) / 60000); + if (seconds < 2) { + return "just now"; + } else if (seconds < 60) { + return `${seconds} seconds ago`; + } else if (minutes === 1) { + return "1 minute ago"; + } else if (minutes < 600) { + return `${minutes} minutes ago`; + } + return new Date(timestamp).toLocaleString(); +} + +const LAYOUT_VARIANTS = { + basic: "Basic default layout (on by default in nightly)", + staging_spocs: "A layout with all spocs shown", + "dev-test-all": + "A little bit of everything. Good layout for testing all components", + "dev-test-feeds": "Stress testing for slow feeds", +}; + +export class ToggleStoryButton extends React.PureComponent { + constructor(props) { + super(props); + this.handleClick = this.handleClick.bind(this); + } + + handleClick() { + this.props.onClick(this.props.story); + } + + render() { + return <button onClick={this.handleClick}>collapse/open</button>; + } +} + +export class ToggleMessageJSON extends React.PureComponent { + constructor(props) { + super(props); + this.handleClick = this.handleClick.bind(this); + } + + handleClick() { + this.props.toggleJSON(this.props.msgId); + } + + render() { + let iconName = this.props.isCollapsed + ? "icon icon-arrowhead-forward-small" + : "icon icon-arrowhead-down-small"; + return ( + <button className="clearButton" onClick={this.handleClick}> + <span className={iconName} /> + </button> + ); + } +} + +export class TogglePrefCheckbox extends React.PureComponent { + constructor(props) { + super(props); + this.onChange = this.onChange.bind(this); + } + + onChange(event) { + this.props.onChange(this.props.pref, event.target.checked); + } + + render() { + return ( + <> + <input + type="checkbox" + checked={this.props.checked} + onChange={this.onChange} + disabled={this.props.disabled} + />{" "} + {this.props.pref}{" "} + </> + ); + } +} + +export class Personalization extends React.PureComponent { + constructor(props) { + super(props); + this.togglePersonalization = this.togglePersonalization.bind(this); + } + + togglePersonalization() { + this.props.dispatch( + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_PERSONALIZATION_TOGGLE, + }) + ); + } + + render() { + const { lastUpdated, initialized } = this.props.state.Personalization; + return ( + <React.Fragment> + <table> + <tbody> + <Row> + <td colSpan="2"> + <TogglePrefCheckbox + checked={this.props.personalized} + pref="personalized" + onChange={this.togglePersonalization} + /> + </td> + </Row> + <Row> + <td className="min">Personalization Last Updated</td> + <td>{relativeTime(lastUpdated) || "(no data)"}</td> + </Row> + <Row> + <td className="min">Personalization Initialized</td> + <td>{initialized ? "true" : "false"}</td> + </Row> + </tbody> + </table> + </React.Fragment> + ); + } +} + +export class DiscoveryStreamAdmin extends React.PureComponent { + constructor(props) { + super(props); + this.restorePrefDefaults = this.restorePrefDefaults.bind(this); + this.setConfigValue = this.setConfigValue.bind(this); + this.expireCache = this.expireCache.bind(this); + this.refreshCache = this.refreshCache.bind(this); + this.idleDaily = this.idleDaily.bind(this); + this.systemTick = this.systemTick.bind(this); + this.syncRemoteSettings = this.syncRemoteSettings.bind(this); + this.changeEndpointVariant = this.changeEndpointVariant.bind(this); + this.onStoryToggle = this.onStoryToggle.bind(this); + this.state = { + toggledStories: {}, + }; + } + + setConfigValue(name, value) { + this.props.dispatch( + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_CONFIG_SET_VALUE, + data: { name, value }, + }) + ); + } + + restorePrefDefaults(event) { + this.props.dispatch( + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS, + }) + ); + } + + refreshCache() { + const { config } = this.props.state.DiscoveryStream; + this.props.dispatch( + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_CONFIG_CHANGE, + data: config, + }) + ); + } + + dispatchSimpleAction(type) { + this.props.dispatch( + ac.OnlyToMain({ + type, + }) + ); + } + + systemTick() { + this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_SYSTEM_TICK); + } + + expireCache() { + this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_EXPIRE_CACHE); + } + + idleDaily() { + this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_IDLE_DAILY); + } + + syncRemoteSettings() { + this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_SYNC_RS); + } + + changeEndpointVariant(event) { + const endpoint = this.props.state.DiscoveryStream.config.layout_endpoint; + if (endpoint) { + this.setConfigValue( + "layout_endpoint", + endpoint.replace( + /layout_variant=.+/, + `layout_variant=${event.target.value}` + ) + ); + } + } + + renderComponent(width, component) { + return ( + <table> + <tbody> + <Row> + <td className="min">Type</td> + <td>{component.type}</td> + </Row> + <Row> + <td className="min">Width</td> + <td>{width}</td> + </Row> + {component.feed && this.renderFeed(component.feed)} + </tbody> + </table> + ); + } + + isCurrentVariant(id) { + const endpoint = this.props.state.DiscoveryStream.config.layout_endpoint; + const isMatch = endpoint && !!endpoint.match(`layout_variant=${id}`); + return isMatch; + } + + renderFeedData(url) { + const { feeds } = this.props.state.DiscoveryStream; + const feed = feeds.data[url].data; + return ( + <React.Fragment> + <h4>Feed url: {url}</h4> + <table> + <tbody> + {feed.recommendations?.map(story => this.renderStoryData(story))} + </tbody> + </table> + </React.Fragment> + ); + } + + renderFeedsData() { + const { feeds } = this.props.state.DiscoveryStream; + return ( + <React.Fragment> + {Object.keys(feeds.data).map(url => this.renderFeedData(url))} + </React.Fragment> + ); + } + + renderSpocs() { + const { spocs } = this.props.state.DiscoveryStream; + let spocsData = []; + if (spocs.data && spocs.data.spocs && spocs.data.spocs.items) { + spocsData = spocs.data.spocs.items || []; + } + + return ( + <React.Fragment> + <table> + <tbody> + <Row> + <td className="min">spocs_endpoint</td> + <td>{spocs.spocs_endpoint}</td> + </Row> + <Row> + <td className="min">Data last fetched</td> + <td>{relativeTime(spocs.lastUpdated)}</td> + </Row> + </tbody> + </table> + <h4>Spoc data</h4> + <table> + <tbody>{spocsData.map(spoc => this.renderStoryData(spoc))}</tbody> + </table> + <h4>Spoc frequency caps</h4> + <table> + <tbody> + {spocs.frequency_caps.map(spoc => this.renderStoryData(spoc))} + </tbody> + </table> + </React.Fragment> + ); + } + + onStoryToggle(story) { + const { toggledStories } = this.state; + this.setState({ + toggledStories: { + ...toggledStories, + [story.id]: !toggledStories[story.id], + }, + }); + } + + renderStoryData(story) { + let storyData = ""; + if (this.state.toggledStories[story.id]) { + storyData = JSON.stringify(story, null, 2); + } + return ( + <tr className="message-item" key={story.id}> + <td className="message-id"> + <span> + {story.id} <br /> + </span> + <ToggleStoryButton story={story} onClick={this.onStoryToggle} /> + </td> + <td className="message-summary"> + <pre>{storyData}</pre> + </td> + </tr> + ); + } + + renderFeed(feed) { + const { feeds } = this.props.state.DiscoveryStream; + if (!feed.url) { + return null; + } + return ( + <React.Fragment> + <Row> + <td className="min">Feed url</td> + <td>{feed.url}</td> + </Row> + <Row> + <td className="min">Data last fetched</td> + <td> + {relativeTime( + feeds.data[feed.url] ? feeds.data[feed.url].lastUpdated : null + ) || "(no data)"} + </td> + </Row> + </React.Fragment> + ); + } + + render() { + const prefToggles = "enabled hardcoded_layout show_spocs collapsible".split( + " " + ); + const { config, lastUpdated, layout } = this.props.state.DiscoveryStream; + const personalized = + this.props.otherPrefs["discoverystream.personalization.enabled"]; + return ( + <div> + <button className="button" onClick={this.restorePrefDefaults}> + Restore Pref Defaults + </button>{" "} + <button className="button" onClick={this.refreshCache}> + Refresh Cache + </button> + <br /> + <button className="button" onClick={this.expireCache}> + Expire Cache + </button>{" "} + <button className="button" onClick={this.systemTick}> + Trigger System Tick + </button>{" "} + <button className="button" onClick={this.idleDaily}> + Trigger Idle Daily + </button> + <br /> + <button className="button" onClick={this.syncRemoteSettings}> + Sync Remote Settings + </button> + <table> + <tbody> + {prefToggles.map(pref => ( + <Row key={pref}> + <td> + <TogglePrefCheckbox + checked={config[pref]} + pref={pref} + onChange={this.setConfigValue} + /> + </td> + </Row> + ))} + </tbody> + </table> + <h3>Endpoint variant</h3> + <p> + You can also change this manually by changing this pref:{" "} + <code>browser.newtabpage.activity-stream.discoverystream.config</code> + </p> + <table + style={ + config.enabled && !config.hardcoded_layout ? null : { opacity: 0.5 } + } + > + <tbody> + {Object.keys(LAYOUT_VARIANTS).map(id => ( + <Row key={id}> + <td className="min"> + <input + type="radio" + value={id} + checked={this.isCurrentVariant(id)} + onChange={this.changeEndpointVariant} + /> + </td> + <td className="min">{id}</td> + <td>{LAYOUT_VARIANTS[id]}</td> + </Row> + ))} + </tbody> + </table> + <h3>Caching info</h3> + <table style={config.enabled ? null : { opacity: 0.5 }}> + <tbody> + <Row> + <td className="min">Data last fetched</td> + <td>{relativeTime(lastUpdated) || "(no data)"}</td> + </Row> + </tbody> + </table> + <h3>Layout</h3> + {layout.map((row, rowIndex) => ( + <div key={`row-${rowIndex}`}> + {row.components.map((component, componentIndex) => ( + <div key={`component-${componentIndex}`} className="ds-component"> + {this.renderComponent(row.width, component)} + </div> + ))} + </div> + ))} + <h3>Personalization</h3> + <Personalization + personalized={personalized} + dispatch={this.props.dispatch} + state={{ + Personalization: this.props.state.Personalization, + }} + /> + <h3>Spocs</h3> + {this.renderSpocs()} + <h3>Feeds Data</h3> + {this.renderFeedsData()} + </div> + ); + } +} + +export class ASRouterAdminInner extends React.PureComponent { + constructor(props) { + super(props); + this.handleEnabledToggle = this.handleEnabledToggle.bind(this); + this.handleUserPrefToggle = this.handleUserPrefToggle.bind(this); + this.onChangeMessageFilter = this.onChangeMessageFilter.bind(this); + this.onChangeMessageGroupsFilter = + this.onChangeMessageGroupsFilter.bind(this); + this.unblockAll = this.unblockAll.bind(this); + this.handleClearAllImpressionsByProvider = + this.handleClearAllImpressionsByProvider.bind(this); + this.handleExpressionEval = this.handleExpressionEval.bind(this); + this.onChangeTargetingParameters = + this.onChangeTargetingParameters.bind(this); + this.onChangeAttributionParameters = + this.onChangeAttributionParameters.bind(this); + this.setAttribution = this.setAttribution.bind(this); + this.onCopyTargetingParams = this.onCopyTargetingParams.bind(this); + this.onNewTargetingParams = this.onNewTargetingParams.bind(this); + this.handleOpenPB = this.handleOpenPB.bind(this); + this.selectPBMessage = this.selectPBMessage.bind(this); + this.resetPBJSON = this.resetPBJSON.bind(this); + this.resetPBMessageState = this.resetPBMessageState.bind(this); + this.toggleJSON = this.toggleJSON.bind(this); + this.toggleAllMessages = this.toggleAllMessages.bind(this); + this.resetGroups = this.resetGroups.bind(this); + this.onMessageFromParent = this.onMessageFromParent.bind(this); + this.setStateFromParent = this.setStateFromParent.bind(this); + this.setState = this.setState.bind(this); + this.state = { + messageFilter: "all", + messageGroupsFilter: "all", + collapsedMessages: [], + modifiedMessages: [], + selectedPBMessage: "", + evaluationStatus: {}, + stringTargetingParameters: null, + newStringTargetingParameters: null, + copiedToClipboard: false, + attributionParameters: { + source: "addons.mozilla.org", + medium: "referral", + campaign: "non-fx-button", + content: `rta:${btoa("uBlock0@raymondhill.net")}`, + experiment: "ua-onboarding", + variation: "chrome", + ua: "Google Chrome 123", + dltoken: "00000000-0000-0000-0000-000000000000", + }, + }; + } + + onMessageFromParent({ type, data }) { + // These only exists due to onPrefChange events in ASRouter + switch (type) { + case "UpdateAdminState": { + this.setStateFromParent(data); + break; + } + } + } + + setStateFromParent(data) { + this.setState(data); + if (!this.state.stringTargetingParameters) { + const stringTargetingParameters = {}; + for (const param of Object.keys(data.targetingParameters)) { + stringTargetingParameters[param] = JSON.stringify( + data.targetingParameters[param], + null, + 2 + ); + } + this.setState({ stringTargetingParameters }); + } + } + + componentWillMount() { + ASRouterUtils.addListener(this.onMessageFromParent); + const endpoint = ASRouterUtils.getPreviewEndpoint(); + ASRouterUtils.sendMessage({ + type: "ADMIN_CONNECT_STATE", + data: { endpoint }, + }).then(this.setStateFromParent); + } + + handleBlock(msg) { + return () => ASRouterUtils.blockById(msg.id); + } + + handleUnblock(msg) { + return () => ASRouterUtils.unblockById(msg.id); + } + + resetJSON(msg) { + // reset the displayed JSON for the given message + document.getElementById(`${msg.id}-textarea`).value = JSON.stringify( + msg, + null, + 2 + ); + // remove the message from the list of modified IDs + let index = this.state.modifiedMessages.indexOf(msg.id); + this.setState(prevState => ({ + modifiedMessages: [ + ...prevState.modifiedMessages.slice(0, index), + ...prevState.modifiedMessages.slice(index + 1), + ], + })); + } + + handleOverride(id) { + return () => + ASRouterUtils.overrideMessage(id).then(state => { + this.setStateFromParent(state); + this.props.notifyContent({ + message: state.message, + }); + }); + } + + resetPBMessageState() { + // Iterate over Private Browsing messages and block/unblock each one to clear impressions + const PBMessages = this.state.messages.filter( + message => message.template === "pb_newtab" + ); // messages from state go here + + PBMessages.forEach(message => { + if (message?.id) { + ASRouterUtils.blockById(message.id); + ASRouterUtils.unblockById(message.id); + } + }); + // Clear the selected messages & radio buttons + document.getElementById("clear radio").checked = true; + this.selectPBMessage("clear"); + } + + resetPBJSON(msg) { + // reset the displayed JSON for the given message + document.getElementById(`${msg.id}-textarea`).value = JSON.stringify( + msg, + null, + 2 + ); + } + + handleOpenPB() { + ASRouterUtils.sendMessage({ + type: "FORCE_PRIVATE_BROWSING_WINDOW", + data: { message: { content: this.state.selectedPBMessage } }, + }); + } + + expireCache() { + ASRouterUtils.sendMessage({ type: "EXPIRE_QUERY_CACHE" }); + } + + resetPref() { + ASRouterUtils.sendMessage({ type: "RESET_PROVIDER_PREF" }); + } + + resetGroups(id, value) { + ASRouterUtils.sendMessage({ + type: "RESET_GROUPS_STATE", + }).then(this.setStateFromParent); + } + + handleExpressionEval() { + const context = {}; + for (const param of Object.keys(this.state.stringTargetingParameters)) { + const value = this.state.stringTargetingParameters[param]; + context[param] = value ? JSON.parse(value) : null; + } + ASRouterUtils.sendMessage({ + type: "EVALUATE_JEXL_EXPRESSION", + data: { + expression: this.refs.expressionInput.value, + context, + }, + }).then(this.setStateFromParent); + } + + onChangeTargetingParameters(event) { + const { name } = event.target; + const { value } = event.target; + + this.setState(({ stringTargetingParameters }) => { + let targetingParametersError = null; + const updatedParameters = { ...stringTargetingParameters }; + updatedParameters[name] = value; + try { + JSON.parse(value); + } catch (e) { + console.error(`Error parsing value of parameter ${name}`); + targetingParametersError = { id: name }; + } + + return { + copiedToClipboard: false, + evaluationStatus: {}, + stringTargetingParameters: updatedParameters, + targetingParametersError, + }; + }); + } + + unblockAll() { + return ASRouterUtils.sendMessage({ + type: "UNBLOCK_ALL", + }).then(this.setStateFromParent); + } + + handleClearAllImpressionsByProvider() { + const providerId = this.state.messageFilter; + if (!providerId) { + return; + } + const userPrefInfo = this.state.userPrefs; + + const isUserEnabled = + providerId in userPrefInfo ? userPrefInfo[providerId] : true; + + ASRouterUtils.sendMessage({ + type: "DISABLE_PROVIDER", + data: providerId, + }); + if (!isUserEnabled) { + ASRouterUtils.sendMessage({ + type: "SET_PROVIDER_USER_PREF", + data: { id: providerId, value: true }, + }); + } + ASRouterUtils.sendMessage({ + type: "ENABLE_PROVIDER", + data: providerId, + }); + } + + handleEnabledToggle(event) { + const provider = this.state.providerPrefs.find( + p => p.id === event.target.dataset.provider + ); + const userPrefInfo = this.state.userPrefs; + + const isUserEnabled = + provider.id in userPrefInfo ? userPrefInfo[provider.id] : true; + const isSystemEnabled = provider.enabled; + const isEnabling = event.target.checked; + + if (isEnabling) { + if (!isUserEnabled) { + ASRouterUtils.sendMessage({ + type: "SET_PROVIDER_USER_PREF", + data: { id: provider.id, value: true }, + }); + } + if (!isSystemEnabled) { + ASRouterUtils.sendMessage({ + type: "ENABLE_PROVIDER", + data: provider.id, + }); + } + } else { + ASRouterUtils.sendMessage({ + type: "DISABLE_PROVIDER", + data: provider.id, + }); + } + + this.setState({ messageFilter: "all" }); + } + + handleUserPrefToggle(event) { + const action = { + type: "SET_PROVIDER_USER_PREF", + data: { id: event.target.dataset.provider, value: event.target.checked }, + }; + ASRouterUtils.sendMessage(action); + this.setState({ messageFilter: "all" }); + } + + onChangeMessageFilter(event) { + this.setState({ messageFilter: event.target.value }); + } + + onChangeMessageGroupsFilter(event) { + this.setState({ messageGroupsFilter: event.target.value }); + } + + // Simulate a copy event that sets to clipboard all targeting paramters and values + onCopyTargetingParams(event) { + const stringTargetingParameters = { + ...this.state.stringTargetingParameters, + }; + for (const key of Object.keys(stringTargetingParameters)) { + // If the value is not set the parameter will be lost when we stringify + if (stringTargetingParameters[key] === undefined) { + stringTargetingParameters[key] = null; + } + } + const setClipboardData = e => { + e.preventDefault(); + e.clipboardData.setData( + "text", + JSON.stringify(stringTargetingParameters, null, 2) + ); + document.removeEventListener("copy", setClipboardData); + this.setState({ copiedToClipboard: true }); + }; + + document.addEventListener("copy", setClipboardData); + + document.execCommand("copy"); + } + + onNewTargetingParams(event) { + this.setState({ newStringTargetingParameters: event.target.value }); + event.target.classList.remove("errorState"); + this.refs.targetingParamsEval.innerText = ""; + + try { + const stringTargetingParameters = JSON.parse(event.target.value); + this.setState({ stringTargetingParameters }); + } catch (e) { + event.target.classList.add("errorState"); + this.refs.targetingParamsEval.innerText = e.message; + } + } + + toggleJSON(msgId) { + if (this.state.collapsedMessages.includes(msgId)) { + let index = this.state.collapsedMessages.indexOf(msgId); + this.setState(prevState => ({ + collapsedMessages: [ + ...prevState.collapsedMessages.slice(0, index), + ...prevState.collapsedMessages.slice(index + 1), + ], + })); + } else { + this.setState(prevState => ({ + collapsedMessages: prevState.collapsedMessages.concat(msgId), + })); + } + } + + handleChange(msgId) { + if (!this.state.modifiedMessages.includes(msgId)) { + this.setState(prevState => ({ + modifiedMessages: prevState.modifiedMessages.concat(msgId), + })); + } + } + + renderMessageItem(msg) { + const isBlockedByGroup = this.state.groups + .filter(group => msg.groups.includes(group.id)) + .some(group => !group.enabled); + const msgProvider = + this.state.providers.find(provider => provider.id === msg.provider) || {}; + const isProviderExcluded = + msgProvider.exclude && msgProvider.exclude.includes(msg.id); + const isMessageBlocked = + this.state.messageBlockList.includes(msg.id) || + this.state.messageBlockList.includes(msg.campaign); + const isBlocked = + isMessageBlocked || isBlockedByGroup || isProviderExcluded; + const impressions = this.state.messageImpressions[msg.id] + ? this.state.messageImpressions[msg.id].length + : 0; + const isCollapsed = this.state.collapsedMessages.includes(msg.id); + const isModified = this.state.modifiedMessages.includes(msg.id); + const aboutMessagePreviewSupported = [ + "infobar", + "spotlight", + "cfr_doorhanger", + ].includes(msg.template); + + let itemClassName = "message-item"; + if (isBlocked) { + itemClassName += " blocked"; + } + + return ( + <tr className={itemClassName} key={`${msg.id}-${msg.provider}`}> + <td className="message-id"> + <span> + {msg.id} <br /> + </span> + </td> + <td> + <ToggleMessageJSON + msgId={`${msg.id}`} + toggleJSON={this.toggleJSON} + isCollapsed={isCollapsed} + /> + </td> + <td className="button-column"> + <button + className={`button ${isBlocked ? "" : " primary"}`} + onClick={ + isBlocked ? this.handleUnblock(msg) : this.handleBlock(msg) + } + > + {isBlocked ? "Unblock" : "Block"} + </button> + { + // eslint-disable-next-line no-nested-ternary + isBlocked ? null : isModified ? ( + <button + className="button restore" + // eslint-disable-next-line react/jsx-no-bind + onClick={e => this.resetJSON(msg)} + > + Reset + </button> + ) : ( + <button + className="button show" + onClick={this.handleOverride(msg.id)} + > + Show + </button> + ) + } + {isBlocked ? null : ( + <button + className="button modify" + // eslint-disable-next-line react/jsx-no-bind + onClick={e => this.modifyJson(msg)} + > + Modify + </button> + )} + {aboutMessagePreviewSupported ? ( + <CopyButton + transformer={text => + `about:messagepreview?json=${encodeURIComponent(btoa(text))}` + } + label="Share" + copiedLabel="Copied!" + inputSelector={`#${msg.id}-textarea`} + className={"button share"} + /> + ) : null} + <br />({impressions} impressions) + </td> + <td className="message-summary"> + {isBlocked && ( + <tr> + Block reason: + {isBlockedByGroup && " Blocked by group"} + {isProviderExcluded && " Excluded by provider"} + {isMessageBlocked && " Message blocked"} + </tr> + )} + <tr> + <pre className={isCollapsed ? "collapsed" : "expanded"}> + <textarea + id={`${msg.id}-textarea`} + name={msg.id} + className="general-textarea" + disabled={isBlocked} + // eslint-disable-next-line react/jsx-no-bind + onChange={e => this.handleChange(msg.id)} + > + {JSON.stringify(msg, null, 2)} + </textarea> + </pre> + </tr> + </td> + </tr> + ); + } + + selectPBMessage(msgId) { + if (msgId === "clear") { + this.setState({ + selectedPBMessage: "", + }); + } else { + let selected = document.getElementById(`${msgId} radio`); + let msg = JSON.parse(document.getElementById(`${msgId}-textarea`).value); + + if (selected.checked) { + this.setState({ + selectedPBMessage: msg?.content, + }); + } else { + this.setState({ + selectedPBMessage: "", + }); + } + } + } + + modifyJson(content) { + const message = JSON.parse( + document.getElementById(`${content.id}-textarea`).value + ); + return ASRouterUtils.modifyMessageJson(message).then(state => { + this.setStateFromParent(state); + this.props.notifyContent({ + message: state.message, + }); + }); + } + + renderPBMessageItem(msg) { + const isBlocked = + this.state.messageBlockList.includes(msg.id) || + this.state.messageBlockList.includes(msg.campaign); + const impressions = this.state.messageImpressions[msg.id] + ? this.state.messageImpressions[msg.id].length + : 0; + + const isCollapsed = this.state.collapsedMessages.includes(msg.id); + + let itemClassName = "message-item"; + if (isBlocked) { + itemClassName += " blocked"; + } + + return ( + <tr className={itemClassName} key={`${msg.id}-${msg.provider}`}> + <td className="message-id"> + <span> + {msg.id} <br /> + <br />({impressions} impressions) + </span> + </td> + <td> + <ToggleMessageJSON + msgId={`${msg.id}`} + toggleJSON={this.toggleJSON} + isCollapsed={isCollapsed} + /> + </td> + <td> + <input + type="radio" + id={`${msg.id} radio`} + name="PB_message_radio" + style={{ marginBottom: 20 }} + onClick={() => this.selectPBMessage(msg.id)} + disabled={isBlocked} + /> + <button + className={`button ${isBlocked ? "" : " primary"}`} + onClick={ + isBlocked ? this.handleUnblock(msg) : this.handleBlock(msg) + } + > + {isBlocked ? "Unblock" : "Block"} + </button> + <button + className="ASRouterButton slim button" + onClick={e => this.resetPBJSON(msg)} + > + Reset JSON + </button> + </td> + <td className={`message-summary`}> + <pre className={isCollapsed ? "collapsed" : "expanded"}> + <textarea + id={`${msg.id}-textarea`} + className="wnp-textarea" + name={msg.id} + > + {JSON.stringify(msg, null, 2)} + </textarea> + </pre> + </td> + </tr> + ); + } + + toggleAllMessages(messagesToShow) { + if (this.state.collapsedMessages.length) { + this.setState({ + collapsedMessages: [], + }); + } else { + Array.prototype.forEach.call(messagesToShow, msg => { + this.setState(prevState => ({ + collapsedMessages: prevState.collapsedMessages.concat(msg.id), + })); + }); + } + } + + renderMessages() { + if (!this.state.messages) { + return null; + } + const messagesToShow = + this.state.messageFilter === "all" + ? this.state.messages + : this.state.messages.filter( + message => + message.provider === this.state.messageFilter && + message.template !== "pb_newtab" + ); + + return ( + <div> + <button + className="ASRouterButton slim" + // eslint-disable-next-line react/jsx-no-bind + onClick={e => this.toggleAllMessages(messagesToShow)} + > + Collapse/Expand All + </button> + <p className="helpLink"> + <span className="icon icon-small-spacer icon-info" />{" "} + <span> + To modify a message, change the JSON and click 'Modify' to see your + changes. Click 'Reset' to restore the JSON to the original. Click + 'Share' to copy a link to the clipboard that can be used to preview + the message by opening the link in Nightly/local builds. + </span> + </p> + <table> + <tbody> + {messagesToShow.map(msg => this.renderMessageItem(msg))} + </tbody> + </table> + </div> + ); + } + + renderMessagesByGroup() { + if (!this.state.messages) { + return null; + } + const messagesToShow = + this.state.messageGroupsFilter === "all" + ? this.state.messages.filter(m => m.groups.length) + : this.state.messages.filter(message => + message.groups.includes(this.state.messageGroupsFilter) + ); + + return ( + <table> + <tbody>{messagesToShow.map(msg => this.renderMessageItem(msg))}</tbody> + </table> + ); + } + + renderPBMessages() { + if (!this.state.messages) { + return null; + } + const messagesToShow = this.state.messages.filter( + message => message.template === "pb_newtab" + ); + return ( + <table> + <tbody> + {messagesToShow.map(msg => this.renderPBMessageItem(msg))} + </tbody> + </table> + ); + } + + renderMessageFilter() { + if (!this.state.providers) { + return null; + } + + return ( + <p> + <button + className="unblock-all ASRouterButton test-only" + onClick={this.unblockAll} + > + Unblock All Snippets + </button> + Show messages from{" "} + <select + value={this.state.messageFilter} + onChange={this.onChangeMessageFilter} + > + <option value="all">all providers</option> + {this.state.providers.map(provider => ( + <option key={provider.id} value={provider.id}> + {provider.id} + </option> + ))} + </select> + {this.state.messageFilter !== "all" && + !this.state.messageFilter.includes("_local_testing") ? ( + <button + className="button messages-reset" + onClick={this.handleClearAllImpressionsByProvider} + > + Reset All + </button> + ) : null} + </p> + ); + } + + renderMessageGroupsFilter() { + if (!this.state.groups) { + return null; + } + + return ( + <p> + Show messages from {/* eslint-disable-next-line jsx-a11y/no-onchange */} + <select + value={this.state.messageGroupsFilter} + onChange={this.onChangeMessageGroupsFilter} + > + <option value="all">all groups</option> + {this.state.groups.map(group => ( + <option key={group.id} value={group.id}> + {group.id} + </option> + ))} + </select> + </p> + ); + } + + renderTableHead() { + return ( + <thead> + <tr className="message-item"> + <td className="min" /> + <td className="min">Provider ID</td> + <td>Source</td> + <td className="min">Cohort</td> + <td className="min">Last Updated</td> + </tr> + </thead> + ); + } + + renderProviders() { + const providersConfig = this.state.providerPrefs; + const providerInfo = this.state.providers; + const userPrefInfo = this.state.userPrefs; + + return ( + <table> + {this.renderTableHead()} + <tbody> + {providersConfig.map((provider, i) => { + const isTestProvider = provider.id.includes("_local_testing"); + const info = providerInfo.find(p => p.id === provider.id) || {}; + const isUserEnabled = + provider.id in userPrefInfo ? userPrefInfo[provider.id] : true; + const isSystemEnabled = isTestProvider || provider.enabled; + + let label = "local"; + if (provider.type === "remote") { + label = ( + <span> + endpoint ( + <a + className="providerUrl" + target="_blank" + href={info.url} + rel="noopener noreferrer" + > + {info.url} + </a> + ) + </span> + ); + } else if (provider.type === "remote-settings") { + label = `remote settings (${provider.collection})`; + } else if (provider.type === "remote-experiments") { + label = ( + <span> + remote settings ( + <a + className="providerUrl" + target="_blank" + href="https://firefox.settings.services.mozilla.com/v1/buckets/main/collections/nimbus-desktop-experiments/records" + rel="noopener noreferrer" + > + nimbus-desktop-experiments + </a> + ) + </span> + ); + } + + let reasonsDisabled = []; + if (!isSystemEnabled) { + reasonsDisabled.push("system pref"); + } + if (!isUserEnabled) { + reasonsDisabled.push("user pref"); + } + if (reasonsDisabled.length) { + label = `disabled via ${reasonsDisabled.join(", ")}`; + } + + return ( + <tr className="message-item" key={i}> + <td> + {isTestProvider ? ( + <input + type="checkbox" + disabled={true} + readOnly={true} + checked={true} + /> + ) : ( + <input + type="checkbox" + data-provider={provider.id} + checked={isUserEnabled && isSystemEnabled} + onChange={this.handleEnabledToggle} + /> + )} + </td> + <td>{provider.id}</td> + <td> + <span + className={`sourceLabel${ + isUserEnabled && isSystemEnabled ? "" : " isDisabled" + }`} + > + {label} + </span> + </td> + <td>{provider.cohort}</td> + <td style={{ whiteSpace: "nowrap" }}> + {info.lastUpdated + ? new Date(info.lastUpdated).toLocaleString() + : ""} + </td> + </tr> + ); + })} + </tbody> + </table> + ); + } + + renderTargetingParameters() { + // There was no error and the result is truthy + const success = + this.state.evaluationStatus.success && + !!this.state.evaluationStatus.result; + const result = + JSON.stringify(this.state.evaluationStatus.result, null, 2) || + "(Empty result)"; + + return ( + <table> + <tbody> + <tr> + <td> + <h2>Evaluate JEXL expression</h2> + </td> + </tr> + <tr> + <td> + <p> + <textarea + ref="expressionInput" + rows="10" + cols="60" + placeholder="Evaluate JEXL expressions and mock parameters by changing their values below" + /> + </p> + <p> + Status:{" "} + <span ref="evaluationStatus"> + {success ? "✅" : "❌"}, Result: {result} + </span> + </p> + </td> + <td> + <button + className="ASRouterButton secondary" + onClick={this.handleExpressionEval} + > + Evaluate + </button> + </td> + </tr> + <tr> + <td> + <h2>Modify targeting parameters</h2> + </td> + </tr> + <tr> + <td> + <button + className="ASRouterButton secondary" + onClick={this.onCopyTargetingParams} + disabled={this.state.copiedToClipboard} + > + {this.state.copiedToClipboard + ? "Parameters copied!" + : "Copy parameters"} + </button> + </td> + </tr> + {this.state.stringTargetingParameters && + Object.keys(this.state.stringTargetingParameters).map( + (param, i) => { + const value = this.state.stringTargetingParameters[param]; + const errorState = + this.state.targetingParametersError && + this.state.targetingParametersError.id === param; + const className = errorState ? "errorState" : ""; + const inputComp = + (value && value.length) > 30 ? ( + <textarea + name={param} + className={className} + value={value} + rows="10" + cols="60" + onChange={this.onChangeTargetingParameters} + /> + ) : ( + <input + name={param} + className={className} + value={value} + onChange={this.onChangeTargetingParameters} + /> + ); + + return ( + <tr key={i}> + <td>{param}</td> + <td>{inputComp}</td> + </tr> + ); + } + )} + </tbody> + </table> + ); + } + + onChangeAttributionParameters(event) { + const { name, value } = event.target; + + this.setState(({ attributionParameters }) => { + const updatedParameters = { ...attributionParameters }; + updatedParameters[name] = value; + + return { attributionParameters: updatedParameters }; + }); + } + + setAttribution(e) { + ASRouterUtils.sendMessage({ + type: "FORCE_ATTRIBUTION", + data: this.state.attributionParameters, + }).then(this.setStateFromParent); + } + + _getGroupImpressionsCount(id, frequency) { + if (frequency) { + return this.state.groupImpressions[id] + ? this.state.groupImpressions[id].length + : 0; + } + + return "n/a"; + } + + renderDiscoveryStream() { + const { config } = this.props.DiscoveryStream; + + return ( + <div> + <table> + <tbody> + <tr className="message-item"> + <td className="min">Enabled</td> + <td>{config.enabled ? "yes" : "no"}</td> + </tr> + <tr className="message-item"> + <td className="min">Endpoint</td> + <td>{config.endpoint || "(empty)"}</td> + </tr> + </tbody> + </table> + </div> + ); + } + + renderAttributionParamers() { + return ( + <div> + <h2> Attribution Parameters </h2> + <p> + {" "} + This forces the browser to set some attribution parameters, useful for + testing the Return To AMO feature. Clicking on 'Force Attribution', + with the default values in each field, will demo the Return To AMO + flow with the addon called 'uBlock Origin'. If you wish to try + different attribution parameters, enter them in the text boxes. If you + wish to try a different addon with the Return To AMO flow, make sure + the 'content' text box has a string that is 'rta:base64(addonID)', the + base64 string of the addonID prefixed with 'rta:'. The addon must + currently be a recommended addon on AMO. Then click 'Force + Attribution'. Clicking on 'Force Attribution' with blank text boxes + reset attribution data. + </p> + <table> + <tr> + <td> + <b> Source </b> + </td> + <td> + {" "} + <input + type="text" + name="source" + placeholder="addons.mozilla.org" + value={this.state.attributionParameters.source} + onChange={this.onChangeAttributionParameters} + />{" "} + </td> + </tr> + <tr> + <td> + <b> Medium </b> + </td> + <td> + {" "} + <input + type="text" + name="medium" + placeholder="referral" + value={this.state.attributionParameters.medium} + onChange={this.onChangeAttributionParameters} + />{" "} + </td> + </tr> + <tr> + <td> + <b> Campaign </b> + </td> + <td> + {" "} + <input + type="text" + name="campaign" + placeholder="non-fx-button" + value={this.state.attributionParameters.campaign} + onChange={this.onChangeAttributionParameters} + />{" "} + </td> + </tr> + <tr> + <td> + <b> Content </b> + </td> + <td> + {" "} + <input + type="text" + name="content" + placeholder={`rta:${btoa("uBlock0@raymondhill.net")}`} + value={this.state.attributionParameters.content} + onChange={this.onChangeAttributionParameters} + />{" "} + </td> + </tr> + <tr> + <td> + <b> Experiment </b> + </td> + <td> + {" "} + <input + type="text" + name="experiment" + placeholder="ua-onboarding" + value={this.state.attributionParameters.experiment} + onChange={this.onChangeAttributionParameters} + />{" "} + </td> + </tr> + <tr> + <td> + <b> Variation </b> + </td> + <td> + {" "} + <input + type="text" + name="variation" + placeholder="chrome" + value={this.state.attributionParameters.variation} + onChange={this.onChangeAttributionParameters} + />{" "} + </td> + </tr> + <tr> + <td> + <b> User Agent </b> + </td> + <td> + {" "} + <input + type="text" + name="ua" + placeholder="Google Chrome 123" + value={this.state.attributionParameters.ua} + onChange={this.onChangeAttributionParameters} + />{" "} + </td> + </tr> + <tr> + <td> + <b> Download Token </b> + </td> + <td> + {" "} + <input + type="text" + name="dltoken" + placeholder="00000000-0000-0000-0000-000000000000" + value={this.state.attributionParameters.dltoken} + onChange={this.onChangeAttributionParameters} + />{" "} + </td> + </tr> + <tr> + <td> + {" "} + <button + className="ASRouterButton primary button" + onClick={this.setAttribution} + > + {" "} + Force Attribution{" "} + </button>{" "} + </td> + </tr> + </table> + </div> + ); + } + + renderErrorMessage({ id, errors }) { + const providerId = <td rowSpan={errors.length}>{id}</td>; + // .reverse() so that the last error (most recent) is first + return errors + .map(({ error, timestamp }, cellKey) => ( + <tr key={cellKey}> + {cellKey === errors.length - 1 ? providerId : null} + <td>{error.message}</td> + <td>{relativeTime(timestamp)}</td> + </tr> + )) + .reverse(); + } + + renderErrors() { + const providersWithErrors = + this.state.providers && + this.state.providers.filter(p => p.errors && p.errors.length); + + if (providersWithErrors && providersWithErrors.length) { + return ( + <table className="errorReporting"> + <thead> + <tr> + <th>Provider ID</th> + <th>Message</th> + <th>Timestamp</th> + </tr> + </thead> + <tbody>{providersWithErrors.map(this.renderErrorMessage)}</tbody> + </table> + ); + } + + return <p>No errors</p>; + } + + renderPBTab() { + if (!this.state.messages) { + return null; + } + let messagesToShow = this.state.messages.filter( + message => message.template === "pb_newtab" + ); + + return ( + <div> + <p className="helpLink"> + <span className="icon icon-small-spacer icon-info" />{" "} + <span> + To view an available message, select its radio button and click + "Open a Private Browsing Window". + <br /> + To modify a message, make changes to the JSON first, then select the + radio button. (To make new changes, click "Reset Message State", + make your changes, and reselect the radio button.) + <br /> + Click "Reset Message State" to clear all message impressions and + view messages in a clean state. + <br /> + Note that ContentSearch functions do not work in debug mode. + </span> + </p> + <div> + <button + className="ASRouterButton primary button" + onClick={this.handleOpenPB} + > + Open a Private Browsing Window + </button> + <button + className="ASRouterButton primary button" + style={{ marginInlineStart: 12 }} + onClick={this.resetPBMessageState} + > + Reset Message State + </button> + <br /> + <input + type="radio" + id={`clear radio`} + name="PB_message_radio" + value="clearPBMessage" + style={{ display: "none" }} + /> + <h2>Messages</h2> + <button + className="ASRouterButton slim button" + // eslint-disable-next-line react/jsx-no-bind + onClick={e => this.toggleAllMessages(messagesToShow)} + > + Collapse/Expand All + </button> + {this.renderPBMessages()} + </div> + </div> + ); + } + + getSection() { + const [section] = this.props.location.routes; + switch (section) { + case "private": + return ( + <React.Fragment> + <h2>Private Browsing Messages</h2> + {this.renderPBTab()} + </React.Fragment> + ); + case "targeting": + return ( + <React.Fragment> + <h2>Targeting Utilities</h2> + <button className="button" onClick={this.expireCache}> + Expire Cache + </button>{" "} + (This expires the cache in ASR Targeting for bookmarks and top + sites) + {this.renderTargetingParameters()} + {this.renderAttributionParamers()} + </React.Fragment> + ); + case "groups": + return ( + <React.Fragment> + <h2>Message Groups</h2> + <button className="button" onClick={this.resetGroups}> + Reset group impressions + </button> + <table> + <thead> + <tr className="message-item"> + <td>Enabled</td> + <td>Impressions count</td> + <td>Custom frequency</td> + <td>User preferences</td> + </tr> + </thead> + <tbody> + {this.state.groups && + this.state.groups.map( + ( + { id, enabled, frequency, userPreferences = [] }, + index + ) => ( + <Row key={id}> + <td> + <TogglePrefCheckbox + checked={enabled} + pref={id} + disabled={true} + /> + </td> + <td>{this._getGroupImpressionsCount(id, frequency)}</td> + <td>{JSON.stringify(frequency, null, 2)}</td> + <td>{userPreferences.join(", ")}</td> + </Row> + ) + )} + </tbody> + </table> + {this.renderMessageGroupsFilter()} + {this.renderMessagesByGroup()} + </React.Fragment> + ); + case "ds": + return ( + <React.Fragment> + <h2>Discovery Stream</h2> + <DiscoveryStreamAdmin + state={{ + DiscoveryStream: this.props.DiscoveryStream, + Personalization: this.props.Personalization, + }} + otherPrefs={this.props.Prefs.values} + dispatch={this.props.dispatch} + /> + </React.Fragment> + ); + case "errors": + return ( + <React.Fragment> + <h2>ASRouter Errors</h2> + {this.renderErrors()} + </React.Fragment> + ); + default: + return ( + <React.Fragment> + <h2> + Message Providers{" "} + <button + title="Restore all provider settings that ship with Firefox" + className="button" + onClick={this.resetPref} + > + Restore default prefs + </button> + </h2> + {this.state.providers ? this.renderProviders() : null} + <h2>Messages</h2> + {this.renderMessageFilter()} + {this.renderMessages()} + </React.Fragment> + ); + } + } + + render() { + return ( + <div + className={`asrouter-admin ${ + this.props.collapsed ? "collapsed" : "expanded" + }`} + > + <aside className="sidebar"> + <ul> + <li> + <a href="#devtools">General</a> + </li> + <li> + <a href="#devtools-private">Private Browsing</a> + </li> + <li> + <a href="#devtools-targeting">Targeting</a> + </li> + <li> + <a href="#devtools-groups">Message Groups</a> + </li> + <li> + <a href="#devtools-ds">Discovery Stream</a> + </li> + <li> + <a href="#devtools-errors">Errors</a> + </li> + </ul> + </aside> + <main className="main-panel"> + <h1>AS Router Admin</h1> + + <p className="helpLink"> + <span className="icon icon-small-spacer icon-info" />{" "} + <span> + Need help using these tools? Check out our{" "} + <a + target="blank" + href="https://firefox-source-docs.mozilla.org/browser/components/newtab/content-src/asrouter/docs/debugging-docs.html" + > + documentation + </a> + </span> + </p> + + {this.getSection()} + </main> + </div> + ); + } +} + +export class CollapseToggle extends React.PureComponent { + constructor(props) { + super(props); + this.onCollapseToggle = this.onCollapseToggle.bind(this); + this.state = { collapsed: false }; + } + + get renderAdmin() { + const { props } = this; + return ( + props.location.hash && + (props.location.hash.startsWith("#asrouter") || + props.location.hash.startsWith("#devtools")) + ); + } + + onCollapseToggle(e) { + e.preventDefault(); + this.setState(state => ({ collapsed: !state.collapsed })); + } + + setBodyClass() { + if (this.renderAdmin && !this.state.collapsed) { + global.document.body.classList.add("no-scroll"); + } else { + global.document.body.classList.remove("no-scroll"); + } + } + + componentDidMount() { + this.setBodyClass(); + } + + componentDidUpdate() { + this.setBodyClass(); + } + + componentWillUnmount() { + global.document.body.classList.remove("no-scroll"); + ASRouterUtils.removeListener(this.onMessageFromParent); + } + + render() { + const { props } = this; + const { renderAdmin } = this; + const isCollapsed = this.state.collapsed || !renderAdmin; + const label = `${isCollapsed ? "Expand" : "Collapse"} devtools`; + return ( + <React.Fragment> + <a + href="#devtools" + title={label} + aria-label={label} + className={`asrouter-toggle ${ + isCollapsed ? "collapsed" : "expanded" + }`} + onClick={this.renderAdmin ? this.onCollapseToggle : null} + > + <span className="icon icon-devtools" /> + </a> + {renderAdmin ? ( + <ASRouterAdminInner {...props} collapsed={this.state.collapsed} /> + ) : null} + </React.Fragment> + ); + } +} + +const _ASRouterAdmin = props => ( + <SimpleHashRouter> + <CollapseToggle {...props} /> + </SimpleHashRouter> +); + +export const ASRouterAdmin = connect(state => ({ + Sections: state.Sections, + DiscoveryStream: state.DiscoveryStream, + Personalization: state.Personalization, + Prefs: state.Prefs, +}))(_ASRouterAdmin); diff --git a/browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.scss b/browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.scss new file mode 100644 index 0000000000..b095b94ef6 --- /dev/null +++ b/browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.scss @@ -0,0 +1,280 @@ +.asrouter-toggle { + position: fixed; + top: 50px; + inset-inline-end: 15px; + border: 0; + background: none; + z-index: 1; + border-radius: 2px; + + .icon-devtools { + background-image: url('chrome://global/skin/icons/developer.svg'); + padding: 15px; + } + + &:dir(rtl) { + transform: scaleX(-1); + } + + &:hover { + background: var(--newtab-element-hover-color); + } + + &.expanded { + background: $black-20; + } +} + +.asrouter-admin { + $border-color: var(--newtab-border-color); + $monospace: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Droid Sans Mono', + 'Source Code Pro', monospace; + $sidebar-width: 240px; + + position: fixed; + top: 0; + inset-inline-start: 0; + width: 100%; + background: var(--newtab-background-color); + height: 100%; + overflow-y: scroll; + margin: 0 auto; + font-size: 14px; + padding-inline-start: $sidebar-width; + color: var(--newtab-text-primary-color); + + &.collapsed { + display: none; + } + + .sidebar { + inset-inline-start: 0; + position: fixed; + width: $sidebar-width; + padding: 30px 20px; + + ul { + margin: 0; + padding: 0; + list-style: none; + } + + li a { + padding: 10px 34px; + display: block; + color: var(--lwt-sidebar-text-color); + + &:hover { + background: var(--newtab-background-color-secondary); + } + } + } + + h1 { + font-weight: 200; + font-size: 32px; + } + + h2 .button, + p .button { + font-size: 14px; + padding: 6px 12px; + margin-inline-start: 5px; + margin-bottom: 0; + } + + .general-textarea { + direction: ltr; + width: 740px; + height: 500px; + overflow: auto; + resize: none; + border-radius: 4px; + display: flex; + } + + .wnp-textarea { + direction: ltr; + width: 740px; + height: 500px; + overflow: auto; + resize: none; + border-radius: 4px; + display: flex; + } + + .json-button { + display: inline-flex; + font-size: 10px; + padding: 4px 10px; + margin-bottom: 6px; + margin-inline-end: 4px; + + &:hover { + background-color: var(--newtab-element-hover-color); + box-shadow: none; + } + } + + table { + border-collapse: collapse; + width: 100%; + + &.minimal-table { + border-collapse: collapse; + border: 1px solid $border-color; + + td { + padding: 8px; + } + + td:first-child { + width: 1%; + white-space: nowrap; + } + + td:not(:first-child) { + font-family: $monospace; + } + } + + &.errorReporting { + tr { + border: 1px solid var(--newtab-background-color-secondary); + } + + td { + padding: 4px; + + &[rowspan] { + border: 1px solid var(--newtab-background-color-secondary); + } + } + } + } + + .sourceLabel { + background: var(--newtab-background-color-secondary); + padding: 2px 5px; + border-radius: 3px; + + &.isDisabled { + background: $email-input-invalid; + color: var(--newtab-status-error); + } + } + + .message-item { + &:first-child td { + border-top: 1px solid $border-color; + } + + td { + vertical-align: top; + padding: 8px; + border-bottom: 1px solid $border-color; + + &.min { + width: 1%; + white-space: nowrap; + } + + &.message-summary { + width: 60%; + } + + &.button-column { + width: 15%; + } + + &:first-child { + border-inline-start: 1px solid $border-color; + } + + &:last-child { + border-inline-end: 1px solid $border-color; + } + } + + &.blocked { + .message-id, + .message-summary { + opacity: 0.5; + } + + .message-id { + opacity: 0.5; + } + } + + .message-id { + font-family: $monospace; + font-size: 12px; + } + } + + .providerUrl { + font-size: 12px; + } + + pre { + background: var(--newtab-background-color-secondary); + margin: 0; + padding: 8px; + font-size: 12px; + max-width: 750px; + overflow: auto; + font-family: $monospace; + } + + .errorState { + border: $input-error-border; + } + + .helpLink { + padding: 10px; + display: flex; + background: $black-10; + border-radius: 3px; + align-items: center; + + a { + text-decoration: underline; + } + + .icon { + min-width: 18px; + min-height: 18px; + } + } + + .ds-component { + margin-bottom: 20px; + } + + .modalOverlayInner { + height: 80%; + } + + .clearButton { + border: 0; + padding: 4px; + border-radius: 4px; + display: flex; + + &:hover { + background: var(--newtab-element-hover-color); + } + } + + .collapsed { + display: none; + } + + .icon { + display: inline-table; + cursor: pointer; + width: 18px; + height: 18px; + } +} diff --git a/browser/components/newtab/content-src/components/ASRouterAdmin/CopyButton.jsx b/browser/components/newtab/content-src/components/ASRouterAdmin/CopyButton.jsx new file mode 100644 index 0000000000..07ce12d7bf --- /dev/null +++ b/browser/components/newtab/content-src/components/ASRouterAdmin/CopyButton.jsx @@ -0,0 +1,31 @@ +/* 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, useRef, useCallback } from "react"; + +export const CopyButton = ({ + className, + label, + copiedLabel, + inputSelector, + transformer, + ...props +}) => { + const [copied, setCopied] = useState(false); + const timeout = useRef(null); + const onClick = useCallback(() => { + let text = document.querySelector(inputSelector).value; + if (transformer) text = transformer(text); + navigator.clipboard.writeText(text); + + clearTimeout(timeout.current); + setCopied(true); + timeout.current = setTimeout(() => setCopied(false), 1500); + }, [inputSelector, transformer]); + return ( + <button className={className} onClick={e => onClick()} {...props}> + {(copied && copiedLabel) || label} + </button> + ); +}; diff --git a/browser/components/newtab/content-src/components/ASRouterAdmin/SimpleHashRouter.jsx b/browser/components/newtab/content-src/components/ASRouterAdmin/SimpleHashRouter.jsx new file mode 100644 index 0000000000..9c3fd8579c --- /dev/null +++ b/browser/components/newtab/content-src/components/ASRouterAdmin/SimpleHashRouter.jsx @@ -0,0 +1,35 @@ +/* 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 class SimpleHashRouter extends React.PureComponent { + constructor(props) { + super(props); + this.onHashChange = this.onHashChange.bind(this); + this.state = { hash: global.location.hash }; + } + + onHashChange() { + this.setState({ hash: global.location.hash }); + } + + componentWillMount() { + global.addEventListener("hashchange", this.onHashChange); + } + + componentWillUnmount() { + global.removeEventListener("hashchange", this.onHashChange); + } + + render() { + const [, ...routes] = this.state.hash.split("-"); + return React.cloneElement(this.props.children, { + location: { + hash: this.state.hash, + routes, + }, + }); + } +} diff --git a/browser/components/newtab/content-src/components/Base/Base.jsx b/browser/components/newtab/content-src/components/Base/Base.jsx new file mode 100644 index 0000000000..8845ad87cd --- /dev/null +++ b/browser/components/newtab/content-src/components/Base/Base.jsx @@ -0,0 +1,273 @@ +/* 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 { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { ASRouterAdmin } from "content-src/components/ASRouterAdmin/ASRouterAdmin"; +import { ASRouterUISurface } from "../../asrouter/asrouter-content"; +import { ConfirmDialog } from "content-src/components/ConfirmDialog/ConfirmDialog"; +import { connect } from "react-redux"; +import { DiscoveryStreamBase } from "content-src/components/DiscoveryStreamBase/DiscoveryStreamBase"; +import { ErrorBoundary } from "content-src/components/ErrorBoundary/ErrorBoundary"; +import { CustomizeMenu } from "content-src/components/CustomizeMenu/CustomizeMenu"; +import React from "react"; +import { Search } from "content-src/components/Search/Search"; +import { Sections } from "content-src/components/Sections/Sections"; + +export const PrefsButton = ({ onClick, icon }) => ( + <div className="prefs-button"> + <button + className={`icon ${icon || "icon-settings"}`} + onClick={onClick} + data-l10n-id="newtab-settings-button" + /> + </div> +); + +// Returns a function will not be continuously triggered when called. The +// function will be triggered if called again after `wait` milliseconds. +function debounce(func, wait) { + let timer; + return (...args) => { + if (timer) { + return; + } + + let wakeUp = () => { + timer = null; + }; + + timer = setTimeout(wakeUp, wait); + func.apply(this, args); + }; +} + +export class _Base extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + message: {}, + }; + this.notifyContent = this.notifyContent.bind(this); + } + + notifyContent(state) { + this.setState(state); + } + + componentWillUnmount() { + this.updateTheme(); + } + + componentWillUpdate() { + this.updateTheme(); + } + + updateTheme() { + const bodyClassName = [ + "activity-stream", + // If we skipped the about:welcome overlay and removed the CSS classes + // we don't want to add them back to the Activity Stream view + document.body.classList.contains("inline-onboarding") + ? "inline-onboarding" + : "", + ] + .filter(v => v) + .join(" "); + global.document.body.className = bodyClassName; + } + + render() { + const { props } = this; + const { App } = props; + const isDevtoolsEnabled = props.Prefs.values["asrouter.devtoolsEnabled"]; + + if (!App.initialized) { + return null; + } + + return ( + <ErrorBoundary className="base-content-fallback"> + <React.Fragment> + <BaseContent {...this.props} adminContent={this.state} /> + {isDevtoolsEnabled ? ( + <ASRouterAdmin notifyContent={this.notifyContent} /> + ) : null} + </React.Fragment> + </ErrorBoundary> + ); + } +} + +export class BaseContent extends React.PureComponent { + constructor(props) { + super(props); + this.openPreferences = this.openPreferences.bind(this); + this.openCustomizationMenu = this.openCustomizationMenu.bind(this); + this.closeCustomizationMenu = this.closeCustomizationMenu.bind(this); + this.handleOnKeyDown = this.handleOnKeyDown.bind(this); + this.onWindowScroll = debounce(this.onWindowScroll.bind(this), 5); + this.setPref = this.setPref.bind(this); + this.state = { fixedSearch: false }; + } + + componentDidMount() { + global.addEventListener("scroll", this.onWindowScroll); + global.addEventListener("keydown", this.handleOnKeyDown); + } + + componentWillUnmount() { + global.removeEventListener("scroll", this.onWindowScroll); + global.removeEventListener("keydown", this.handleOnKeyDown); + } + + onWindowScroll() { + const prefs = this.props.Prefs.values; + const SCROLL_THRESHOLD = prefs["logowordmark.alwaysVisible"] ? 179 : 34; + if (global.scrollY > SCROLL_THRESHOLD && !this.state.fixedSearch) { + this.setState({ fixedSearch: true }); + } else if (global.scrollY <= SCROLL_THRESHOLD && this.state.fixedSearch) { + this.setState({ fixedSearch: false }); + } + } + + openPreferences() { + this.props.dispatch(ac.OnlyToMain({ type: at.SETTINGS_OPEN })); + this.props.dispatch(ac.UserEvent({ event: "OPEN_NEWTAB_PREFS" })); + } + + openCustomizationMenu() { + this.props.dispatch({ type: at.SHOW_PERSONALIZE }); + this.props.dispatch(ac.UserEvent({ event: "SHOW_PERSONALIZE" })); + } + + closeCustomizationMenu() { + if (this.props.App.customizeMenuVisible) { + this.props.dispatch({ type: at.HIDE_PERSONALIZE }); + this.props.dispatch(ac.UserEvent({ event: "HIDE_PERSONALIZE" })); + } + } + + handleOnKeyDown(e) { + if (e.key === "Escape") { + this.closeCustomizationMenu(); + } + } + + setPref(pref, value) { + this.props.dispatch(ac.SetPref(pref, value)); + } + + render() { + const { props } = this; + const { App } = props; + const { initialized, customizeMenuVisible } = App; + const prefs = props.Prefs.values; + + const isDiscoveryStream = + props.DiscoveryStream.config && props.DiscoveryStream.config.enabled; + let filteredSections = props.Sections.filter( + section => section.id !== "topstories" + ); + + const pocketEnabled = + prefs["feeds.section.topstories"] && prefs["feeds.system.topstories"]; + const noSectionsEnabled = + !prefs["feeds.topsites"] && + !pocketEnabled && + filteredSections.filter(section => section.enabled).length === 0; + const searchHandoffEnabled = prefs["improvesearch.handoffToAwesomebar"]; + const enabledSections = { + topSitesEnabled: prefs["feeds.topsites"], + pocketEnabled: prefs["feeds.section.topstories"], + highlightsEnabled: prefs["feeds.section.highlights"], + showSponsoredTopSitesEnabled: prefs.showSponsoredTopSites, + showSponsoredPocketEnabled: prefs.showSponsored, + showRecentSavesEnabled: prefs.showRecentSaves, + topSitesRowsCount: prefs.topSitesRows, + }; + + const pocketRegion = prefs["feeds.system.topstories"]; + const { mayHaveSponsoredTopSites } = prefs; + + const outerClassName = [ + "outer-wrapper", + isDiscoveryStream && pocketEnabled && "ds-outer-wrapper-search-alignment", + isDiscoveryStream && "ds-outer-wrapper-breakpoint-override", + prefs.showSearch && + this.state.fixedSearch && + !noSectionsEnabled && + "fixed-search", + prefs.showSearch && noSectionsEnabled && "only-search", + prefs["logowordmark.alwaysVisible"] && "visible-logo", + ] + .filter(v => v) + .join(" "); + + const hasSnippet = + prefs["feeds.snippets"] && + this.props.adminContent && + this.props.adminContent.message && + this.props.adminContent.message.id; + + return ( + <div> + <CustomizeMenu + onClose={this.closeCustomizationMenu} + onOpen={this.openCustomizationMenu} + openPreferences={this.openPreferences} + setPref={this.setPref} + enabledSections={enabledSections} + pocketRegion={pocketRegion} + mayHaveSponsoredTopSites={mayHaveSponsoredTopSites} + showing={customizeMenuVisible} + /> + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions*/} + <div className={outerClassName} onClick={this.closeCustomizationMenu}> + <main className={hasSnippet ? "has-snippet" : ""}> + {prefs.showSearch && ( + <div className="non-collapsible-section"> + <ErrorBoundary> + <Search + showLogo={ + noSectionsEnabled || prefs["logowordmark.alwaysVisible"] + } + handoffEnabled={searchHandoffEnabled} + {...props.Search} + /> + </ErrorBoundary> + </div> + )} + <ASRouterUISurface + adminContent={this.props.adminContent} + appUpdateChannel={this.props.Prefs.values.appUpdateChannel} + fxaEndpoint={this.props.Prefs.values.fxa_endpoint} + dispatch={this.props.dispatch} + /> + <div className={`body-wrapper${initialized ? " on" : ""}`}> + {isDiscoveryStream ? ( + <ErrorBoundary className="borderless-error"> + <DiscoveryStreamBase locale={props.App.locale} /> + </ErrorBoundary> + ) : ( + <Sections /> + )} + </div> + <ConfirmDialog /> + </main> + </div> + </div> + ); + } +} + +export const Base = connect(state => ({ + App: state.App, + Prefs: state.Prefs, + Sections: state.Sections, + DiscoveryStream: state.DiscoveryStream, + Search: state.Search, +}))(_Base); diff --git a/browser/components/newtab/content-src/components/Base/_Base.scss b/browser/components/newtab/content-src/components/Base/_Base.scss new file mode 100644 index 0000000000..7e13fe9a90 --- /dev/null +++ b/browser/components/newtab/content-src/components/Base/_Base.scss @@ -0,0 +1,143 @@ +.outer-wrapper { + color: var(--newtab-text-primary-color); + display: flex; + flex-grow: 1; + min-height: 100vh; + padding: ($section-spacing + $section-vertical-padding) $base-gutter $base-gutter; + + &.ds-outer-wrapper-breakpoint-override { + padding: 30px 0 32px; + + @media(min-width: $break-point-medium) { + padding: 30px 32px 32px; + } + } + + &.only-search { + display: block; + padding-top: 134px; + } + + a { + color: var(--newtab-primary-action-background); + } +} + +main { + margin: auto; + width: $wrapper-default-width; + padding: 0; + + section { + margin-bottom: $section-spacing; + position: relative; + } + + .hide-main & { + visibility: hidden; + } + + @media (min-width: $break-point-medium) { + width: $wrapper-max-width-medium; + } + + @media (min-width: $break-point-large) { + width: $wrapper-max-width-large; + } + + @media (min-width: $break-point-widest) { + width: $wrapper-max-width-widest; + } + + &.has-snippet { + // Offset the snippets container so things at the bottom of the page are still + // visible when snippets are visible. Adjust for other spacing. + padding-bottom: $snippets-container-height - $section-spacing - $base-gutter; + } +} + +.below-search-snippet.withButton { + margin: auto; + width: 100%; +} + +.ds-outer-wrapper-search-alignment { + main { + // This override is to ensure while Discovery Stream loads, + // the search bar does not jump around. (it sticks to the top) + margin: 0 auto; + } +} + +.ds-outer-wrapper-breakpoint-override { + main { + width: 266px; + padding-bottom: 0; + + @media (min-width: $break-point-medium) { + width: 510px; + } + + @media (min-width: $break-point-large) { + width: 746px; + } + + @media (min-width: $break-point-widest) { + width: 986px; + } + + &.has-snippet { + // Offset the snippets container so things at the bottom of the page are still + // visible when snippets are visible. Adjust for other spacing. + padding-bottom: $snippets-container-height - $section-spacing - $base-gutter; + } + } +} + +.base-content-fallback { + // Make the error message be centered against the viewport + height: 100vh; +} + +.body-wrapper { + // Hide certain elements so the page structure is fixed, e.g., placeholders, + // while avoiding flashes of changing content, e.g., icons and text + $selectors-to-hide: '.section-title, .sections-list .section:last-of-type, .topics'; + + #{$selectors-to-hide} { + opacity: 0; + } + + &.on { + #{$selectors-to-hide} { + opacity: 1; + } + } +} + +.non-collapsible-section { + padding: 0 $section-horizontal-padding; +} + +.prefs-button { + button { + background-color: transparent; + border: 0; + border-radius: 2px; + cursor: pointer; + inset-inline-end: 15px; + padding: 15px; + position: fixed; + top: 15px; + z-index: 1000; + + &:hover, + &:focus { + background-color: var(--newtab-element-hover-color); + } + + &:active { + background-color: var(--newtab-element-active-color); + } + } +} diff --git a/browser/components/newtab/content-src/components/Card/Card.jsx b/browser/components/newtab/content-src/components/Card/Card.jsx new file mode 100644 index 0000000000..9d03377f1b --- /dev/null +++ b/browser/components/newtab/content-src/components/Card/Card.jsx @@ -0,0 +1,362 @@ +/* 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 { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { cardContextTypes } from "./types"; +import { connect } from "react-redux"; +import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton"; +import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu"; +import React from "react"; +import { ScreenshotUtils } from "content-src/lib/screenshot-utils"; + +// Keep track of pending image loads to only request once +const gImageLoading = new Map(); + +/** + * Card component. + * Cards are found within a Section component and contain information about a link such + * as preview image, page title, page description, and some context about if the page + * was visited, bookmarked, trending etc... + * Each Section can make an unordered list of Cards which will create one instane of + * this class. Each card will then get a context menu which reflects the actions that + * can be done on this Card. + */ +export class _Card extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + activeCard: null, + imageLoaded: false, + cardImage: null, + }; + this.onMenuButtonUpdate = this.onMenuButtonUpdate.bind(this); + this.onLinkClick = this.onLinkClick.bind(this); + } + + /** + * Helper to conditionally load an image and update state when it loads. + */ + async maybeLoadImage() { + // No need to load if it's already loaded or no image + const { cardImage } = this.state; + if (!cardImage) { + return; + } + + const imageUrl = cardImage.url; + if (!this.state.imageLoaded) { + // Initialize a promise to share a load across multiple card updates + if (!gImageLoading.has(imageUrl)) { + const loaderPromise = new Promise((resolve, reject) => { + const loader = new Image(); + loader.addEventListener("load", resolve); + loader.addEventListener("error", reject); + loader.src = imageUrl; + }); + + // Save and remove the promise only while it's pending + gImageLoading.set(imageUrl, loaderPromise); + loaderPromise + .catch(ex => ex) + .then(() => gImageLoading.delete(imageUrl)) + .catch(); + } + + // Wait for the image whether just started loading or reused promise + try { + await gImageLoading.get(imageUrl); + } catch (ex) { + // Ignore the failed image without changing state + return; + } + + // Only update state if we're still waiting to load the original image + if ( + ScreenshotUtils.isRemoteImageLocal( + this.state.cardImage, + this.props.link.image + ) && + !this.state.imageLoaded + ) { + this.setState({ imageLoaded: true }); + } + } + } + + /** + * Helper to obtain the next state based on nextProps and prevState. + * + * NOTE: Rename this method to getDerivedStateFromProps when we update React + * to >= 16.3. We will need to update tests as well. We cannot rename this + * method to getDerivedStateFromProps now because there is a mismatch in + * the React version that we are using for both testing and production. + * (i.e. react-test-render => "16.3.2", react => "16.2.0"). + * + * See https://github.com/airbnb/enzyme/blob/master/packages/enzyme-adapter-react-16/package.json#L43. + */ + static getNextStateFromProps(nextProps, prevState) { + const { image } = nextProps.link; + const imageInState = ScreenshotUtils.isRemoteImageLocal( + prevState.cardImage, + image + ); + let nextState = null; + + // Image is updating. + if (!imageInState && nextProps.link) { + nextState = { imageLoaded: false }; + } + + if (imageInState) { + return nextState; + } + + // Since image was updated, attempt to revoke old image blob URL, if it exists. + ScreenshotUtils.maybeRevokeBlobObjectURL(prevState.cardImage); + + nextState = nextState || {}; + nextState.cardImage = ScreenshotUtils.createLocalImageObject(image); + + return nextState; + } + + onMenuButtonUpdate(isOpen) { + if (isOpen) { + this.setState({ activeCard: this.props.index }); + } else { + this.setState({ activeCard: null }); + } + } + + /** + * Report to telemetry additional information about the item. + */ + _getTelemetryInfo() { + // Filter out "history" type for being the default + if (this.props.link.type !== "history") { + return { value: { card_type: this.props.link.type } }; + } + + return null; + } + + onLinkClick(event) { + event.preventDefault(); + const { altKey, button, ctrlKey, metaKey, shiftKey } = event; + if (this.props.link.type === "download") { + this.props.dispatch( + ac.OnlyToMain({ + type: at.OPEN_DOWNLOAD_FILE, + data: Object.assign(this.props.link, { + event: { button, ctrlKey, metaKey, shiftKey }, + }), + }) + ); + } else { + this.props.dispatch( + ac.OnlyToMain({ + type: at.OPEN_LINK, + data: Object.assign(this.props.link, { + event: { altKey, button, ctrlKey, metaKey, shiftKey }, + }), + }) + ); + } + if (this.props.isWebExtension) { + this.props.dispatch( + ac.WebExtEvent(at.WEBEXT_CLICK, { + source: this.props.eventSource, + url: this.props.link.url, + action_position: this.props.index, + }) + ); + } else { + this.props.dispatch( + ac.UserEvent( + Object.assign( + { + event: "CLICK", + source: this.props.eventSource, + action_position: this.props.index, + }, + this._getTelemetryInfo() + ) + ) + ); + + if (this.props.shouldSendImpressionStats) { + this.props.dispatch( + ac.ImpressionStats({ + source: this.props.eventSource, + click: 0, + tiles: [{ id: this.props.link.guid, pos: this.props.index }], + }) + ); + } + } + } + + componentDidMount() { + this.maybeLoadImage(); + } + + componentDidUpdate() { + this.maybeLoadImage(); + } + + // NOTE: Remove this function when we update React to >= 16.3 since React will + // call getDerivedStateFromProps automatically. We will also need to + // rename getNextStateFromProps to getDerivedStateFromProps. + componentWillMount() { + const nextState = _Card.getNextStateFromProps(this.props, this.state); + if (nextState) { + this.setState(nextState); + } + } + + // NOTE: Remove this function when we update React to >= 16.3 since React will + // call getDerivedStateFromProps automatically. We will also need to + // rename getNextStateFromProps to getDerivedStateFromProps. + componentWillReceiveProps(nextProps) { + const nextState = _Card.getNextStateFromProps(nextProps, this.state); + if (nextState) { + this.setState(nextState); + } + } + + componentWillUnmount() { + ScreenshotUtils.maybeRevokeBlobObjectURL(this.state.cardImage); + } + + render() { + const { + index, + className, + link, + dispatch, + contextMenuOptions, + eventSource, + shouldSendImpressionStats, + } = this.props; + const { props } = this; + const title = link.title || link.hostname; + const isContextMenuOpen = this.state.activeCard === index; + // Display "now" as "trending" until we have new strings #3402 + const { icon, fluentID } = + cardContextTypes[link.type === "now" ? "trending" : link.type] || {}; + const hasImage = this.state.cardImage || link.hasImage; + const imageStyle = { + backgroundImage: this.state.cardImage + ? `url(${this.state.cardImage.url})` + : "none", + }; + const outerClassName = [ + "card-outer", + className, + isContextMenuOpen && "active", + props.placeholder && "placeholder", + ] + .filter(v => v) + .join(" "); + + return ( + <li className={outerClassName}> + <a + href={link.type === "pocket" ? link.open_url : link.url} + onClick={!props.placeholder ? this.onLinkClick : undefined} + > + <div className="card"> + <div className="card-preview-image-outer"> + {hasImage && ( + <div + className={`card-preview-image${ + this.state.imageLoaded ? " loaded" : "" + }`} + style={imageStyle} + /> + )} + </div> + <div className="card-details"> + {link.type === "download" && ( + <div + className="card-host-name alternate" + data-l10n-id="newtab-menu-open-file" + /> + )} + {link.hostname && ( + <div className="card-host-name"> + {link.hostname.slice(0, 100)} + {link.type === "download" && ` \u2014 ${link.description}`} + </div> + )} + <div + className={[ + "card-text", + icon ? "" : "no-context", + link.description ? "" : "no-description", + link.hostname ? "" : "no-host-name", + ].join(" ")} + > + <h4 className="card-title" dir="auto"> + {link.title} + </h4> + <p className="card-description" dir="auto"> + {link.description} + </p> + </div> + <div className="card-context"> + {icon && !link.context && ( + <span + aria-haspopup="true" + className={`card-context-icon icon icon-${icon}`} + /> + )} + {link.icon && link.context && ( + <span + aria-haspopup="true" + className="card-context-icon icon" + style={{ backgroundImage: `url('${link.icon}')` }} + /> + )} + {fluentID && !link.context && ( + <div className="card-context-label" data-l10n-id={fluentID} /> + )} + {link.context && ( + <div className="card-context-label">{link.context}</div> + )} + </div> + </div> + </div> + </a> + {!props.placeholder && ( + <ContextMenuButton + tooltip="newtab-menu-content-tooltip" + tooltipArgs={{ title }} + onUpdate={this.onMenuButtonUpdate} + > + <LinkMenu + dispatch={dispatch} + index={index} + source={eventSource} + options={link.contextMenuOptions || contextMenuOptions} + site={link} + siteInfo={this._getTelemetryInfo()} + shouldSendImpressionStats={shouldSendImpressionStats} + /> + </ContextMenuButton> + )} + </li> + ); + } +} +_Card.defaultProps = { link: {} }; +export const Card = connect(state => ({ + platform: state.Prefs.values.platform, +}))(_Card); +export const PlaceholderCard = props => ( + <Card placeholder={true} className={props.className} /> +); diff --git a/browser/components/newtab/content-src/components/Card/_Card.scss b/browser/components/newtab/content-src/components/Card/_Card.scss new file mode 100644 index 0000000000..0c363b52d8 --- /dev/null +++ b/browser/components/newtab/content-src/components/Card/_Card.scss @@ -0,0 +1,331 @@ +@use 'sass:math'; + +.card-outer { + @include context-menu-button; + + background: var(--newtab-background-color-secondary); + border-radius: $border-radius-new; + display: inline-block; + height: $card-height; + margin-inline-end: $base-gutter; + position: relative; + width: 100%; + + &:is(:focus):not(.placeholder) { + @include ds-focus; + + transition: none; + } + + &:hover { + box-shadow: none; + transition: none; + } + + &.placeholder { + background: transparent; + + .card-preview-image-outer, + .card-context { + display: none; + } + } + + .card { + border-radius: $border-radius-new; + box-shadow: $shadow-card; + height: 100%; + } + + > a { + color: inherit; + display: block; + height: 100%; + outline: none; + position: absolute; + width: 100%; + + &:is(:focus) { + .card { + @include ds-focus; + } + } + + &:is(.active, :focus) { + .card { + @include fade-in-card; + } + + .card-title { + color: var(--newtab-primary-action-background); + } + } + } + + &:is(:hover, :focus, .active):not(.placeholder) { + @include context-menu-button-hover; + + outline: none; + + .card-title { + color: var(--newtab-primary-action-background); + } + + .alternate ~ .card-host-name { + display: none; + } + + .card-host-name.alternate { + display: block; + } + } + + .card-preview-image-outer { + background-color: var(--newtab-element-secondary-color); + border-radius: $border-radius-new $border-radius-new 0 0; + height: $card-preview-image-height; + overflow: hidden; + position: relative; + + &::after { + border-bottom: 1px solid var(--newtab-card-hairline-color); + bottom: 0; + content: ''; + position: absolute; + width: 100%; + } + + .card-preview-image { + background-position: center; + background-repeat: no-repeat; + background-size: cover; + height: 100%; + opacity: 0; + transition: opacity 1s $photon-easing; + width: 100%; + + &.loaded { + opacity: 1; + } + } + } + + .card-details { + padding: 15px 16px 12px; + } + + .card-text { + max-height: 4 * $card-text-line-height + $card-title-margin; + overflow: hidden; + + &.no-host-name, + &.no-context { + max-height: 5 * $card-text-line-height + $card-title-margin; + } + + &.no-host-name.no-context { + max-height: 6 * $card-text-line-height + $card-title-margin; + } + + &:not(.no-description) .card-title { + max-height: 3 * $card-text-line-height; + overflow: hidden; + } + } + + .card-host-name { + color: var(--newtab-text-secondary-color); + font-size: 10px; + overflow: hidden; + padding-bottom: 4px; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; + } + + .card-host-name.alternate { display: none; } + + .card-title { + font-size: 14px; + font-weight: 600; + line-height: $card-text-line-height; + margin: 0 0 $card-title-margin; + word-wrap: break-word; + } + + .card-description { + font-size: 12px; + line-height: $card-text-line-height; + margin: 0; + overflow: hidden; + word-wrap: break-word; + } + + .card-context { + bottom: 0; + color: var(--newtab-text-secondary-color); + display: flex; + font-size: 11px; + inset-inline-start: 0; + padding: 9px 16px 9px 14px; + position: absolute; + } + + .card-context-icon { + fill: var(--newtab-text-secondary-color); + height: 22px; + margin-inline-end: 6px; + } + + .card-context-label { + flex-grow: 1; + line-height: 22px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.normal-cards { + .card-outer { + // Wide layout styles + @media (min-width: $break-point-widest) { + $line-height: 23px; + + height: $card-height-large; + + .card-preview-image-outer { + height: $card-preview-image-height-large; + } + + .card-details { + padding: 13px 16px 12px; + } + + .card-text { + max-height: 6 * $line-height + $card-title-margin; + } + + .card-host-name { + font-size: 12px; + padding-bottom: 5px; + } + + .card-title { + font-size: 17px; + line-height: $line-height; + margin-bottom: 0; + } + + .card-text:not(.no-description) { + .card-title { + max-height: 3 * $line-height; + } + } + + .card-description { + font-size: 15px; + line-height: $line-height; + } + + .card-context { + bottom: 4px; + font-size: 14px; + } + } + } +} + +.compact-cards { + $card-detail-vertical-spacing: 12px; + $card-title-font-size: 12px; + + .card-outer { + height: $card-height-compact; + + .card-preview-image-outer { + height: $card-preview-image-height-compact; + } + + .card-details { + padding: $card-detail-vertical-spacing 16px; + } + + .card-host-name { + line-height: 10px; + } + + .card-text { + .card-title, + &:not(.no-description) .card-title { + font-size: $card-title-font-size; + line-height: $card-title-font-size + 1; + max-height: $card-title-font-size + 5; + overflow: hidden; + padding: 0 0 4px; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .card-description { + display: none; + } + + .card-context { + $icon-size: 16px; + $container-size: 32px; + + background-color: var(--newtab-background-color-secondary); + border-radius: math.div($container-size, 2); + clip-path: inset(-1px -1px $container-size - ($card-height-compact - $card-preview-image-height-compact - 2 * $card-detail-vertical-spacing)); + height: $container-size; + width: $container-size; + padding: math.div($container-size - $icon-size, 2); + // The -1 at the end is so both opacity borders don't overlap, which causes bug 1629483 + top: $card-preview-image-height-compact - math.div($container-size, 2) - 1; + inset-inline-end: 12px; + inset-inline-start: auto; + + &::after { + border: 1px solid var(--newtab-card-hairline-color); + border-bottom: 0; + border-radius: math.div($container-size, 2) + 1 math.div($container-size, 2) + 1 0 0; + content: ''; + position: absolute; + height: math.div($container-size + 2, 2); + width: $container-size + 2; + top: -1px; + left: -1px; + } + + .card-context-icon { + margin-inline-end: 0; + height: $icon-size; + width: $icon-size; + + &.icon-bookmark-added { + fill: $bookmark-icon-fill; + } + + &.icon-download { + fill: $download-icon-fill; + } + + &.icon-pocket { + fill: $pocket-icon-fill; + } + } + + .card-context-label { + display: none; + } + } + } + + @media not all and (min-width: $break-point-widest) { + .hide-for-narrow { + display: none; + } + } +} diff --git a/browser/components/newtab/content-src/components/Card/types.js b/browser/components/newtab/content-src/components/Card/types.js new file mode 100644 index 0000000000..0b17eea408 --- /dev/null +++ b/browser/components/newtab/content-src/components/Card/types.js @@ -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/. */ + +export const cardContextTypes = { + history: { + fluentID: "newtab-label-visited", + icon: "history-item", + }, + removedBookmark: { + fluentID: "newtab-label-removed-bookmark", + icon: "bookmark-removed", + }, + bookmark: { + fluentID: "newtab-label-bookmarked", + icon: "bookmark-added", + }, + trending: { + fluentID: "newtab-label-recommended", + icon: "trending", + }, + pocket: { + fluentID: "newtab-label-saved", + icon: "pocket", + }, + download: { + fluentID: "newtab-label-download", + icon: "download", + }, +}; diff --git a/browser/components/newtab/content-src/components/CollapsibleSection/CollapsibleSection.jsx b/browser/components/newtab/content-src/components/CollapsibleSection/CollapsibleSection.jsx new file mode 100644 index 0000000000..679e8e137f --- /dev/null +++ b/browser/components/newtab/content-src/components/CollapsibleSection/CollapsibleSection.jsx @@ -0,0 +1,116 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { ErrorBoundary } from "content-src/components/ErrorBoundary/ErrorBoundary"; +import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText"; +import React from "react"; +import { connect } from "react-redux"; + +/** + * A section that can collapse. As of bug 1710937, it can no longer collapse. + * See bug 1727365 for follow-up work to simplify this component. + */ +export class _CollapsibleSection extends React.PureComponent { + constructor(props) { + super(props); + this.onBodyMount = this.onBodyMount.bind(this); + this.onMenuButtonMouseEnter = this.onMenuButtonMouseEnter.bind(this); + this.onMenuButtonMouseLeave = this.onMenuButtonMouseLeave.bind(this); + this.onMenuUpdate = this.onMenuUpdate.bind(this); + this.state = { + menuButtonHover: false, + showContextMenu: false, + }; + this.setContextMenuButtonRef = this.setContextMenuButtonRef.bind(this); + } + + setContextMenuButtonRef(element) { + this.contextMenuButtonRef = element; + } + + onBodyMount(node) { + this.sectionBody = node; + } + + onMenuButtonMouseEnter() { + this.setState({ menuButtonHover: true }); + } + + onMenuButtonMouseLeave() { + this.setState({ menuButtonHover: false }); + } + + onMenuUpdate(showContextMenu) { + this.setState({ showContextMenu }); + } + + render() { + const { isAnimating, maxHeight, menuButtonHover, showContextMenu } = + this.state; + const { id, collapsed, learnMore, title, subTitle } = this.props; + const active = menuButtonHover || showContextMenu; + let bodyStyle; + if (isAnimating && !collapsed) { + bodyStyle = { maxHeight }; + } else if (!isAnimating && collapsed) { + bodyStyle = { display: "none" }; + } + let titleStyle; + if (this.props.hideTitle) { + titleStyle = { visibility: "hidden" }; + } + const hasSubtitleClassName = subTitle ? `has-subtitle` : ``; + return ( + <section + className={`collapsible-section ${this.props.className}${ + active ? " active" : "" + }`} + // Note: data-section-id is used for web extension api tests in mozilla central + data-section-id={id} + > + <div className="section-top-bar"> + <h3 + className={`section-title-container ${hasSubtitleClassName}`} + style={titleStyle} + > + <span className="section-title"> + <FluentOrText message={title} /> + </span> + <span className="learn-more-link-wrapper"> + {learnMore && ( + <span className="learn-more-link"> + <FluentOrText message={learnMore.link.message}> + <a href={learnMore.link.href} /> + </FluentOrText> + </span> + )} + </span> + {subTitle && ( + <span className="section-sub-title"> + <FluentOrText message={subTitle} /> + </span> + )} + </h3> + </div> + <ErrorBoundary className="section-body-fallback"> + <div ref={this.onBodyMount} style={bodyStyle}> + {this.props.children} + </div> + </ErrorBoundary> + </section> + ); + } +} + +_CollapsibleSection.defaultProps = { + document: global.document || { + addEventListener: () => {}, + removeEventListener: () => {}, + visibilityState: "hidden", + }, +}; + +export const CollapsibleSection = connect(state => ({ + Prefs: state.Prefs, +}))(_CollapsibleSection); diff --git a/browser/components/newtab/content-src/components/CollapsibleSection/_CollapsibleSection.scss b/browser/components/newtab/content-src/components/CollapsibleSection/_CollapsibleSection.scss new file mode 100644 index 0000000000..9811339b27 --- /dev/null +++ b/browser/components/newtab/content-src/components/CollapsibleSection/_CollapsibleSection.scss @@ -0,0 +1,106 @@ +.collapsible-section { + padding: $section-vertical-padding $section-horizontal-padding; + + .section-title-container { + margin: 0; + + &.has-subtitle { + display: flex; + flex-direction: column; + + @media (min-width: $break-point-large) { + flex-direction: row; + align-items: baseline; + justify-content: space-between; + } + } + } + + .section-title { + font-size: $section-title-font-size; + font-weight: 600; + color: var(--newtab-text-primary-color); + + &.grey-title { + color: var(--newtab-text-primary-color); + display: inline-block; + fill: var(--newtab-text-primary-color); + vertical-align: middle; + } + + .section-title-contents { + // Center "What's Pocket?" for "mobile" viewport + @media (max-width: $break-point-medium - 1) { + display: block; + + .learn-more-link-wrapper { + display: block; + text-align: center; + + .learn-more-link { + margin-inline-start: 0; + } + } + } + + vertical-align: top; + } + } + + .section-sub-title { + font-size: 14px; + line-height: 16px; + color: var(--newtab-text-secondary-color); + opacity: 0.3; + } + + .section-top-bar { + min-height: 19px; + margin-bottom: 13px; + position: relative; + } + + &.active { + background: var(--newtab-element-hover-color); + border-radius: 4px; + } + + .learn-more-link { + font-size: 13px; + margin-inline-start: 12px; + + a { + color: var(--newtab-primary-action-background); + } + } + + .section-body-fallback { + height: $card-height; + } + + .section-body { + // This is so the top sites favicon and card dropshadows don't get clipped during animation: + $horizontal-padding: 7px; + + margin: 0 (-$horizontal-padding); + padding: 0 $horizontal-padding; + + &.animating { + overflow: hidden; + pointer-events: none; + } + } + + &[data-section-id='topsites'] { + .section-top-bar { + display: none; + } + } + + // Hide first story card for the medium breakpoint to prevent orphaned third story + &[data-section-id='topstories'] .card-outer:first-child { + @media (min-width: $break-point-medium) and (max-width: $break-point-large - 1) { + display: none; + } + } +} diff --git a/browser/components/newtab/content-src/components/ComponentPerfTimer/ComponentPerfTimer.jsx b/browser/components/newtab/content-src/components/ComponentPerfTimer/ComponentPerfTimer.jsx new file mode 100644 index 0000000000..4efd8c712e --- /dev/null +++ b/browser/components/newtab/content-src/components/ComponentPerfTimer/ComponentPerfTimer.jsx @@ -0,0 +1,177 @@ +/* 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 { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { perfService as perfSvc } from "content-src/lib/perf-service"; +import React from "react"; + +// Currently record only a fixed set of sections. This will prevent data +// from custom sections from showing up or from topstories. +const RECORDED_SECTIONS = ["highlights", "topsites"]; + +export class ComponentPerfTimer extends React.Component { + constructor(props) { + super(props); + // Just for test dependency injection: + this.perfSvc = this.props.perfSvc || perfSvc; + + this._sendBadStateEvent = this._sendBadStateEvent.bind(this); + this._sendPaintedEvent = this._sendPaintedEvent.bind(this); + this._reportMissingData = false; + this._timestampHandled = false; + this._recordedFirstRender = false; + } + + componentDidMount() { + if (!RECORDED_SECTIONS.includes(this.props.id)) { + return; + } + + this._maybeSendPaintedEvent(); + } + + componentDidUpdate() { + if (!RECORDED_SECTIONS.includes(this.props.id)) { + return; + } + + this._maybeSendPaintedEvent(); + } + + /** + * Call the given callback after the upcoming frame paints. + * + * @note Both setTimeout and requestAnimationFrame are throttled when the page + * is hidden, so this callback may get called up to a second or so after the + * requestAnimationFrame "paint" for hidden tabs. + * + * Newtabs hidden while loading will presumably be fairly rare (other than + * preloaded tabs, which we will be filtering out on the server side), so such + * cases should get lost in the noise. + * + * If we decide that it's important to find out when something that's hidden + * has "painted", however, another option is to post a message to this window. + * That should happen even faster than setTimeout, and, at least as of this + * writing, it's not throttled in hidden windows in Firefox. + * + * @param {Function} callback + * + * @returns void + */ + _afterFramePaint(callback) { + requestAnimationFrame(() => setTimeout(callback, 0)); + } + + _maybeSendBadStateEvent() { + // Follow up bugs: + // https://github.com/mozilla/activity-stream/issues/3691 + if (!this.props.initialized) { + // Remember to report back when data is available. + this._reportMissingData = true; + } else if (this._reportMissingData) { + this._reportMissingData = false; + // Report how long it took for component to become initialized. + this._sendBadStateEvent(); + } + } + + _maybeSendPaintedEvent() { + // If we've already handled a timestamp, don't do it again. + if (this._timestampHandled || !this.props.initialized) { + return; + } + + // And if we haven't, we're doing so now, so remember that. Even if + // something goes wrong in the callback, we can't try again, as we'd be + // sending back the wrong data, and we have to do it here, so that other + // calls to this method while waiting for the next frame won't also try to + // handle it. + this._timestampHandled = true; + this._afterFramePaint(this._sendPaintedEvent); + } + + /** + * Triggered by call to render. Only first call goes through due to + * `_recordedFirstRender`. + */ + _ensureFirstRenderTsRecorded() { + // Used as t0 for recording how long component took to initialize. + if (!this._recordedFirstRender) { + this._recordedFirstRender = true; + // topsites_first_render_ts, highlights_first_render_ts. + const key = `${this.props.id}_first_render_ts`; + this.perfSvc.mark(key); + } + } + + /** + * Creates `SAVE_SESSION_PERF_DATA` with timestamp in ms + * of how much longer the data took to be ready for display than it would + * have been the ideal case. + * https://github.com/mozilla/ping-centre/issues/98 + */ + _sendBadStateEvent() { + // highlights_data_ready_ts, topsites_data_ready_ts. + const dataReadyKey = `${this.props.id}_data_ready_ts`; + this.perfSvc.mark(dataReadyKey); + + try { + const firstRenderKey = `${this.props.id}_first_render_ts`; + // value has to be Int32. + const value = parseInt( + this.perfSvc.getMostRecentAbsMarkStartByName(dataReadyKey) - + this.perfSvc.getMostRecentAbsMarkStartByName(firstRenderKey), + 10 + ); + this.props.dispatch( + ac.OnlyToMain({ + type: at.SAVE_SESSION_PERF_DATA, + // highlights_data_late_by_ms, topsites_data_late_by_ms. + data: { [`${this.props.id}_data_late_by_ms`]: value }, + }) + ); + } catch (ex) { + // If this failed, it's likely because the `privacy.resistFingerprinting` + // pref is true. + } + } + + _sendPaintedEvent() { + // Record first_painted event but only send if topsites. + if (this.props.id !== "topsites") { + return; + } + + // topsites_first_painted_ts. + const key = `${this.props.id}_first_painted_ts`; + this.perfSvc.mark(key); + + try { + const data = {}; + data[key] = this.perfSvc.getMostRecentAbsMarkStartByName(key); + + this.props.dispatch( + ac.OnlyToMain({ + type: at.SAVE_SESSION_PERF_DATA, + data, + }) + ); + } catch (ex) { + // If this failed, it's likely because the `privacy.resistFingerprinting` + // pref is true. We should at least not blow up, and should continue + // to set this._timestampHandled to avoid going through this again. + } + } + + render() { + if (RECORDED_SECTIONS.includes(this.props.id)) { + this._ensureFirstRenderTsRecorded(); + this._maybeSendBadStateEvent(); + } + return this.props.children; + } +} diff --git a/browser/components/newtab/content-src/components/ConfirmDialog/ConfirmDialog.jsx b/browser/components/newtab/content-src/components/ConfirmDialog/ConfirmDialog.jsx new file mode 100644 index 0000000000..f69e540079 --- /dev/null +++ b/browser/components/newtab/content-src/components/ConfirmDialog/ConfirmDialog.jsx @@ -0,0 +1,103 @@ +/* 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 { actionCreators as ac, actionTypes } from "common/Actions.sys.mjs"; +import { connect } from "react-redux"; +import React from "react"; + +/** + * ConfirmDialog component. + * One primary action button, one cancel button. + * + * Content displayed is controlled by `data` prop the component receives. + * Example: + * data: { + * // Any sort of data needed to be passed around by actions. + * payload: site.url, + * // Primary button AlsoToMain action. + * action: "DELETE_HISTORY_URL", + * // Primary button USerEvent action. + * userEvent: "DELETE", + * // Array of locale ids to display. + * message_body: ["confirm_history_delete_p1", "confirm_history_delete_notice_p2"], + * // Text for primary button. + * confirm_button_string_id: "menu_action_delete" + * }, + */ +export class _ConfirmDialog extends React.PureComponent { + constructor(props) { + super(props); + this._handleCancelBtn = this._handleCancelBtn.bind(this); + this._handleConfirmBtn = this._handleConfirmBtn.bind(this); + } + + _handleCancelBtn() { + this.props.dispatch({ type: actionTypes.DIALOG_CANCEL }); + this.props.dispatch( + ac.UserEvent({ + event: actionTypes.DIALOG_CANCEL, + source: this.props.data.eventSource, + }) + ); + } + + _handleConfirmBtn() { + this.props.data.onConfirm.forEach(this.props.dispatch); + } + + _renderModalMessage() { + const message_body = this.props.data.body_string_id; + + if (!message_body) { + return null; + } + + return ( + <span> + {message_body.map(msg => ( + <p key={msg} data-l10n-id={msg} /> + ))} + </span> + ); + } + + render() { + if (!this.props.visible) { + return null; + } + + return ( + <div className="confirmation-dialog"> + <div + className="modal-overlay" + onClick={this._handleCancelBtn} + role="presentation" + /> + <div className="modal"> + <section className="modal-message"> + {this.props.data.icon && ( + <span + className={`icon icon-spacer icon-${this.props.data.icon}`} + /> + )} + {this._renderModalMessage()} + </section> + <section className="actions"> + <button + onClick={this._handleCancelBtn} + data-l10n-id={this.props.data.cancel_button_string_id} + /> + <button + className="done" + onClick={this._handleConfirmBtn} + data-l10n-id={this.props.data.confirm_button_string_id} + /> + </section> + </div> + </div> + ); + } +} + +export const ConfirmDialog = connect(state => state.Dialog)(_ConfirmDialog); diff --git a/browser/components/newtab/content-src/components/ConfirmDialog/_ConfirmDialog.scss b/browser/components/newtab/content-src/components/ConfirmDialog/_ConfirmDialog.scss new file mode 100644 index 0000000000..ca9940ffc5 --- /dev/null +++ b/browser/components/newtab/content-src/components/ConfirmDialog/_ConfirmDialog.scss @@ -0,0 +1,68 @@ +.confirmation-dialog { + .modal { + box-shadow: $shadow-secondary; + left: 0; + margin: auto; + position: fixed; + right: 0; + top: 20%; + width: 400px; + } + + section { + margin: 0; + } + + .modal-message { + display: flex; + padding: 16px; + padding-bottom: 0; + + p { + margin: 0; + margin-bottom: 16px; + } + } + + .actions { + border: 0; + display: flex; + flex-wrap: nowrap; + padding: 0 16px; + + button { + margin-inline-end: 16px; + padding-inline-end: 18px; + padding-inline-start: 18px; + white-space: normal; + width: 50%; + + &.done { + margin-inline-end: 0; + margin-inline-start: 0; + } + } + } + + .icon { + margin-inline-end: 16px; + } +} + +.modal-overlay { + background: var(--newtab-overlay-color); + height: 100%; + left: 0; + position: fixed; + top: 0; + width: 100%; + z-index: 11001; +} + +.modal { + background: var(--newtab-background-color-secondary); + border: $border-secondary; + border-radius: 5px; + font-size: 15px; + z-index: 11002; +} diff --git a/browser/components/newtab/content-src/components/ContextMenu/ContextMenu.jsx b/browser/components/newtab/content-src/components/ContextMenu/ContextMenu.jsx new file mode 100644 index 0000000000..5ea6a57f71 --- /dev/null +++ b/browser/components/newtab/content-src/components/ContextMenu/ContextMenu.jsx @@ -0,0 +1,176 @@ +/* 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 { connect } from "react-redux"; + +export class ContextMenu extends React.PureComponent { + constructor(props) { + super(props); + this.hideContext = this.hideContext.bind(this); + this.onShow = this.onShow.bind(this); + this.onClick = this.onClick.bind(this); + } + + hideContext() { + this.props.onUpdate(false); + } + + onShow() { + if (this.props.onShow) { + this.props.onShow(); + } + } + + componentDidMount() { + this.onShow(); + setTimeout(() => { + global.addEventListener("click", this.hideContext); + }, 0); + } + + componentWillUnmount() { + global.removeEventListener("click", this.hideContext); + } + + onClick(event) { + // Eat all clicks on the context menu so they don't bubble up to window. + // This prevents the context menu from closing when clicking disabled items + // or the separators. + event.stopPropagation(); + } + + render() { + // Disabling focus on the menu span allows the first tab to focus on the first menu item instead of the wrapper. + return ( + // eslint-disable-next-line jsx-a11y/interactive-supports-focus + <span className="context-menu"> + <ul + role="menu" + onClick={this.onClick} + onKeyDown={this.onClick} + className="context-menu-list" + > + {this.props.options.map((option, i) => + option.type === "separator" ? ( + <li key={i} className="separator" role="separator" /> + ) : ( + option.type !== "empty" && ( + <ContextMenuItem + key={i} + option={option} + hideContext={this.hideContext} + keyboardAccess={this.props.keyboardAccess} + /> + ) + ) + )} + </ul> + </span> + ); + } +} + +export class _ContextMenuItem extends React.PureComponent { + constructor(props) { + super(props); + this.onClick = this.onClick.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + this.onKeyUp = this.onKeyUp.bind(this); + this.focusFirst = this.focusFirst.bind(this); + } + + onClick(event) { + this.props.hideContext(); + this.props.option.onClick(event); + } + + // Focus the first menu item if the menu was accessed via the keyboard. + focusFirst(button) { + if (this.props.keyboardAccess && button) { + button.focus(); + } + } + + // This selects the correct node based on the key pressed + focusSibling(target, key) { + const parent = target.parentNode; + const closestSiblingSelector = + key === "ArrowUp" ? "previousSibling" : "nextSibling"; + if (!parent[closestSiblingSelector]) { + return; + } + if (parent[closestSiblingSelector].firstElementChild) { + parent[closestSiblingSelector].firstElementChild.focus(); + } else { + parent[closestSiblingSelector][ + closestSiblingSelector + ].firstElementChild.focus(); + } + } + + onKeyDown(event) { + const { option } = this.props; + switch (event.key) { + case "Tab": + // tab goes down in context menu, shift + tab goes up in context menu + // if we're on the last item, one more tab will close the context menu + // similarly, if we're on the first item, one more shift + tab will close it + if ( + (event.shiftKey && option.first) || + (!event.shiftKey && option.last) + ) { + this.props.hideContext(); + } + break; + case "ArrowUp": + case "ArrowDown": + event.preventDefault(); + this.focusSibling(event.target, event.key); + break; + case "Enter": + case " ": + event.preventDefault(); + this.props.hideContext(); + option.onClick(); + break; + case "Escape": + this.props.hideContext(); + break; + } + } + + // Prevents the default behavior of spacebar + // scrolling the page & auto-triggering buttons. + onKeyUp(event) { + if (event.key === " ") { + event.preventDefault(); + } + } + + render() { + const { option } = this.props; + return ( + <li role="presentation" className="context-menu-item"> + <button + className={option.disabled ? "disabled" : ""} + role="menuitem" + onClick={this.onClick} + onKeyDown={this.onKeyDown} + onKeyUp={this.onKeyUp} + ref={option.first ? this.focusFirst : null} + aria-haspopup={ + option.id === "newtab-menu-edit-topsites" ? "dialog" : null + } + > + <span data-l10n-id={option.string_id || option.id} /> + </button> + </li> + ); + } +} + +export const ContextMenuItem = connect(state => ({ + Prefs: state.Prefs, +}))(_ContextMenuItem); diff --git a/browser/components/newtab/content-src/components/ContextMenu/ContextMenuButton.jsx b/browser/components/newtab/content-src/components/ContextMenu/ContextMenuButton.jsx new file mode 100644 index 0000000000..0364f5386a --- /dev/null +++ b/browser/components/newtab/content-src/components/ContextMenu/ContextMenuButton.jsx @@ -0,0 +1,72 @@ +/* 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 class ContextMenuButton extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + showContextMenu: false, + contextMenuKeyboard: false, + }; + this.onClick = this.onClick.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + this.onUpdate = this.onUpdate.bind(this); + } + + openContextMenu(isKeyBoard, event) { + if (this.props.onUpdate) { + this.props.onUpdate(true); + } + this.setState({ + showContextMenu: true, + contextMenuKeyboard: isKeyBoard, + }); + } + + onClick(event) { + event.preventDefault(); + this.openContextMenu(false, event); + } + + onKeyDown(event) { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + this.openContextMenu(true, event); + } + } + + onUpdate(showContextMenu) { + if (this.props.onUpdate) { + this.props.onUpdate(showContextMenu); + } + this.setState({ showContextMenu }); + } + + render() { + const { tooltipArgs, tooltip, children, refFunction } = this.props; + const { showContextMenu, contextMenuKeyboard } = this.state; + + return ( + <React.Fragment> + <button + aria-haspopup="true" + data-l10n-id={tooltip} + data-l10n-args={tooltipArgs ? JSON.stringify(tooltipArgs) : null} + className="context-menu-button icon" + onKeyDown={this.onKeyDown} + onClick={this.onClick} + ref={refFunction} + /> + {showContextMenu + ? React.cloneElement(children, { + keyboardAccess: contextMenuKeyboard, + onUpdate: this.onUpdate, + }) + : null} + </React.Fragment> + ); + } +} diff --git a/browser/components/newtab/content-src/components/ContextMenu/_ContextMenu.scss b/browser/components/newtab/content-src/components/ContextMenu/_ContextMenu.scss new file mode 100644 index 0000000000..c0074128e6 --- /dev/null +++ b/browser/components/newtab/content-src/components/ContextMenu/_ContextMenu.scss @@ -0,0 +1,57 @@ +@use 'sass:math'; + +.context-menu { + background: var(--newtab-background-color-secondary); + border-radius: $context-menu-border-radius; + box-shadow: $context-menu-shadow; + display: block; + font-size: $context-menu-font-size; + margin-inline-start: 5px; + inset-inline-start: 100%; + position: absolute; + top: math.div($context-menu-button-size, 4); + z-index: 8; + + > ul { + list-style: none; + margin: 0; + padding: $context-menu-outer-padding 0; + + > li { + margin: 0; + width: 100%; + + &.separator { + border-bottom: $border-secondary; + margin: $context-menu-outer-padding 0; + } + + > a, + > button { + align-items: center; + color: inherit; + cursor: pointer; + display: flex; + width: 100%; + line-height: 16px; + outline: none; + border: 0; + padding: $context-menu-item-padding; + white-space: nowrap; + + &:is(:focus, :hover) { + background: var(--newtab-element-secondary-hover-color); + } + + &:active { + background: var(--newtab-element-secondary-active-color); + } + + &.disabled { + opacity: 0.4; + pointer-events: none; + } + } + } + } +} diff --git a/browser/components/newtab/content-src/components/CustomizeMenu/BackgroundsSection/BackgroundsSection.jsx b/browser/components/newtab/content-src/components/CustomizeMenu/BackgroundsSection/BackgroundsSection.jsx new file mode 100644 index 0000000000..522ea6841f --- /dev/null +++ b/browser/components/newtab/content-src/components/CustomizeMenu/BackgroundsSection/BackgroundsSection.jsx @@ -0,0 +1,11 @@ +/* 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 class BackgroundsSection extends React.PureComponent { + render() { + return <div />; + } +} diff --git a/browser/components/newtab/content-src/components/CustomizeMenu/ContentSection/ContentSection.jsx b/browser/components/newtab/content-src/components/CustomizeMenu/ContentSection/ContentSection.jsx new file mode 100644 index 0000000000..423fd131e2 --- /dev/null +++ b/browser/components/newtab/content-src/components/CustomizeMenu/ContentSection/ContentSection.jsx @@ -0,0 +1,308 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +import { actionCreators as ac } from "common/Actions.sys.mjs"; + +export class ContentSection extends React.PureComponent { + constructor(props) { + super(props); + this.onPreferenceSelect = this.onPreferenceSelect.bind(this); + + // Refs are necessary for dynamically measuring drawer heights for slide animations + this.topSitesDrawerRef = React.createRef(); + this.pocketDrawerRef = React.createRef(); + } + + inputUserEvent(eventSource, status) { + this.props.dispatch( + ac.UserEvent({ + event: "PREF_CHANGED", + source: eventSource, + value: { status, menu_source: "CUSTOMIZE_MENU" }, + }) + ); + } + + onPreferenceSelect(e) { + let prefName = e.target.getAttribute("preference"); + const eventSource = e.target.getAttribute("eventSource"); // TOP_SITES, TOP_STORIES, HIGHLIGHTS + let value; + if (e.target.nodeName === "SELECT") { + value = parseInt(e.target.value, 10); + } else if (e.target.nodeName === "INPUT") { + value = e.target.checked; + if (eventSource) { + this.inputUserEvent(eventSource, value); + } + } + this.props.setPref(prefName, value); + } + + componentDidMount() { + this.setDrawerMargins(); + } + + componentDidUpdate() { + this.setDrawerMargins(); + } + + setDrawerMargins() { + this.setDrawerMargin( + `TOP_SITES`, + this.props.enabledSections.topSitesEnabled + ); + this.setDrawerMargin( + `TOP_STORIES`, + this.props.enabledSections.pocketEnabled + ); + } + + setDrawerMargin(drawerID, isOpen) { + let drawerRef; + + if (drawerID === `TOP_SITES`) { + drawerRef = this.topSitesDrawerRef.current; + } else if (drawerID === `TOP_STORIES`) { + drawerRef = this.pocketDrawerRef.current; + } else { + return; + } + + let drawerHeight; + + if (drawerRef) { + drawerHeight = parseFloat(window.getComputedStyle(drawerRef)?.height); + + if (isOpen) { + drawerRef.style.marginTop = `0`; + } else { + drawerRef.style.marginTop = `-${drawerHeight}px`; + } + } + } + + render() { + const { + enabledSections, + mayHaveSponsoredTopSites, + pocketRegion, + mayHaveSponsoredStories, + mayHaveRecentSaves, + openPreferences, + } = this.props; + const { + topSitesEnabled, + pocketEnabled, + highlightsEnabled, + showSponsoredTopSitesEnabled, + showSponsoredPocketEnabled, + showRecentSavesEnabled, + topSitesRowsCount, + } = enabledSections; + + return ( + <div className="home-section"> + <div id="shortcuts-section" className="section"> + <label className="switch"> + <input + id="shortcuts-toggle" + checked={topSitesEnabled} + type="checkbox" + onChange={this.onPreferenceSelect} + preference="feeds.topsites" + aria-labelledby="custom-shortcuts-title" + aria-describedby="custom-shortcuts-subtitle" + eventSource="TOP_SITES" + /> + <span className="slider" role="presentation"></span> + </label> + <div> + <h2 id="custom-shortcuts-title" className="title"> + <label + htmlFor="shortcuts-toggle" + data-l10n-id="newtab-custom-shortcuts-title" + ></label> + </h2> + <p + id="custom-shortcuts-subtitle" + className="subtitle" + data-l10n-id="newtab-custom-shortcuts-subtitle" + ></p> + <div className="more-info-top-wrapper"> + <div className="more-information" ref={this.topSitesDrawerRef}> + <select + id="row-selector" + className="selector" + name="row-count" + preference="topSitesRows" + value={topSitesRowsCount} + onChange={this.onPreferenceSelect} + disabled={!topSitesEnabled} + aria-labelledby="custom-shortcuts-title" + > + <option + value="1" + data-l10n-id="newtab-custom-row-selector" + data-l10n-args='{"num": 1}' + /> + <option + value="2" + data-l10n-id="newtab-custom-row-selector" + data-l10n-args='{"num": 2}' + /> + <option + value="3" + data-l10n-id="newtab-custom-row-selector" + data-l10n-args='{"num": 3}' + /> + <option + value="4" + data-l10n-id="newtab-custom-row-selector" + data-l10n-args='{"num": 4}' + /> + </select> + {mayHaveSponsoredTopSites && ( + <div className="check-wrapper" role="presentation"> + <input + id="sponsored-shortcuts" + className="sponsored-checkbox" + disabled={!topSitesEnabled} + checked={showSponsoredTopSitesEnabled} + type="checkbox" + onChange={this.onPreferenceSelect} + preference="showSponsoredTopSites" + eventSource="SPONSORED_TOP_SITES" + /> + <label + className="sponsored" + htmlFor="sponsored-shortcuts" + data-l10n-id="newtab-custom-sponsored-sites" + /> + </div> + )} + </div> + </div> + </div> + </div> + + {pocketRegion && ( + <div id="pocket-section" className="section"> + <label className="switch"> + <input + id="pocket-toggle" + checked={pocketEnabled} + type="checkbox" + onChange={this.onPreferenceSelect} + preference="feeds.section.topstories" + aria-labelledby="custom-pocket-title" + aria-describedby="custom-pocket-subtitle" + eventSource="TOP_STORIES" + /> + <span className="slider" role="presentation"></span> + </label> + <div> + <h2 id="custom-pocket-title" className="title"> + <label + htmlFor="pocket-toggle" + data-l10n-id="newtab-custom-pocket-title" + ></label> + </h2> + <p + id="custom-pocket-subtitle" + className="subtitle" + data-l10n-id="newtab-custom-pocket-subtitle" + /> + {(mayHaveSponsoredStories || mayHaveRecentSaves) && ( + <div className="more-info-pocket-wrapper"> + <div className="more-information" ref={this.pocketDrawerRef}> + {mayHaveSponsoredStories && ( + <div className="check-wrapper" role="presentation"> + <input + id="sponsored-pocket" + className="sponsored-checkbox" + disabled={!pocketEnabled} + checked={showSponsoredPocketEnabled} + type="checkbox" + onChange={this.onPreferenceSelect} + preference="showSponsored" + eventSource="POCKET_SPOCS" + /> + <label + className="sponsored" + htmlFor="sponsored-pocket" + data-l10n-id="newtab-custom-pocket-sponsored" + /> + </div> + )} + {mayHaveRecentSaves && ( + <div className="check-wrapper" role="presentation"> + <input + id="recent-saves-pocket" + className="sponsored-checkbox" + disabled={!pocketEnabled} + checked={showRecentSavesEnabled} + type="checkbox" + onChange={this.onPreferenceSelect} + preference="showRecentSaves" + eventSource="POCKET_RECENT_SAVES" + /> + <label + className="sponsored" + htmlFor="recent-saves-pocket" + data-l10n-id="newtab-custom-pocket-show-recent-saves" + /> + </div> + )} + </div> + </div> + )} + </div> + </div> + )} + + <div id="recent-section" className="section"> + <label className="switch"> + <input + id="highlights-toggle" + checked={highlightsEnabled} + type="checkbox" + onChange={this.onPreferenceSelect} + preference="feeds.section.highlights" + eventSource="HIGHLIGHTS" + aria-labelledby="custom-recent-title" + aria-describedby="custom-recent-subtitle" + /> + <span className="slider" role="presentation"></span> + </label> + <div> + <h2 id="custom-recent-title" className="title"> + <label + htmlFor="highlights-toggle" + data-l10n-id="newtab-custom-recent-title" + ></label> + </h2> + + <p + id="custom-recent-subtitle" + className="subtitle" + data-l10n-id="newtab-custom-recent-subtitle" + /> + </div> + </div> + + <span className="divider" role="separator"></span> + + <div> + <button + id="settings-link" + className="external-link" + onClick={openPreferences} + data-l10n-id="newtab-custom-settings" + /> + </div> + </div> + ); + } +} diff --git a/browser/components/newtab/content-src/components/CustomizeMenu/CustomizeMenu.jsx b/browser/components/newtab/content-src/components/CustomizeMenu/CustomizeMenu.jsx new file mode 100644 index 0000000000..2b16c7fd39 --- /dev/null +++ b/browser/components/newtab/content-src/components/CustomizeMenu/CustomizeMenu.jsx @@ -0,0 +1,87 @@ +/* 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 { BackgroundsSection } from "content-src/components/CustomizeMenu/BackgroundsSection/BackgroundsSection"; +import { ContentSection } from "content-src/components/CustomizeMenu/ContentSection/ContentSection"; +import { connect } from "react-redux"; +import React from "react"; +import { CSSTransition } from "react-transition-group"; + +export class _CustomizeMenu extends React.PureComponent { + constructor(props) { + super(props); + this.onEntered = this.onEntered.bind(this); + this.onExited = this.onExited.bind(this); + } + + onEntered() { + if (this.closeButton) { + this.closeButton.focus(); + } + } + + onExited() { + if (this.openButton) { + this.openButton.focus(); + } + } + + render() { + return ( + <span> + <CSSTransition + timeout={300} + classNames="personalize-animate" + in={!this.props.showing} + appear={true} + > + <button + className="icon icon-settings personalize-button" + onClick={() => this.props.onOpen()} + data-l10n-id="newtab-personalize-icon-label" + ref={c => (this.openButton = c)} + /> + </CSSTransition> + <CSSTransition + timeout={250} + classNames="customize-animate" + in={this.props.showing} + onEntered={this.onEntered} + onExited={this.onExited} + appear={true} + > + <div + className="customize-menu" + role="dialog" + data-l10n-id="newtab-personalize-dialog-label" + > + <button + onClick={() => this.props.onClose()} + className="close-button" + data-l10n-id="newtab-custom-close-button" + ref={c => (this.closeButton = c)} + /> + <BackgroundsSection /> + <ContentSection + openPreferences={this.props.openPreferences} + setPref={this.props.setPref} + enabledSections={this.props.enabledSections} + pocketRegion={this.props.pocketRegion} + mayHaveSponsoredTopSites={this.props.mayHaveSponsoredTopSites} + mayHaveSponsoredStories={ + this.props.DiscoveryStream.config.show_spocs + } + mayHaveRecentSaves={this.props.DiscoveryStream.recentSavesEnabled} + dispatch={this.props.dispatch} + /> + </div> + </CSSTransition> + </span> + ); + } +} + +export const CustomizeMenu = connect(state => ({ + DiscoveryStream: state.DiscoveryStream, +}))(_CustomizeMenu); diff --git a/browser/components/newtab/content-src/components/CustomizeMenu/_CustomizeMenu.scss b/browser/components/newtab/content-src/components/CustomizeMenu/_CustomizeMenu.scss new file mode 100644 index 0000000000..98046c2326 --- /dev/null +++ b/browser/components/newtab/content-src/components/CustomizeMenu/_CustomizeMenu.scss @@ -0,0 +1,311 @@ +@media (max-height: 701px) { + .personalize-button { + position: absolute; + top: 16px; + inset-inline-end: 16px; + } +} + +@media (min-height: 700px) { + .personalize-button { + position: fixed; + top: 16px; + inset-inline-end: 16px; + z-index: 1000; + } +} + +.personalize-button { + border: 0; + border-radius: 4px; + background-color: transparent; + padding: 15px; + + &:hover { + background-color: var(--newtab-element-hover-color); + } + + &:active { + background-color: var(--newtab-element-active-color); + } + + &:focus-visible { + @include ds-focus; + } + + &.personalize-animate-exit-done { + visibility: hidden; + } +} + +.customize-menu { + color: var(--newtab-text-primary-color); + background-color: var(--newtab-background-color-secondary); + width: 432px; + height: 100%; + position: fixed; + inset-block: 0; + inset-inline-end: 0; + z-index: 1001; + padding: 16px; + overflow: auto; + transform: translateX(435px); + visibility: hidden; + cursor: default; + + @media (prefers-reduced-motion: no-preference) { + transition: transform 250ms $customize-menu-slide-bezier, visibility 250ms; + } + + @media (forced-colors: active) { + border-inline-start: solid 1px; + } + + &:dir(rtl) { + transform: translateX(-435px); + } + + &.customize-animate-enter-done, + &.customize-animate-enter-active { + box-shadow: $shadow-large; + visibility: visible; + transform: translateX(0); + } + + &.customize-animate-exit-active { + box-shadow: $shadow-large; + } + + .close-button { + margin-inline-start: auto; + margin-bottom: 28px; + white-space: nowrap; + display: block; + background-color: var(--newtab-element-secondary-color); + padding: 8px 10px; + border: $customize-menu-border-tint; + border-radius: 4px; + color: var(--newtab-text-primary-color); + font-size: 13px; + font-weight: 600; + } + + .close-button:hover { + background-color: var(--newtab-element-secondary-hover-color); + } + + .close-button:hover:active { + background-color: var(--newtab-element-secondary-active-color); + } +} + +.grid-skip { + display: contents; +} + +.home-section { + display: grid; + grid-template-columns: 1fr; + grid-template-rows: repeat(4, auto); + grid-row-gap: 32px; + padding: 0 16px; + + .section { + display: grid; + grid-template-rows: auto; + grid-template-columns: auto 26px; + + & > div { + grid-area: 1; + } + + .title { + grid-column: 1 / -1; + margin: 0; + font-weight: 600; + font-size: 16px; + margin-bottom: 10px; + } + + .subtitle { + margin: 0; + font-size: 14px; + } + + .sponsored { + font-size: 14px; + margin-inline-start: 5px; + } + + .check-wrapper { + position: relative; + } + + .sponsored-checkbox { + margin-inline-start: 2px; + width: 16px; + height: 16px; + vertical-align: middle; + border: $customize-menu-border-tint; + box-sizing: border-box; + border-radius: 4px; + appearance: none; + background-color: var(--newtab-element-secondary-color); + } + + .sponsored-checkbox:checked { + -moz-context-properties: fill; + fill: var(--newtab-primary-element-text-color); + background: url('chrome://global/skin/icons/check.svg') center no-repeat; + background-color: var(--newtab-primary-action-background); + + @media (forced-colors: active) { + fill: $black; + } + } + + .sponsored-checkbox:active + .checkmark { + fill: var(--newtab-element-secondary-color); + } + + .selector { + color: var(--newtab-text-primary-color); + width: 118px; + display: block; + border: 1px solid var(--newtab-border-color); + border-radius: 4px; + appearance: none; + padding-block: 7px; + padding-inline: 10px 13px; + margin-inline-start: 2px; + margin-bottom: 2px; + -moz-context-properties: fill; + fill: var(--newtab-text-primary-color); + background: url('chrome://global/skin/icons/arrow-down-12.svg') right no-repeat; + background-size: 8px; + background-origin: content-box; + background-color: var(--newtab-background-color-secondary); + + &:dir(rtl) { + background-position-x: left; + } + } + + .switch { + position: relative; + display: inline-block; + width: 26px; + height: 16px; + grid-column: 2; + margin-top: 2px; + } + + .switch input { + opacity: 0; + width: 0; + height: 0; + } + + .slider { + position: absolute; + inset: 0; + transition: transform 250ms; + border-radius: 13px; + border: $customize-menu-border-tint; + background-color: var(--newtab-element-secondary-color); + + &::before { + position: absolute; + content: ''; + height: 8px; + width: 8px; + inset-inline-start: 3px; + bottom: 3px; + background-color: var(--newtab-primary-element-text-color); + transition: transform 250ms; + border-radius: 50%; + outline: $customize-menu-border-tint; + } + } + + .switch input:focus-visible + .slider { + outline: 0; + box-shadow: $shadow-focus; + } + + .switch input:not(:checked):focus-visible + .slider { + border: 1px solid var(--newtab-primary-action-background); + } + + input:checked + .slider { + background-color: var(--newtab-primary-action-background); + } + + input:checked + .slider::before { + transform: translateX(10px); + } + + input:checked + .slider:dir(rtl)::before { + transform: translateX(-10px); + } + + .more-info-top-wrapper, + .more-info-pocket-wrapper { + margin-inline-start: -2px; + overflow: hidden; + + .more-information { + padding-top: 12px; + position: relative; + transition: margin-top 250ms $customize-menu-expand-bezier; + } + } + + .more-info-top-wrapper { + .check-wrapper { + margin-top: 10px; + } + } + } + + .divider { + border-top: 1px var(--newtab-border-color) solid; + margin: 0 -16px; + } + + .external-link { + font-size: 14px; + cursor: pointer; + border: 1px solid transparent; + border-radius: 4px; + -moz-context-properties: fill; + fill: var(--newtab-text-primary-color); + background: url('chrome://global/skin/icons/settings.svg') left no-repeat; + background-size: 16px; + padding-inline-start: 21px; + margin-bottom: 20px; + text-decoration: underline; + + @media (forced-colors: active) { + padding: 8px 10px; + padding-inline-start: 21px; + } + + &:dir(rtl) { + background-position-x: right; + } + } + + .external-link:hover { + text-decoration: none; + } +} + +.home-section .section .sponsored-checkbox:focus-visible, +.selector:focus-visible, +.external-link:focus-visible, +.close-button:focus-visible { + border: 1px solid var(--newtab-primary-action-background); + outline: 0; + box-shadow: $shadow-focus; +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx new file mode 100644 index 0000000000..9acfef211b --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx @@ -0,0 +1,384 @@ +/* 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 { CardGrid } from "content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid"; +import { CollectionCardGrid } from "content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid"; +import { CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection"; +import { connect } from "react-redux"; +import { DSMessage } from "content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage"; +import { DSPrivacyModal } from "content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal"; +import { DSSignup } from "content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup"; +import { DSTextPromo } from "content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo"; +import { Highlights } from "content-src/components/DiscoveryStreamComponents/Highlights/Highlights"; +import { HorizontalRule } from "content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule"; +import { Navigation } from "content-src/components/DiscoveryStreamComponents/Navigation/Navigation"; +import { PrivacyLink } from "content-src/components/DiscoveryStreamComponents/PrivacyLink/PrivacyLink"; +import React from "react"; +import { SectionTitle } from "content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle"; +import { selectLayoutRender } from "content-src/lib/selectLayoutRender"; +import { TopSites } from "content-src/components/TopSites/TopSites"; + +const ALLOWED_CSS_URL_PREFIXES = [ + "chrome://", + "resource://", + "https://img-getpocket.cdn.mozilla.net/", +]; +const DUMMY_CSS_SELECTOR = "DUMMY#CSS.SELECTOR"; + +/** + * Validate a CSS declaration. The values are assumed to be normalized by CSSOM. + */ +export function isAllowedCSS(property, value) { + // Bug 1454823: INTERNAL properties, e.g., -moz-context-properties, are + // exposed but their values aren't resulting in getting nothing. Fortunately, + // we don't care about validating the values of the current set of properties. + if (value === undefined) { + return true; + } + + // Make sure all urls are of the allowed protocols/prefixes + const urls = value.match(/url\("[^"]+"\)/g); + return ( + !urls || + urls.every(url => + ALLOWED_CSS_URL_PREFIXES.some(prefix => url.slice(5).startsWith(prefix)) + ) + ); +} + +export class _DiscoveryStreamBase extends React.PureComponent { + constructor(props) { + super(props); + this.onStyleMount = this.onStyleMount.bind(this); + } + + onStyleMount(style) { + // Unmounting style gets rid of old styles, so nothing else to do + if (!style) { + return; + } + + const { sheet } = style; + const styles = JSON.parse(style.dataset.styles); + styles.forEach((row, rowIndex) => { + row.forEach((component, componentIndex) => { + // Nothing to do without optional styles overrides + if (!component) { + return; + } + + Object.entries(component).forEach(([selectors, declarations]) => { + // Start with a dummy rule to validate declarations and selectors + sheet.insertRule(`${DUMMY_CSS_SELECTOR} {}`); + const [rule] = sheet.cssRules; + + // Validate declarations and remove any offenders. CSSOM silently + // discards invalid entries, so here we apply extra restrictions. + rule.style = declarations; + [...rule.style].forEach(property => { + const value = rule.style[property]; + if (!isAllowedCSS(property, value)) { + console.error(`Bad CSS declaration ${property}: ${value}`); + rule.style.removeProperty(property); + } + }); + + // Set the actual desired selectors scoped to the component + const prefix = `.ds-layout > .ds-column:nth-child(${ + rowIndex + 1 + }) .ds-column-grid > :nth-child(${componentIndex + 1})`; + // NB: Splitting on "," doesn't work with strings with commas, but + // we're okay with not supporting those selectors + rule.selectorText = selectors + .split(",") + .map( + selector => + prefix + + // Assume :pseudo-classes are for component instead of descendant + (selector[0] === ":" ? "" : " ") + + selector + ) + .join(","); + + // CSSOM silently ignores bad selectors, so we'll be noisy instead + if (rule.selectorText === DUMMY_CSS_SELECTOR) { + console.error(`Bad CSS selector ${selectors}`); + } + }); + }); + }); + } + + renderComponent(component, embedWidth) { + switch (component.type) { + case "Highlights": + return <Highlights />; + case "TopSites": + return ( + <div className="ds-top-sites"> + <TopSites isFixed={true} title={component.header?.title} /> + </div> + ); + case "TextPromo": + return ( + <DSTextPromo + dispatch={this.props.dispatch} + type={component.type} + data={component.data} + /> + ); + case "Signup": + return ( + <DSSignup + dispatch={this.props.dispatch} + type={component.type} + data={component.data} + /> + ); + case "Message": + return ( + <DSMessage + title={component.header && component.header.title} + subtitle={component.header && component.header.subtitle} + link_text={component.header && component.header.link_text} + link_url={component.header && component.header.link_url} + icon={component.header && component.header.icon} + essentialReadsHeader={component.essentialReadsHeader} + editorsPicksHeader={component.editorsPicksHeader} + /> + ); + case "SectionTitle": + return <SectionTitle header={component.header} />; + case "Navigation": + return ( + <Navigation + dispatch={this.props.dispatch} + links={component.properties.links} + extraLinks={component.properties.extraLinks} + alignment={component.properties.alignment} + explore_topics={component.properties.explore_topics} + header={component.header} + locale={this.props.App.locale} + newFooterSection={component.newFooterSection} + privacyNoticeURL={component.properties.privacyNoticeURL} + /> + ); + case "CollectionCardGrid": + const { DiscoveryStream } = this.props; + return ( + <CollectionCardGrid + data={component.data} + feed={component.feed} + spocs={DiscoveryStream.spocs} + placement={component.placement} + type={component.type} + items={component.properties.items} + dismissible={this.props.DiscoveryStream.isCollectionDismissible} + dispatch={this.props.dispatch} + /> + ); + case "CardGrid": + return ( + <CardGrid + title={component.header && component.header.title} + data={component.data} + feed={component.feed} + widgets={component.widgets} + type={component.type} + dispatch={this.props.dispatch} + items={component.properties.items} + hybridLayout={component.properties.hybridLayout} + hideCardBackground={component.properties.hideCardBackground} + fourCardLayout={component.properties.fourCardLayout} + compactGrid={component.properties.compactGrid} + essentialReadsHeader={component.properties.essentialReadsHeader} + onboardingExperience={component.properties.onboardingExperience} + editorsPicksHeader={component.properties.editorsPicksHeader} + recentSavesEnabled={this.props.DiscoveryStream.recentSavesEnabled} + hideDescriptions={this.props.DiscoveryStream.hideDescriptions} + /> + ); + case "HorizontalRule": + return <HorizontalRule />; + case "PrivacyLink": + return <PrivacyLink properties={component.properties} />; + default: + return <div>{component.type}</div>; + } + } + + renderStyles(styles) { + // Use json string as both the key and styles to render so React knows when + // to unmount and mount a new instance for new styles. + const json = JSON.stringify(styles); + return <style key={json} data-styles={json} ref={this.onStyleMount} />; + } + + render() { + const { locale } = this.props; + // Select layout render data by adding spocs and position to recommendations + const { layoutRender } = selectLayoutRender({ + state: this.props.DiscoveryStream, + prefs: this.props.Prefs.values, + locale, + }); + const { config } = this.props.DiscoveryStream; + + // Allow rendering without extracting special components + if (!config.collapsible) { + return this.renderLayout(layoutRender); + } + + // Find the first component of a type and remove it from layout + const extractComponent = type => { + for (const [rowIndex, row] of Object.entries(layoutRender)) { + for (const [index, component] of Object.entries(row.components)) { + if (component.type === type) { + // Remove the row if it was the only component or the single item + if (row.components.length === 1) { + layoutRender.splice(rowIndex, 1); + } else { + row.components.splice(index, 1); + } + return component; + } + } + } + return null; + }; + + // Get "topstories" Section state for default values + const topStories = this.props.Sections.find(s => s.id === "topstories"); + + if (!topStories) { + return null; + } + + // Extract TopSites to render before the rest and Message to use for header + const topSites = extractComponent("TopSites"); + const sponsoredCollection = extractComponent("CollectionCardGrid"); + const message = extractComponent("Message") || { + header: { + link_text: topStories.learnMore.link.message, + link_url: topStories.learnMore.link.href, + title: topStories.title, + }, + }; + + const privacyLinkComponent = extractComponent("PrivacyLink"); + let learnMore = { + link: { + href: message.header.link_url, + message: message.header.link_text, + }, + }; + let sectionTitle = message.header.title; + let subTitle = ""; + + // If we're in one of these experiments, override the default message. + // For now this is English only. + if (message.essentialReadsHeader || message.editorsPicksHeader) { + learnMore = null; + subTitle = "Recommended By Pocket"; + if (message.essentialReadsHeader) { + sectionTitle = "Today’s Essential Reads"; + } else if (message.editorsPicksHeader) { + sectionTitle = "Editor’s Picks"; + } + } + + // Render a DS-style TopSites then the rest if any in a collapsible section + return ( + <React.Fragment> + {this.props.DiscoveryStream.isPrivacyInfoModalVisible && ( + <DSPrivacyModal dispatch={this.props.dispatch} /> + )} + {topSites && + this.renderLayout([ + { + width: 12, + components: [topSites], + }, + ])} + {sponsoredCollection && + this.renderLayout([ + { + width: 12, + components: [sponsoredCollection], + }, + ])} + {!!layoutRender.length && ( + <CollapsibleSection + className="ds-layout" + collapsed={topStories.pref.collapsed} + dispatch={this.props.dispatch} + id={topStories.id} + isFixed={true} + learnMore={learnMore} + privacyNoticeURL={topStories.privacyNoticeURL} + showPrefName={topStories.pref.feed} + title={sectionTitle} + subTitle={subTitle} + eventSource="CARDGRID" + > + {this.renderLayout(layoutRender)} + </CollapsibleSection> + )} + {this.renderLayout([ + { + width: 12, + components: [{ type: "Highlights" }], + }, + ])} + {privacyLinkComponent && + this.renderLayout([ + { + width: 12, + components: [privacyLinkComponent], + }, + ])} + </React.Fragment> + ); + } + + renderLayout(layoutRender) { + const styles = []; + return ( + <div className="discovery-stream ds-layout"> + {layoutRender.map((row, rowIndex) => ( + <div + key={`row-${rowIndex}`} + className={`ds-column ds-column-${row.width}`} + > + <div className="ds-column-grid"> + {row.components.map((component, componentIndex) => { + if (!component) { + return null; + } + styles[rowIndex] = [ + ...(styles[rowIndex] || []), + component.styles, + ]; + return ( + <div key={`component-${componentIndex}`}> + {this.renderComponent(component, row.width)} + </div> + ); + })} + </div> + </div> + ))} + {this.renderStyles(styles)} + </div> + ); + } +} + +export const DiscoveryStreamBase = connect(state => ({ + DiscoveryStream: state.DiscoveryStream, + Prefs: state.Prefs, + Sections: state.Sections, + document: global.document, + App: state.App, +}))(_DiscoveryStreamBase); diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamBase/_DiscoveryStreamBase.scss b/browser/components/newtab/content-src/components/DiscoveryStreamBase/_DiscoveryStreamBase.scss new file mode 100644 index 0000000000..015cb57a8b --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamBase/_DiscoveryStreamBase.scss @@ -0,0 +1,67 @@ +$ds-width: 936px; + +.discovery-stream.ds-layout { + $columns: 12; + --gridColumnGap: 48px; + --gridRowGap: 24px; + + grid-template-columns: repeat($columns, 1fr); + grid-column-gap: var(--gridColumnGap); + grid-row-gap: var(--gridRowGap); + margin: 0 auto; + + @while $columns > 0 { + .ds-column-#{$columns} { + grid-column-start: auto; + grid-column-end: span $columns; + } + + $columns: $columns - 1; + } + + .ds-column-grid { + display: grid; + grid-row-gap: var(--gridRowGap); + + // We want to completely hide components with no content, + // otherwise, it creates grid-row-gap gaps around nothing. + > div:empty { + display: none; + } + } +} + +.ds-header { + margin: 8px 0; + + .ds-context { + font-weight: 400; + } +} + +.ds-header, +.ds-layout .section-title span { + color: var(--newtab-text-primary-color); + font-size: $section-title-font-size; + font-weight: 600; + line-height: 20px; + + .icon { + fill: var(--newtab-text-secondary-color); + } +} + +.collapsible-section.ds-layout { + margin: auto; + + .section-top-bar { + .learn-more-link a { + color: var(--newtab-primary-action-background); + font-weight: 500; + + &:is(:focus, :hover) { + text-decoration: none; + } + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx new file mode 100644 index 0000000000..09dc657cbb --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx @@ -0,0 +1,536 @@ +/* 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 { DSCard, PlaceholderDSCard } from "../DSCard/DSCard.jsx"; +import { DSEmptyState } from "../DSEmptyState/DSEmptyState.jsx"; +import { DSDismiss } from "content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss"; +import { TopicsWidget } from "../TopicsWidget/TopicsWidget.jsx"; +import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; +import { FluentOrText } from "../../FluentOrText/FluentOrText.jsx"; +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import React, { useEffect, useState, useRef, useCallback } from "react"; +import { connect, useSelector } from "react-redux"; +const PREF_ONBOARDING_EXPERIENCE_DISMISSED = + "discoverystream.onboardingExperience.dismissed"; +const INTERSECTION_RATIO = 0.5; +const VISIBLE = "visible"; +const VISIBILITY_CHANGE_EVENT = "visibilitychange"; +const WIDGET_IDS = { + TOPICS: 1, +}; + +export function DSSubHeader({ children }) { + return ( + <div className="section-top-bar ds-sub-header"> + <h3 className="section-title-container">{children}</h3> + </div> + ); +} + +export function OnboardingExperience({ + children, + dispatch, + windowObj = global, +}) { + const [dismissed, setDismissed] = useState(false); + const [maxHeight, setMaxHeight] = useState(null); + const heightElement = useRef(null); + + const onDismissClick = useCallback(() => { + // We update this as state and redux. + // The state update is for this newtab, + // and the redux update is for other tabs, offscreen tabs, and future tabs. + // We need the state update for this tab to support the transition. + setDismissed(true); + dispatch(ac.SetPref(PREF_ONBOARDING_EXPERIENCE_DISMISSED, true)); + dispatch( + ac.DiscoveryStreamUserEvent({ + event: "BLOCK", + source: "POCKET_ONBOARDING", + }) + ); + }, [dispatch]); + + useEffect(() => { + const resizeObserver = new windowObj.ResizeObserver(() => { + if (heightElement.current) { + setMaxHeight(heightElement.current.offsetHeight); + } + }); + + const options = { threshold: INTERSECTION_RATIO }; + const intersectionObserver = new windowObj.IntersectionObserver(entries => { + if ( + entries.some( + entry => + entry.isIntersecting && + entry.intersectionRatio >= INTERSECTION_RATIO + ) + ) { + dispatch( + ac.DiscoveryStreamUserEvent({ + event: "IMPRESSION", + source: "POCKET_ONBOARDING", + }) + ); + // Once we have observed an impression, we can stop for this instance of newtab. + intersectionObserver.unobserve(heightElement.current); + } + }, options); + + const onVisibilityChange = () => { + intersectionObserver.observe(heightElement.current); + windowObj.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + onVisibilityChange + ); + }; + + if (heightElement.current) { + resizeObserver.observe(heightElement.current); + // Check visibility or setup a visibility event to make + // sure we don't fire this for off screen pre loaded tabs. + if (windowObj.document.visibilityState === VISIBLE) { + intersectionObserver.observe(heightElement.current); + } else { + windowObj.document.addEventListener( + VISIBILITY_CHANGE_EVENT, + onVisibilityChange + ); + } + setMaxHeight(heightElement.current.offsetHeight); + } + + // Return unmount callback to clean up observers. + return () => { + resizeObserver?.disconnect(); + intersectionObserver?.disconnect(); + windowObj.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + onVisibilityChange + ); + }; + }, [dispatch, windowObj]); + + const style = {}; + if (dismissed) { + style.maxHeight = "0"; + style.opacity = "0"; + style.transition = "max-height 0.26s ease, opacity 0.26s ease"; + } else if (maxHeight) { + style.maxHeight = `${maxHeight}px`; + } + + return ( + <div style={style}> + <div className="ds-onboarding-ref" ref={heightElement}> + <div className="ds-onboarding-container"> + <DSDismiss + onDismissClick={onDismissClick} + extraClasses={`ds-onboarding`} + > + <div> + <header> + <span className="icon icon-pocket" /> + <span data-l10n-id="newtab-pocket-onboarding-discover" /> + </header> + <p data-l10n-id="newtab-pocket-onboarding-cta" /> + </div> + <div className="ds-onboarding-graphic" /> + </DSDismiss> + </div> + </div> + </div> + ); +} + +export function IntersectionObserver({ + children, + windowObj = window, + onIntersecting, +}) { + const intersectionElement = useRef(null); + + useEffect(() => { + let observer; + if (!observer && onIntersecting && intersectionElement.current) { + observer = new windowObj.IntersectionObserver(entries => { + const entry = entries.find(e => e.isIntersecting); + + if (entry) { + // Stop observing since element has been seen + if (observer && intersectionElement.current) { + observer.unobserve(intersectionElement.current); + } + + onIntersecting(); + } + }); + observer.observe(intersectionElement.current); + } + // Cleanup + return () => observer?.disconnect(); + }, [windowObj, onIntersecting]); + + return <div ref={intersectionElement}>{children}</div>; +} + +export function RecentSavesContainer({ + gridClassName = "", + dispatch, + windowObj = window, + items = 3, + source = "CARDGRID_RECENT_SAVES", +}) { + const { + recentSavesData, + isUserLoggedIn, + experimentData: { utmCampaign, utmContent, utmSource }, + } = useSelector(state => state.DiscoveryStream); + + const [visible, setVisible] = useState(false); + const onIntersecting = useCallback(() => setVisible(true), []); + + useEffect(() => { + if (visible) { + dispatch( + ac.AlsoToMain({ + type: at.DISCOVERY_STREAM_POCKET_STATE_INIT, + }) + ); + } + }, [visible, dispatch]); + + // The user has not yet scrolled to this section, + // so wait before potentially requesting Pocket data. + if (!visible) { + return ( + <IntersectionObserver + windowObj={windowObj} + onIntersecting={onIntersecting} + /> + ); + } + + // Intersection observer has finished, but we're not yet logged in. + if (visible && !isUserLoggedIn) { + return null; + } + + let queryParams = `?utm_source=${utmSource}`; + // We really only need to add these params to urls we own. + if (utmCampaign && utmContent) { + queryParams += `&utm_content=${utmContent}&utm_campaign=${utmCampaign}`; + } + + function renderCard(rec, index) { + const url = new URL(rec.url); + const urlSearchParams = new URLSearchParams(queryParams); + if (rec?.id && !url.href.match(/getpocket\.com\/read/)) { + url.href = `https://getpocket.com/read/${rec.id}`; + } + + for (let [key, val] of urlSearchParams.entries()) { + url.searchParams.set(key, val); + } + + return ( + <DSCard + key={`dscard-${rec?.id || index}`} + id={rec.id} + pos={index} + type={source} + image_src={rec.image_src} + raw_image_src={rec.raw_image_src} + word_count={rec.word_count} + time_to_read={rec.time_to_read} + title={rec.title} + excerpt={rec.excerpt} + url={url.href} + source={rec.domain} + isRecentSave={true} + dispatch={dispatch} + /> + ); + } + + function onMyListClicked() { + dispatch( + ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source: `${source}_VIEW_LIST`, + }) + ); + } + + const recentSavesCards = []; + // We fill the cards with a for loop over an inline map because + // we want empty placeholders if there are not enough cards. + for (let index = 0; index < items; index++) { + const recentSave = recentSavesData[index]; + if (!recentSave) { + recentSavesCards.push(<PlaceholderDSCard key={`dscard-${index}`} />); + } else { + recentSavesCards.push( + renderCard( + { + id: recentSave.id, + image_src: recentSave.top_image_url, + raw_image_src: recentSave.top_image_url, + word_count: recentSave.word_count, + time_to_read: recentSave.time_to_read, + title: recentSave.resolved_title || recentSave.given_title, + url: recentSave.resolved_url || recentSave.given_url, + domain: recentSave.domain_metadata?.name, + excerpt: recentSave.excerpt, + }, + index + ) + ); + } + } + + // We are visible and logged in. + return ( + <> + <DSSubHeader> + <span className="section-title"> + <FluentOrText message="Recently Saved to your List" /> + </span> + <SafeAnchor + onLinkClick={onMyListClicked} + className="section-sub-link" + url={`https://getpocket.com/a${queryParams}`} + > + <FluentOrText message="View My List" /> + </SafeAnchor> + </DSSubHeader> + <div className={`ds-card-grid-recent-saves ${gridClassName}`}> + {recentSavesCards} + </div> + </> + ); +} + +export class _CardGrid extends React.PureComponent { + renderCards() { + const prefs = this.props.Prefs.values; + const { + items, + hybridLayout, + hideCardBackground, + fourCardLayout, + compactGrid, + essentialReadsHeader, + editorsPicksHeader, + onboardingExperience, + widgets, + recentSavesEnabled, + hideDescriptions, + DiscoveryStream, + } = this.props; + const { saveToPocketCard } = DiscoveryStream; + const showRecentSaves = prefs.showRecentSaves && recentSavesEnabled; + const isOnboardingExperienceDismissed = + prefs[PREF_ONBOARDING_EXPERIENCE_DISMISSED]; + + const recs = this.props.data.recommendations.slice(0, items); + const cards = []; + let essentialReadsCards = []; + let editorsPicksCards = []; + + for (let index = 0; index < items; index++) { + const rec = recs[index]; + cards.push( + !rec || rec.placeholder ? ( + <PlaceholderDSCard key={`dscard-${index}`} /> + ) : ( + <DSCard + key={`dscard-${rec.id}`} + pos={rec.pos} + flightId={rec.flight_id} + image_src={rec.image_src} + raw_image_src={rec.raw_image_src} + word_count={rec.word_count} + time_to_read={rec.time_to_read} + title={rec.title} + excerpt={rec.excerpt} + url={rec.url} + id={rec.id} + shim={rec.shim} + type={this.props.type} + context={rec.context} + sponsor={rec.sponsor} + sponsored_by_override={rec.sponsored_by_override} + dispatch={this.props.dispatch} + source={rec.domain} + pocket_id={rec.pocket_id} + context_type={rec.context_type} + bookmarkGuid={rec.bookmarkGuid} + is_collection={this.props.is_collection} + saveToPocketCard={saveToPocketCard} + /> + ) + ); + } + + if (widgets?.positions?.length && widgets?.data?.length) { + let positionIndex = 0; + const source = "CARDGRID_WIDGET"; + + for (const widget of widgets.data) { + let widgetComponent = null; + const position = widgets.positions[positionIndex]; + + // Stop if we run out of positions to place widgets. + if (!position) { + break; + } + + switch (widget?.type) { + case "TopicsWidget": + widgetComponent = ( + <TopicsWidget + position={position.index} + dispatch={this.props.dispatch} + source={source} + id={WIDGET_IDS.TOPICS} + /> + ); + break; + } + + if (widgetComponent) { + // We found a widget, so up the position for next try. + positionIndex++; + // We replace an existing card with the widget. + cards.splice(position.index, 1, widgetComponent); + } + } + } + + let moreRecsHeader = ""; + // For now this is English only. + if (showRecentSaves || (essentialReadsHeader && editorsPicksHeader)) { + let spliceAt = 6; + // For 4 card row layouts, second row is 8 cards, and regular it is 6 cards. + if (fourCardLayout) { + spliceAt = 8; + } + // If we have a custom header, ensure the more recs section also has a header. + moreRecsHeader = "More Recommendations"; + // Put the first 2 rows into essentialReadsCards. + essentialReadsCards = [...cards.splice(0, spliceAt)]; + // Put the rest into editorsPicksCards. + if (essentialReadsHeader && editorsPicksHeader) { + editorsPicksCards = [...cards.splice(0, cards.length)]; + } + } + + const hideCardBackgroundClass = hideCardBackground + ? `ds-card-grid-hide-background` + : ``; + const fourCardLayoutClass = fourCardLayout + ? `ds-card-grid-four-card-variant` + : ``; + const hideDescriptionsClassName = !hideDescriptions + ? `ds-card-grid-include-descriptions` + : ``; + const compactGridClassName = compactGrid ? `ds-card-grid-compact` : ``; + const hybridLayoutClassName = hybridLayout + ? `ds-card-grid-hybrid-layout` + : ``; + + const gridClassName = `ds-card-grid ${hybridLayoutClassName} ${hideCardBackgroundClass} ${fourCardLayoutClass} ${hideDescriptionsClassName} ${compactGridClassName}`; + + return ( + <> + {!isOnboardingExperienceDismissed && onboardingExperience && ( + <OnboardingExperience dispatch={this.props.dispatch} /> + )} + {essentialReadsCards?.length > 0 && ( + <div className={gridClassName}>{essentialReadsCards}</div> + )} + {showRecentSaves && ( + <RecentSavesContainer + gridClassName={gridClassName} + dispatch={this.props.dispatch} + /> + )} + {editorsPicksCards?.length > 0 && ( + <> + <DSSubHeader> + <span className="section-title"> + <FluentOrText message="Editor’s Picks" /> + </span> + </DSSubHeader> + <div className={gridClassName}>{editorsPicksCards}</div> + </> + )} + {cards?.length > 0 && ( + <> + {moreRecsHeader && ( + <DSSubHeader> + <span className="section-title"> + <FluentOrText message={moreRecsHeader} /> + </span> + </DSSubHeader> + )} + <div className={gridClassName}>{cards}</div> + </> + )} + </> + ); + } + + render() { + const { data } = this.props; + + // Handle a render before feed has been fetched by displaying nothing + if (!data) { + return null; + } + + // Handle the case where a user has dismissed all recommendations + const isEmpty = data.recommendations.length === 0; + + return ( + <div> + {this.props.title && ( + <div className="ds-header"> + <div className="title">{this.props.title}</div> + {this.props.context && ( + <FluentOrText message={this.props.context}> + <div className="ds-context" /> + </FluentOrText> + )} + </div> + )} + {isEmpty ? ( + <div className="ds-card-grid empty"> + <DSEmptyState + status={data.status} + dispatch={this.props.dispatch} + feed={this.props.feed} + /> + </div> + ) : ( + this.renderCards() + )} + </div> + ); + } +} + +_CardGrid.defaultProps = { + items: 4, // Number of stories to display +}; + +export const CardGrid = connect(state => ({ + Prefs: state.Prefs, + DiscoveryStream: state.DiscoveryStream, +}))(_CardGrid); diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/_CardGrid.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/_CardGrid.scss new file mode 100644 index 0000000000..fb838f4628 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/_CardGrid.scss @@ -0,0 +1,355 @@ +$col4-header-line-height: 20; +$col4-header-font-size: 14; + +.ds-onboarding-container, +.ds-card-grid .ds-card { + @include dark-theme-only { + background: none; + } + + background: $white; + border-radius: 4px; + + &:not(.placeholder) { + @include dark-theme-only { + background: var(--newtab-background-color-secondary); + } + + border-radius: $border-radius-new; + box-shadow: $shadow-card; + + .img-wrapper .img { + img, + .placeholder-image { + border-radius: $border-radius-new $border-radius-new 0 0; + } + } + } +} + +.ds-onboarding-container { + padding-inline-start: 16px; + padding-inline-end: 16px; + + @media (min-width: $break-point-medium) { + padding-inline-end: 48px; + } + + @media (min-width: $break-point-large) { + padding-inline-end: 56px; + } + + margin-bottom: 24px; + // This is to position the dismiss button to the right most of this element. + position: relative; + + .ds-onboarding { + position: static; + display: flex; + + .ds-dismiss-button { + inset-inline-end: 8px; + top: 8px; + } + } + + header { + @include dark-theme-only { + color: var(--newtab-background-color-primary); + } + + display: flex; + margin: 32px 0 8px; + + @media (min-width: $break-point-medium) { + margin: 16px 0 8px; + display: block; + height: 24px; + } + + font-size: 17px; + line-height: 23.8px; + font-weight: 600; + color: $pocket-icon-fill; + } + + p { + margin: 8px 0 16px; + font-size: 13px; + line-height: 19.5px; + } + + .icon-pocket { + @include dark-theme-only { + @media (forced-colors: active) { + fill: CurrentColor; + } + fill: var(--newtab-text-primary-color); + } + @media (forced-colors: active) { + fill: CurrentColor; + } + + fill: $pocket-icon-fill; + margin-top: 3px; + margin-inline-end: 8px; + height: 22px; + width: 22px; + background-image: url('chrome://global/skin/icons/pocket.svg'); + + @media (min-width: $break-point-medium) { + margin-top: -5px; + margin-inline-start: -2px; + margin-inline-end: 15px; + height: 30px; + width: 30px; + } + + background-size: contain; + } + + .ds-onboarding-graphic { + background-image: url('chrome://activity-stream/content/data/content/assets/pocket-onboarding.avif'); + + @media (min-resolution: 2x) { + background-image: url('chrome://activity-stream/content/data/content/assets/pocket-onboarding@2x.avif'); + } + + border-radius: 8px; + background-size: contain; + background-repeat: no-repeat; + background-position: center; + height: 120px; + width: 200px; + margin-top: 16px; + margin-bottom: 16px; + margin-inline-start: 54px; + flex-shrink: 0; + display: none; + + @media (min-width: $break-point-large) { + display: block; + } + } +} + +.ds-card-grid { + display: grid; + grid-gap: 24px; + + &.ds-card-grid-compact { + grid-gap: 20px; + } + + &.ds-card-grid-recent-saves { + .ds-card { + // Hide the second row orphan on narrow screens. + @media (min-width: $break-point-medium) and (max-width: $break-point-large) { + &:last-child:nth-child(2n - 1) { + display: none; + } + } + } + } + + .ds-card-link:focus { + @include ds-focus; + + transition: none; + border-radius: $border-radius-new; + } + + // "2/3 width layout" + .ds-column-5 &, + .ds-column-6 &, + .ds-column-7 &, + .ds-column-8 & { + grid-template-columns: repeat(2, 1fr); + } + + // "Full width layout" + .ds-column-9 &, + .ds-column-10 &, + .ds-column-11 &, + .ds-column-12 & { + grid-template-columns: repeat(1, 1fr); + + @media (min-width: $break-point-medium) { + grid-template-columns: repeat(2, 1fr); + } + + @media (min-width: $break-point-large) { + grid-template-columns: repeat(3, 1fr); + } + + .title { + font-size: 17px; + line-height: 24px; + } + + .excerpt { + @include limit-visible-lines(3, 24, 15); + } + } + + &.empty { + grid-template-columns: auto; + } + + @mixin small-cards { + .ds-card { + &.placeholder { + min-height: 247px; + } + + .meta { + .story-footer { + margin-top: 8px; + } + + .source, + .story-sponsored-label, + .status-message .story-context-label { + color: var(--newtab-text-secondary-color); + -webkit-line-clamp: 2; + } + + .source, + .story-sponsored-label { + font-size: 13px; + } + + .status-message .story-context-label { + font-size: 11.7px; + } + + .story-badge-icon { + margin-inline-end: 2px; + margin-bottom: 2px; + height: 14px; + width: 14px; + background-size: 14px; + } + + .title { + font-size: 14px; + line-height: 20px; + } + + .info-wrap { + flex-grow: 0; + } + } + } + } + + &.ds-card-grid-four-card-variant { + // "Full width layout" + .ds-column-9 &, + .ds-column-10 &, + .ds-column-11 &, + .ds-column-12 & { + grid-template-columns: repeat(1, 1fr); + + @media (min-width: $break-point-medium) { + grid-template-columns: repeat(2, 1fr); + } + + @media (min-width: $break-point-large) { + grid-template-columns: repeat(3, 1fr); + } + + @media (min-width: $break-point-widest) { + grid-template-columns: repeat(4, 1fr); + } + } + + @include small-cards; + } + + &.ds-card-grid-hybrid-layout { + .ds-column-9 &, + .ds-column-10 &, + .ds-column-11 &, + .ds-column-12 & { + grid-template-columns: repeat(1, 1fr); + + @media (min-width: $break-point-medium) { + grid-template-columns: repeat(2, 1fr); + } + + @media (min-width: $break-point-large) { + grid-template-columns: repeat(3, 1fr); + } + + @media (max-height: 1065px) { + .excerpt { + display: none; + } + } + + @media (max-width: $break-point-widest) { + @include small-cards; + } + + @media (min-width: $break-point-widest) and (max-height: 964px) { + @include small-cards; + + grid-template-columns: repeat(4, 1fr); + } + } + } +} + +.outer-wrapper .ds-card-grid.ds-card-grid-hide-background .ds-card, +.outer-wrapper.newtab-experience .ds-card-grid.ds-card-grid-hide-background .ds-card { + &:not(.placeholder) { + box-shadow: none; + background: none; + + .ds-card-link:focus { + box-shadow: none; + + .img-wrapper .img img { + @include ds-focus; + } + } + + .img-wrapper .img img { + border-radius: 8px; + box-shadow: $shadow-card; + } + + .meta { + padding: 12px 0 0; + } + } +} + +.ds-layout { + .ds-sub-header { + margin-top: 24px; + + .section-title-container { + flex-direction: row; + align-items: baseline; + justify-content: space-between; + display: flex; + } + + .section-sub-link { + color: var(--newtab-primary-action-background); + font-size: 14px; + line-height: 16px; + cursor: pointer; + + &:hover { + text-decoration: underline; + } + + &:active { + color: var(--newtab-primary-element-active-color); + } + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid.jsx new file mode 100644 index 0000000000..d089a5c8ab --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid.jsx @@ -0,0 +1,139 @@ +/* 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 { actionCreators as ac } from "common/Actions.sys.mjs"; +import { CardGrid } from "content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid"; +import { DSDismiss } from "content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss"; +import { LinkMenuOptions } from "content-src/lib/link-menu-options"; +import React from "react"; + +export class CollectionCardGrid extends React.PureComponent { + constructor(props) { + super(props); + this.onDismissClick = this.onDismissClick.bind(this); + this.state = { + dismissed: false, + }; + } + + onDismissClick() { + const { data } = this.props; + if (this.props.dispatch && data && data.spocs && data.spocs.length) { + this.setState({ + dismissed: true, + }); + const pos = 0; + const source = this.props.type.toUpperCase(); + // Grab the available items in the array to dismiss. + // This fires a ping for all items available, even if below the fold. + const spocsData = data.spocs.map(item => ({ + url: item.url, + guid: item.id, + shim: item.shim, + flight_id: item.flightId, + })); + + const blockUrlOption = LinkMenuOptions.BlockUrls(spocsData, pos, source); + const { action, impression, userEvent } = blockUrlOption; + this.props.dispatch(action); + + this.props.dispatch( + ac.DiscoveryStreamUserEvent({ + event: userEvent, + source, + action_position: pos, + }) + ); + if (impression) { + this.props.dispatch(impression); + } + } + } + + render() { + const { data, dismissible, pocket_button_enabled } = this.props; + if ( + this.state.dismissed || + !data || + !data.spocs || + !data.spocs[0] || + // We only display complete collections. + data.spocs.length < 3 + ) { + return null; + } + const { spocs, placement, feed } = this.props; + // spocs.data is spocs state data, and not an array of spocs. + const { title, context, sponsored_by_override, sponsor } = + spocs.data[placement.name] || {}; + // Just in case of bad data, don't display a broken collection. + if (!title) { + return null; + } + + let sponsoredByMessage = ""; + + // If override is not false or an empty string. + if (sponsored_by_override || sponsored_by_override === "") { + // We specifically want to display nothing if the server returns an empty string. + // So the server can turn off the label. + // This is to support the use cases where the sponsored context is displayed elsewhere. + sponsoredByMessage = sponsored_by_override; + } else if (sponsor) { + sponsoredByMessage = { + id: `newtab-label-sponsored-by`, + values: { sponsor }, + }; + } else if (context) { + sponsoredByMessage = context; + } + + // Generally a card grid displays recs with spocs already injected. + // Normally it doesn't care which rec is a spoc and which isn't, + // it just displays content in a grid. + // For collections, we're only displaying a list of spocs. + // We don't need to tell the card grid that our list of cards are spocs, + // it shouldn't need to care. So we just pass our spocs along as recs. + // Think of it as injecting all rec positions with spocs. + // Consider maybe making recommendations in CardGrid use a more generic name. + const recsData = { + recommendations: data.spocs, + }; + + // All cards inside of a collection card grid have a slightly different type. + // For the case of interactions to the card grid, we use the type "COLLECTIONCARDGRID". + // Example, you dismiss the whole collection, we use the type "COLLECTIONCARDGRID". + // For interactions inside the card grid, example, you dismiss a single card in the collection, + // we use the type "COLLECTIONCARDGRID_CARD". + const type = `${this.props.type}_card`; + + const collectionGrid = ( + <div className="ds-collection-card-grid"> + <CardGrid + pocket_button_enabled={pocket_button_enabled} + title={title} + context={sponsoredByMessage} + data={recsData} + feed={feed} + type={type} + is_collection={true} + dispatch={this.props.dispatch} + items={this.props.items} + /> + </div> + ); + + if (dismissible) { + return ( + <DSDismiss + onDismissClick={this.onDismissClick} + extraClasses={`ds-dismiss-ds-collection`} + > + {collectionGrid} + </DSDismiss> + ); + } + return collectionGrid; + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/_CollectionCardGrid.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/_CollectionCardGrid.scss new file mode 100644 index 0000000000..f4778f3b95 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/_CollectionCardGrid.scss @@ -0,0 +1,38 @@ +.ds-dismiss.ds-dismiss-ds-collection { + .ds-dismiss-button { + margin: 15px 0 0; + inset-inline-end: 25px; + } + + &.hovering { + background: var(--newtab-element-hover-color); + } +} + +.ds-collection-card-grid { + padding: 10px 25px 25px; + margin: 0 0 20px; + + .story-footer { + display: none; + } + + .ds-header { + padding: 0 40px 0 0; + margin-bottom: 12px; + + .title { + color: var(--newtab-text-primary-color); + font-weight: 600; + font-size: 17px; + line-height: 24px; + } + + .ds-context { + color: var(--newtab-text-secondary-color); + font-weight: normal; + font-size: 13px; + line-height: 24px; + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx new file mode 100644 index 0000000000..561da8e2fa --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx @@ -0,0 +1,491 @@ +/* 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 { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { DSImage } from "../DSImage/DSImage.jsx"; +import { DSLinkMenu } from "../DSLinkMenu/DSLinkMenu"; +import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats"; +import React from "react"; +import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; +import { + DSContextFooter, + SponsorLabel, + DSMessageFooter, +} from "../DSContextFooter/DSContextFooter.jsx"; +import { FluentOrText } from "../../FluentOrText/FluentOrText.jsx"; +import { connect } from "react-redux"; + +const READING_WPM = 220; + +/** + * READ TIME FROM WORD COUNT + * @param {int} wordCount number of words in an article + * @returns {int} number of words per minute in minutes + */ +export function readTimeFromWordCount(wordCount) { + if (!wordCount) return false; + return Math.ceil(parseInt(wordCount, 10) / READING_WPM); +} + +export const DSSource = ({ + source, + timeToRead, + newSponsoredLabel, + context, + sponsor, + sponsored_by_override, +}) => { + // First try to display sponsored label or time to read here. + if (newSponsoredLabel) { + // If we can display something for spocs, do so. + if (sponsored_by_override || sponsor || context) { + return ( + <SponsorLabel + context={context} + sponsor={sponsor} + sponsored_by_override={sponsored_by_override} + newSponsoredLabel="new-sponsored-label" + /> + ); + } + } + + // If we are not a spoc, and can display a time to read value. + if (source && timeToRead) { + return ( + <p className="source clamp time-to-read"> + <FluentOrText + message={{ + id: `newtab-label-source-read-time`, + values: { source, timeToRead }, + }} + /> + </p> + ); + } + + // Otherwise display a default source. + return <p className="source clamp">{source}</p>; +}; + +export const DefaultMeta = ({ + source, + title, + excerpt, + timeToRead, + newSponsoredLabel, + context, + context_type, + sponsor, + sponsored_by_override, + saveToPocketCard, + isRecentSave, +}) => ( + <div className="meta"> + <div className="info-wrap"> + <DSSource + source={source} + timeToRead={timeToRead} + newSponsoredLabel={newSponsoredLabel} + context={context} + sponsor={sponsor} + sponsored_by_override={sponsored_by_override} + /> + <header title={title} className="title clamp"> + {title} + </header> + {excerpt && <p className="excerpt clamp">{excerpt}</p>} + </div> + {!newSponsoredLabel && ( + <DSContextFooter + context_type={context_type} + context={context} + sponsor={sponsor} + sponsored_by_override={sponsored_by_override} + /> + )} + {/* Sponsored label is normally in the way of any message. + newSponsoredLabel cards sponsored label is moved to just under the thumbnail, + so we can display both, so we specifically don't pass in context. */} + {newSponsoredLabel && ( + <DSMessageFooter + context_type={context_type} + context={null} + saveToPocketCard={saveToPocketCard} + /> + )} + </div> +); + +export class _DSCard extends React.PureComponent { + constructor(props) { + super(props); + + this.onLinkClick = this.onLinkClick.bind(this); + this.onSaveClick = this.onSaveClick.bind(this); + this.onMenuUpdate = this.onMenuUpdate.bind(this); + this.onMenuShow = this.onMenuShow.bind(this); + + this.setContextMenuButtonHostRef = element => { + this.contextMenuButtonHostElement = element; + }; + this.setPlaceholderRef = element => { + this.placeholderElement = element; + }; + + this.state = { + isSeen: false, + }; + + // If this is for the about:home startup cache, then we always want + // to render the DSCard, regardless of whether or not its been seen. + if (props.App.isForStartupCache) { + this.state.isSeen = true; + } + + // We want to choose the optimal thumbnail for the underlying DSImage, but + // want to do it in a performant way. The breakpoints used in the + // CSS of the page are, unfortuntely, not easy to retrieve without + // causing a style flush. To avoid that, we hardcode them here. + // + // The values chosen here were the dimensions of the card thumbnails as + // computed by getBoundingClientRect() for each type of viewport width + // across both high-density and normal-density displays. + this.dsImageSizes = [ + { + mediaMatcher: "(min-width: 1122px)", + width: 296, + height: 148, + }, + + { + mediaMatcher: "(min-width: 866px)", + width: 218, + height: 109, + }, + + { + mediaMatcher: "(max-width: 610px)", + width: 202, + height: 101, + }, + ]; + } + + onLinkClick(event) { + if (this.props.dispatch) { + this.props.dispatch( + ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source: this.props.type.toUpperCase(), + action_position: this.props.pos, + value: { card_type: this.props.flightId ? "spoc" : "organic" }, + }) + ); + + this.props.dispatch( + ac.ImpressionStats({ + source: this.props.type.toUpperCase(), + click: 0, + window_inner_width: this.props.windowObj.innerWidth, + window_inner_height: this.props.windowObj.innerHeight, + tiles: [ + { + id: this.props.id, + pos: this.props.pos, + ...(this.props.shim && this.props.shim.click + ? { shim: this.props.shim.click } + : {}), + type: this.props.flightId ? "spoc" : "organic", + }, + ], + }) + ); + } + } + + onSaveClick(event) { + if (this.props.dispatch) { + this.props.dispatch( + ac.AlsoToMain({ + type: at.SAVE_TO_POCKET, + data: { site: { url: this.props.url, title: this.props.title } }, + }) + ); + + this.props.dispatch( + ac.DiscoveryStreamUserEvent({ + event: "SAVE_TO_POCKET", + source: "CARDGRID_HOVER", + action_position: this.props.pos, + value: { card_type: this.props.flightId ? "spoc" : "organic" }, + }) + ); + + this.props.dispatch( + ac.ImpressionStats({ + source: "CARDGRID_HOVER", + pocket: 0, + tiles: [ + { + id: this.props.id, + pos: this.props.pos, + ...(this.props.shim && this.props.shim.save + ? { shim: this.props.shim.save } + : {}), + }, + ], + }) + ); + } + } + + onMenuUpdate(showContextMenu) { + if (!showContextMenu) { + const dsLinkMenuHostDiv = this.contextMenuButtonHostElement; + if (dsLinkMenuHostDiv) { + dsLinkMenuHostDiv.classList.remove("active", "last-item"); + } + } + } + + async onMenuShow() { + const dsLinkMenuHostDiv = this.contextMenuButtonHostElement; + if (dsLinkMenuHostDiv) { + // Force translation so we can be sure it's ready before measuring. + await this.props.windowObj.document.l10n.translateFragment( + dsLinkMenuHostDiv + ); + if (this.props.windowObj.scrollMaxX > 0) { + dsLinkMenuHostDiv.classList.add("last-item"); + } + dsLinkMenuHostDiv.classList.add("active"); + } + } + + onSeen(entries) { + if (this.state) { + const entry = entries.find(e => e.isIntersecting); + + if (entry) { + if (this.placeholderElement) { + this.observer.unobserve(this.placeholderElement); + } + + // Stop observing since element has been seen + this.setState({ + isSeen: true, + }); + } + } + } + + onIdleCallback() { + if (!this.state.isSeen) { + if (this.observer && this.placeholderElement) { + this.observer.unobserve(this.placeholderElement); + } + this.setState({ + isSeen: true, + }); + } + } + + componentDidMount() { + this.idleCallbackId = this.props.windowObj.requestIdleCallback( + this.onIdleCallback.bind(this) + ); + if (this.placeholderElement) { + this.observer = new IntersectionObserver(this.onSeen.bind(this)); + this.observer.observe(this.placeholderElement); + } + } + + componentWillUnmount() { + // Remove observer on unmount + if (this.observer && this.placeholderElement) { + this.observer.unobserve(this.placeholderElement); + } + if (this.idleCallbackId) { + this.props.windowObj.cancelIdleCallback(this.idleCallbackId); + } + } + + render() { + if (this.props.placeholder || !this.state.isSeen) { + return ( + <div className="ds-card placeholder" ref={this.setPlaceholderRef} /> + ); + } + + const { isRecentSave, DiscoveryStream, saveToPocketCard } = this.props; + let { source } = this.props; + if (!source) { + try { + source = new URL(this.props.url).hostname; + } catch (e) {} + } + + const { + pocketButtonEnabled, + hideDescriptions, + compactImages, + imageGradient, + newSponsoredLabel, + titleLines = 3, + descLines = 3, + readTime: displayReadTime, + } = DiscoveryStream; + + const excerpt = !hideDescriptions ? this.props.excerpt : ""; + + let timeToRead; + if (displayReadTime) { + timeToRead = + this.props.time_to_read || readTimeFromWordCount(this.props.word_count); + } + + const compactImagesClassName = compactImages ? `ds-card-compact-image` : ``; + const imageGradientClassName = imageGradient + ? `ds-card-image-gradient` + : ``; + const titleLinesName = `ds-card-title-lines-${titleLines}`; + const descLinesClassName = `ds-card-desc-lines-${descLines}`; + + let stpButton = () => { + return ( + <button className="card-stp-button" onClick={this.onSaveClick}> + {this.props.context_type === "pocket" ? ( + <> + <span className="story-badge-icon icon icon-pocket" /> + <span data-l10n-id="newtab-pocket-saved" /> + </> + ) : ( + <> + <span className="story-badge-icon icon icon-pocket-save" /> + <span data-l10n-id="newtab-pocket-save" /> + </> + )} + </button> + ); + }; + + return ( + <div + className={`ds-card ${compactImagesClassName} ${imageGradientClassName} ${titleLinesName} ${descLinesClassName}`} + ref={this.setContextMenuButtonHostRef} + > + <SafeAnchor + className="ds-card-link" + dispatch={this.props.dispatch} + onLinkClick={!this.props.placeholder ? this.onLinkClick : undefined} + url={this.props.url} + > + <div className="img-wrapper"> + <DSImage + extraClassNames="img" + source={this.props.image_src} + rawSource={this.props.raw_image_src} + sizes={this.dsImageSizes} + url={this.props.url} + title={this.props.title} + isRecentSave={isRecentSave} + /> + </div> + <DefaultMeta + source={source} + title={this.props.title} + excerpt={excerpt} + newSponsoredLabel={newSponsoredLabel} + timeToRead={timeToRead} + context={this.props.context} + context_type={this.props.context_type} + sponsor={this.props.sponsor} + sponsored_by_override={this.props.sponsored_by_override} + saveToPocketCard={saveToPocketCard} + /> + <ImpressionStats + flightId={this.props.flightId} + rows={[ + { + id: this.props.id, + pos: this.props.pos, + ...(this.props.shim && this.props.shim.impression + ? { shim: this.props.shim.impression } + : {}), + }, + ]} + dispatch={this.props.dispatch} + source={this.props.type} + /> + </SafeAnchor> + {saveToPocketCard && ( + <div className="card-stp-button-hover-background"> + <div className="card-stp-button-position-wrapper"> + {!this.props.flightId && stpButton()} + <DSLinkMenu + id={this.props.id} + index={this.props.pos} + dispatch={this.props.dispatch} + url={this.props.url} + title={this.props.title} + source={source} + type={this.props.type} + pocket_id={this.props.pocket_id} + shim={this.props.shim} + bookmarkGuid={this.props.bookmarkGuid} + flightId={ + !this.props.is_collection ? this.props.flightId : undefined + } + showPrivacyInfo={!!this.props.flightId} + onMenuUpdate={this.onMenuUpdate} + onMenuShow={this.onMenuShow} + saveToPocketCard={saveToPocketCard} + pocket_button_enabled={pocketButtonEnabled} + isRecentSave={isRecentSave} + /> + </div> + </div> + )} + {!saveToPocketCard && ( + <DSLinkMenu + id={this.props.id} + index={this.props.pos} + dispatch={this.props.dispatch} + url={this.props.url} + title={this.props.title} + source={source} + type={this.props.type} + pocket_id={this.props.pocket_id} + shim={this.props.shim} + bookmarkGuid={this.props.bookmarkGuid} + flightId={ + !this.props.is_collection ? this.props.flightId : undefined + } + showPrivacyInfo={!!this.props.flightId} + hostRef={this.contextMenuButtonHostRef} + onMenuUpdate={this.onMenuUpdate} + onMenuShow={this.onMenuShow} + pocket_button_enabled={pocketButtonEnabled} + isRecentSave={isRecentSave} + /> + )} + </div> + ); + } +} + +_DSCard.defaultProps = { + windowObj: window, // Added to support unit tests +}; + +export const DSCard = connect(state => ({ + App: state.App, + DiscoveryStream: state.DiscoveryStream, +}))(_DSCard); + +export const PlaceholderDSCard = props => <DSCard placeholder={true} />; diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss new file mode 100644 index 0000000000..a29087a5df --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss @@ -0,0 +1,251 @@ +// Type sizes +$header-font-size: 17; +$header-line-height: 24; +$excerpt-font-size: 14; +$excerpt-line-height: 20; +$ds-card-image-gradient-fade: rgba(0, 0, 0, 0%); +$ds-card-image-gradient-solid: rgba(0, 0, 0, 100%); + +.ds-card { + display: flex; + flex-direction: column; + position: relative; + + &.placeholder { + background: transparent; + box-shadow: inset $inner-box-shadow; + border-radius: 4px; + min-height: 300px; + } + + .img-wrapper { + width: 100%; + position: relative; + } + + .card-stp-button-hover-background { + opacity: 0; + width: 100%; + position: absolute; + top: 0; + height: 0; + transition: opacity; + transition-duration: 0s; + padding-top: 50%; + pointer-events: none; + background: $black-40; + border-radius: 8px 8px 0 0; + + .card-stp-button-position-wrapper { + position: absolute; + inset-inline-end: 10px; + top: 10px; + display: flex; + justify-content: end; + align-items: center; + } + + .icon-pocket-save, + .icon-pocket { + margin-inline-end: 4px; + height: 15px; + width: 15px; + background-size: 15px; + fill: $white; + } + + .context-menu-button { + position: static; + transition: none; + border-radius: 3px; + } + + .context-menu-position-container { + position: relative; + } + + .context-menu { + margin-inline-start: 18.5px; + inset-inline-start: auto; + position: absolute; + top: 20.25px; + } + + .card-stp-button { + display: flex; + margin-inline-end: 7px; + font-weight: 400; + font-size: 13px; + line-height: 16px; + background-color: $pocket-icon-fill; + border: 0; + border-radius: 4px; + padding: 6px; + white-space: nowrap; + color: $white; + } + + button, + .context-menu { + pointer-events: auto; + } + + button { + cursor: pointer; + } + } + + &.last-item { + .card-stp-button-hover-background { + .context-menu { + margin-inline-start: auto; + margin-inline-end: 18.5px; + } + } + } + + // The active class is added when the context menu is open. + &.active, + &:focus-within, + &:hover { + .card-stp-button-hover-background { + display: block; + opacity: 1; + transition-duration: 0.3s; + + .context-menu-button { + opacity: 1; + transform: scale(1); + } + } + } + + .img { + height: 0; + padding-top: 50%; // 2:1 aspect ratio + + img { + border-radius: 4px; + box-shadow: $shadow-image-inset; + } + } + + .ds-card-link { + height: 100%; + display: flex; + flex-direction: column; + text-decoration: none; + + &:hover { + header { + color: var(--newtab-primary-action-background); + } + } + + &:focus { + @include ds-focus; + + transition: none; + + header { + color: var(--newtab-primary-action-background); + } + } + + &:active { + header { + color: var(--newtab-primary-element-active-color); + } + } + } + + .meta { + display: flex; + flex-direction: column; + padding: 12px 16px; + flex-grow: 1; + + .info-wrap { + flex-grow: 1; + } + + .title { + // show only 3 lines of copy + @include limit-visible-lines(3, $header-line-height, $header-font-size); + + font-weight: 600; + } + + .excerpt { + // show only 3 lines of copy + @include limit-visible-lines( + 3, + $excerpt-line-height, + $excerpt-font-size + ); + } + + .source { + -webkit-line-clamp: 1; + margin-bottom: 2px; + font-size: 13px; + color: var(--newtab-text-secondary-color); + + span { + display: inline-block; + } + } + + .new-sponsored-label { + font-size: 13px; + margin-bottom: 2px; + } + } + + &.ds-card-title-lines-2 .meta .title { + // show only 2 lines of copy + @include limit-visible-lines(2, $header-line-height, $header-font-size); + } + + &.ds-card-title-lines-1 .meta .title { + // show only 1 line of copy + @include limit-visible-lines(1, $header-line-height, $header-font-size); + } + + &.ds-card-desc-lines-2 .meta .excerpt { + // show only 2 lines of copy + @include limit-visible-lines(2, $excerpt-line-height, $excerpt-font-size); + } + + &.ds-card-desc-lines-1 .meta .excerpt { + // show only 1 line of copy + @include limit-visible-lines(1, $excerpt-line-height, $excerpt-font-size); + } + + &.ds-card-compact-image .img { + padding-top: 47%; + } + + &.ds-card-image-gradient { + img { + mask-image: linear-gradient(to top, $ds-card-image-gradient-fade, $ds-card-image-gradient-solid 40px); + } + + .meta { + padding: 3px 15px 11px; + } + } + + header { + line-height: $header-line-height * 1px; + font-size: $header-font-size * 1px; + color: var(--newtab-text-primary-color); + } + + p { + font-size: $excerpt-font-size * 1px; + line-height: $excerpt-line-height * 1px; + color: var(--newtab-text-primary-color); + margin: 0; + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter.jsx new file mode 100644 index 0000000000..0d7d4f666e --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter.jsx @@ -0,0 +1,118 @@ +/* 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 { cardContextTypes } from "../../Card/types.js"; +import { CSSTransition, TransitionGroup } from "react-transition-group"; +import { FluentOrText } from "../../FluentOrText/FluentOrText.jsx"; +import React from "react"; + +// Animation time is mirrored in DSContextFooter.scss +const ANIMATION_DURATION = 3000; + +export const DSMessageLabel = props => { + const { context, context_type } = props; + const { icon, fluentID } = cardContextTypes[context_type] || {}; + + if (!context && context_type) { + return ( + <TransitionGroup component={null}> + <CSSTransition + key={fluentID} + timeout={ANIMATION_DURATION} + classNames="story-animate" + > + <StatusMessage icon={icon} fluentID={fluentID} /> + </CSSTransition> + </TransitionGroup> + ); + } + + return null; +}; + +export const StatusMessage = ({ icon, fluentID }) => ( + <div className="status-message"> + <span + aria-haspopup="true" + className={`story-badge-icon icon icon-${icon}`} + /> + <div className="story-context-label" data-l10n-id={fluentID} /> + </div> +); + +export const SponsorLabel = ({ + sponsored_by_override, + sponsor, + context, + newSponsoredLabel, +}) => { + const classList = `story-sponsored-label ${newSponsoredLabel || ""} clamp`; + // If override is not false or an empty string. + if (sponsored_by_override) { + return <p className={classList}>{sponsored_by_override}</p>; + } else if (sponsored_by_override === "") { + // We specifically want to display nothing if the server returns an empty string. + // So the server can turn off the label. + // This is to support the use cases where the sponsored context is displayed elsewhere. + return null; + } else if (sponsor) { + return ( + <p className={classList}> + <FluentOrText + message={{ + id: `newtab-label-sponsored-by`, + values: { sponsor }, + }} + /> + </p> + ); + } else if (context) { + return <p className={classList}>{context}</p>; + } + return null; +}; + +export class DSContextFooter extends React.PureComponent { + render() { + const { context, context_type, sponsor, sponsored_by_override } = + this.props; + + const sponsorLabel = SponsorLabel({ + sponsored_by_override, + sponsor, + context, + }); + const dsMessageLabel = DSMessageLabel({ + context, + context_type, + }); + + if (sponsorLabel || dsMessageLabel) { + return ( + <div className="story-footer"> + {sponsorLabel} + {dsMessageLabel} + </div> + ); + } + + return null; + } +} + +export const DSMessageFooter = props => { + const { context, context_type, saveToPocketCard } = props; + + const dsMessageLabel = DSMessageLabel({ + context, + context_type, + }); + + // This case is specific and already displayed to the user elsewhere. + if (!dsMessageLabel || (saveToPocketCard && context_type === "pocket")) { + return null; + } + + return <div className="story-footer">{dsMessageLabel}</div>; +}; diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/_DSContextFooter.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/_DSContextFooter.scss new file mode 100644 index 0000000000..c23bb1c661 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/_DSContextFooter.scss @@ -0,0 +1,81 @@ +.story-footer { + color: var(--newtab-text-secondary-color); + inset-inline-start: 0; + margin-top: 12px; + position: relative; + + .story-sponsored-label span { + display: inline-block; + } + + .story-sponsored-label, + .status-message { + -webkit-line-clamp: 1; + font-size: 13px; + line-height: 24px; + color: var(--newtab-text-secondary-color); + } + + .status-message { + display: flex; + align-items: center; + height: 24px; + + .story-badge-icon { + fill: var(--newtab-text-secondary-color); + height: 16px; + margin-inline-end: 6px; + + &.icon-bookmark-removed { + background-image: url('#{$image-path}icon-removed-bookmark.svg'); + } + } + + .story-context-label { + color: var(--newtab-text-secondary-color); + flex-grow: 1; + font-size: 13px; + line-height: 24px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } +} + +.story-animate-enter { + opacity: 0; +} + +.story-animate-enter-active { + opacity: 1; + transition: opacity 150ms ease-in 300ms; + + .story-badge-icon, + .story-context-label { + animation: color 3s ease-out 0.3s; + + @keyframes color { + 0% { + color: var(--newtab-status-success); + fill: var(--newtab-status-success); + } + + 100% { + color: var(--newtab-text-secondary-color); + fill: var(--newtab-text-secondary-color); + } + } + } +} + +.story-animate-exit { + position: absolute; + top: 0; + opacity: 1; +} + +.story-animate-exit-active { + opacity: 0; + transition: opacity 250ms ease-in; +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss.jsx new file mode 100644 index 0000000000..9090ebe582 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss.jsx @@ -0,0 +1,57 @@ +/* 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 class DSDismiss extends React.PureComponent { + constructor(props) { + super(props); + this.onDismissClick = this.onDismissClick.bind(this); + this.onHover = this.onHover.bind(this); + this.offHover = this.offHover.bind(this); + this.state = { + hovering: false, + }; + } + + onDismissClick() { + if (this.props.onDismissClick) { + this.props.onDismissClick(); + } + } + + onHover() { + this.setState({ + hovering: true, + }); + } + + offHover() { + this.setState({ + hovering: false, + }); + } + + render() { + let className = `ds-dismiss + ${this.state.hovering ? ` hovering` : ``} + ${this.props.extraClasses ? ` ${this.props.extraClasses}` : ``}`; + + return ( + <div className={className}> + {this.props.children} + <button + className="ds-dismiss-button" + data-l10n-id="newtab-dismiss-button-tooltip" + onHover={this.onHover} + onClick={this.onDismissClick} + onMouseEnter={this.onHover} + onMouseLeave={this.offHover} + > + <span className="icon icon-dismiss" /> + </button> + </div> + ); + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/_DSDismiss.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/_DSDismiss.scss new file mode 100644 index 0000000000..3c736a24ad --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/_DSDismiss.scss @@ -0,0 +1,47 @@ +.ds-dismiss { + position: relative; + border-radius: 8px; + transition-duration: 250ms; + transition-property: background; + + &:hover { + .ds-dismiss-button { + opacity: 1; + } + } + + .ds-dismiss-button { + border: 0; + cursor: pointer; + height: 32px; + width: 32px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + position: absolute; + inset-inline-end: 0; + top: 0; + border-radius: 50%; + background-color: transparent; + + .icon { + @media (forced-colors: active) { + fill: CurrentColor; + } + fill: var(--newtab-text-primary-color); + } + + &:hover { + background: var(--newtab-element-hover-color); + } + + &:active { + background: var(--newtab-element-active-color); + } + + &:focus { + box-shadow: $shadow-secondary; + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx new file mode 100644 index 0000000000..ff3886b407 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx @@ -0,0 +1,100 @@ +/* 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 { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import React from "react"; + +export class DSEmptyState extends React.PureComponent { + constructor(props) { + super(props); + this.onReset = this.onReset.bind(this); + this.state = {}; + } + + componentWillUnmount() { + if (this.timeout) { + clearTimeout(this.timeout); + } + } + + onReset() { + if (this.props.dispatch && this.props.feed) { + const { feed } = this.props; + const { url } = feed; + this.props.dispatch({ + type: at.DISCOVERY_STREAM_FEED_UPDATE, + data: { + feed: { + ...feed, + data: { + ...feed.data, + status: "waiting", + }, + }, + url, + }, + }); + + this.setState({ waiting: true }); + this.timeout = setTimeout(() => { + this.timeout = null; + this.setState({ + waiting: false, + }); + }, 300); + + this.props.dispatch( + ac.OnlyToMain({ type: at.DISCOVERY_STREAM_RETRY_FEED, data: { feed } }) + ); + } + } + + renderButton() { + if (this.props.status === "waiting" || this.state.waiting) { + return ( + <button + className="try-again-button waiting" + data-l10n-id="newtab-discovery-empty-section-topstories-loading" + /> + ); + } + + return ( + <button + className="try-again-button" + onClick={this.onReset} + data-l10n-id="newtab-discovery-empty-section-topstories-try-again-button" + /> + ); + } + + renderState() { + if (this.props.status === "waiting" || this.props.status === "failed") { + return ( + <React.Fragment> + <h2 data-l10n-id="newtab-discovery-empty-section-topstories-timed-out" /> + {this.renderButton()} + </React.Fragment> + ); + } + + return ( + <React.Fragment> + <h2 data-l10n-id="newtab-discovery-empty-section-topstories-header" /> + <p data-l10n-id="newtab-discovery-empty-section-topstories-content" /> + </React.Fragment> + ); + } + + render() { + return ( + <div className="section-empty-state"> + <div className="empty-state-message">{this.renderState()}</div> + </div> + ); + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/_DSEmptyState.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/_DSEmptyState.scss new file mode 100644 index 0000000000..9f9accf71b --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/_DSEmptyState.scss @@ -0,0 +1,83 @@ +.section-empty-state { + border: $border-secondary; + border-radius: 4px; + display: flex; + height: $card-height-compact; + width: 100%; + + .empty-state-message { + color: var(--newtab-text-secondary-color); + font-size: 14px; + line-height: 20px; + text-align: center; + margin: auto; + max-width: 936px; + } + + .try-again-button { + margin-top: 12px; + padding: 6px 32px; + border-radius: 2px; + border: 0; + background: var(--newtab-button-secondary-color); + color: var(--newtab-text-primary-color); + cursor: pointer; + position: relative; + transition: background 0.2s ease, color 0.2s ease; + + &:not(.waiting) { + &:focus { + @include ds-fade-in; + + @include dark-theme-only { + @include ds-fade-in($blue-40-40); + } + } + + &:hover { + @include ds-fade-in(var(--newtab-element-secondary-color)); + } + } + + &::after { + content: ''; + height: 20px; + width: 20px; + animation: spinner 1s linear infinite; + opacity: 0; + position: absolute; + top: 50%; + left: 50%; + margin: -10px 0 0 -10px; + mask-image: url('chrome://activity-stream/content/data/content/assets/spinner.svg'); + mask-size: 20px; + background: var(--newtab-text-secondary-color); + } + + &.waiting { + cursor: initial; + background: var(--newtab-element-secondary-color); + color: transparent; + transition: background 0.2s ease; + + &::after { + transition: opacity 0.2s ease; + opacity: 1; + } + } + } + + h2 { + font-size: 15px; + font-weight: 600; + margin: 0; + } + + p { + margin: 0; + } +} + +@keyframes spinner { + to { transform: rotate(360deg); } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/DSImage.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/DSImage.jsx new file mode 100644 index 0000000000..8a6cefed3a --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/DSImage.jsx @@ -0,0 +1,263 @@ +/* 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"; + +const PLACEHOLDER_IMAGE_DATA_ARRAY = [ + { + rotation: "0deg", + offsetx: "20px", + offsety: "8px", + scale: "45%", + }, + { + rotation: "54deg", + offsetx: "-26px", + offsety: "62px", + scale: "55%", + }, + { + rotation: "-30deg", + offsetx: "78px", + offsety: "30px", + scale: "68%", + }, + { + rotation: "-22deg", + offsetx: "0", + offsety: "92px", + scale: "60%", + }, + { + rotation: "-65deg", + offsetx: "66px", + offsety: "28px", + scale: "60%", + }, + { + rotation: "22deg", + offsetx: "-35px", + offsety: "62px", + scale: "52%", + }, + { + rotation: "-25deg", + offsetx: "86px", + offsety: "-15px", + scale: "68%", + }, +]; + +const PLACEHOLDER_IMAGE_COLORS_ARRAY = + "#0090ED #FF4F5F #2AC3A2 #FF7139 #A172FF #FFA437 #FF2A8A".split(" "); + +function generateIndex({ keyCode, max }) { + if (!keyCode) { + // Just grab a random index if we cannot generate an index from a key. + return Math.floor(Math.random() * max); + } + + const hashStr = str => { + let hash = 0; + for (let i = 0; i < str.length; i++) { + let charCode = str.charCodeAt(i); + hash += charCode; + } + return hash; + }; + + const hash = hashStr(keyCode); + return hash % max; +} + +export function PlaceholderImage({ urlKey, titleKey }) { + const dataIndex = generateIndex({ + keyCode: urlKey, + max: PLACEHOLDER_IMAGE_DATA_ARRAY.length, + }); + const colorIndex = generateIndex({ + keyCode: titleKey, + max: PLACEHOLDER_IMAGE_COLORS_ARRAY.length, + }); + const { rotation, offsetx, offsety, scale } = + PLACEHOLDER_IMAGE_DATA_ARRAY[dataIndex]; + const color = PLACEHOLDER_IMAGE_COLORS_ARRAY[colorIndex]; + const style = { + "--placeholderBackgroundColor": color, + "--placeholderBackgroundRotation": rotation, + "--placeholderBackgroundOffsetx": offsetx, + "--placeholderBackgroundOffsety": offsety, + "--placeholderBackgroundScale": scale, + }; + + return <div style={style} className="placeholder-image" />; +} + +export class DSImage extends React.PureComponent { + constructor(props) { + super(props); + + this.onOptimizedImageError = this.onOptimizedImageError.bind(this); + this.onNonOptimizedImageError = this.onNonOptimizedImageError.bind(this); + this.onLoad = this.onLoad.bind(this); + + this.state = { + isLoaded: false, + optimizedImageFailed: false, + useTransition: false, + }; + } + + onIdleCallback() { + if (!this.state.isLoaded) { + this.setState({ + useTransition: true, + }); + } + } + + reformatImageURL(url, width, height) { + // Change the image URL to request a size tailored for the parent container width + // Also: force JPEG, quality 60, no upscaling, no EXIF data + // Uses Thumbor: https://thumbor.readthedocs.io/en/latest/usage.html + return `https://img-getpocket.cdn.mozilla.net/${width}x${height}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/${encodeURIComponent( + url + )}`; + } + + componentDidMount() { + this.idleCallbackId = this.props.windowObj.requestIdleCallback( + this.onIdleCallback.bind(this) + ); + } + + componentWillUnmount() { + if (this.idleCallbackId) { + this.props.windowObj.cancelIdleCallback(this.idleCallbackId); + } + } + + render() { + let classNames = `ds-image + ${this.props.extraClassNames ? ` ${this.props.extraClassNames}` : ``} + ${this.state && this.state.useTransition ? ` use-transition` : ``} + ${this.state && this.state.isLoaded ? ` loaded` : ``} + `; + + let img; + + if (this.state) { + if ( + this.props.optimize && + this.props.rawSource && + !this.state.optimizedImageFailed + ) { + let baseSource = this.props.rawSource; + + let sizeRules = []; + let srcSetRules = []; + + for (let rule of this.props.sizes) { + let { mediaMatcher, width, height } = rule; + let sizeRule = `${mediaMatcher} ${width}px`; + sizeRules.push(sizeRule); + let srcSetRule = `${this.reformatImageURL( + baseSource, + width, + height + )} ${width}w`; + let srcSetRule2x = `${this.reformatImageURL( + baseSource, + width * 2, + height * 2 + )} ${width * 2}w`; + srcSetRules.push(srcSetRule); + srcSetRules.push(srcSetRule2x); + } + + if (this.props.sizes.length) { + // We have to supply a fallback in the very unlikely event that none of + // the media queries match. The smallest dimension was chosen arbitrarily. + sizeRules.push( + `${this.props.sizes[this.props.sizes.length - 1].width}px` + ); + } + + img = ( + <img + loading="lazy" + alt={this.props.alt_text} + crossOrigin="anonymous" + onLoad={this.onLoad} + onError={this.onOptimizedImageError} + sizes={sizeRules.join(",")} + src={baseSource} + srcSet={srcSetRules.join(",")} + /> + ); + } else if (this.props.source && !this.state.nonOptimizedImageFailed) { + img = ( + <img + loading="lazy" + alt={this.props.alt_text} + crossOrigin="anonymous" + onLoad={this.onLoad} + onError={this.onNonOptimizedImageError} + src={this.props.source} + /> + ); + } else { + // We consider a failed to load img or source without an image as loaded. + classNames = `${classNames} loaded`; + // Remove the img element if we have no source. Render a placeholder instead. + // This only happens for recent saves without a source. + if ( + this.props.isRecentSave && + !this.props.rawSource && + !this.props.source + ) { + img = ( + <PlaceholderImage + urlKey={this.props.url} + titleKey={this.props.title} + /> + ); + } else { + img = <div className="broken-image" />; + } + } + } + + return <picture className={classNames}>{img}</picture>; + } + + onOptimizedImageError() { + // This will trigger a re-render and the unoptimized 450px image will be used as a fallback + this.setState({ + optimizedImageFailed: true, + }); + } + + onNonOptimizedImageError() { + this.setState({ + nonOptimizedImageFailed: true, + }); + } + + onLoad() { + this.setState({ + isLoaded: true, + }); + } +} + +DSImage.defaultProps = { + source: null, // The current source style from Pocket API (always 450px) + rawSource: null, // Unadulterated image URL to filter through Thumbor + extraClassNames: null, // Additional classnames to append to component + optimize: true, // Measure parent container to request exact sizes + alt_text: null, + windowObj: window, // Added to support unit tests + sizes: [], +}; diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/_DSImage.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/_DSImage.scss new file mode 100644 index 0000000000..b11bcdcf55 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/_DSImage.scss @@ -0,0 +1,48 @@ +.ds-image { + display: block; + position: relative; + opacity: 0; + + &.use-transition { + transition: opacity 0.8s; + } + + &.loaded { + opacity: 1; + } + + img, + .placeholder-image, + .broken-image { + background-color: var(--newtab-element-secondary-color); + position: absolute; + top: 0; + width: 100%; + height: 100%; + object-fit: cover; + } + + .placeholder-image { + overflow: hidden; + background-color: var(--placeholderBackgroundColor); + + &::before { + content: ''; + background-image: url('chrome://activity-stream/content/data/content/assets/pocket-swoosh.svg'); + background-repeat: no-repeat; + background-position: center; + transform: rotate(var(--placeholderBackgroundRotation)); + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + // We use margin-left over margin-inline-start on purpose. + // This is because we are using it to offset an image's content, + // and the image content is the same in ltr and rtl. + margin-left: var(--placeholderBackgroundOffsetx); // stylelint-disable-line property-disallowed-list + margin-top: var(--placeholderBackgroundOffsety); + background-size: var(--placeholderBackgroundScale); + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx new file mode 100644 index 0000000000..b75063940c --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx @@ -0,0 +1,70 @@ +/* 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 { LinkMenu } from "content-src/components/LinkMenu/LinkMenu"; +import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton"; +import { actionCreators as ac } from "common/Actions.sys.mjs"; +import React from "react"; + +export class DSLinkMenu extends React.PureComponent { + render() { + const { index, dispatch } = this.props; + let pocketMenuOptions = []; + let TOP_STORIES_CONTEXT_MENU_OPTIONS = [ + "OpenInNewWindow", + "OpenInPrivateWindow", + ]; + if (!this.props.isRecentSave) { + if (this.props.pocket_button_enabled) { + pocketMenuOptions = this.props.saveToPocketCard + ? ["CheckDeleteFromPocket"] + : ["CheckSavedToPocket"]; + } + TOP_STORIES_CONTEXT_MENU_OPTIONS = [ + "CheckBookmark", + "CheckArchiveFromPocket", + ...pocketMenuOptions, + "Separator", + "OpenInNewWindow", + "OpenInPrivateWindow", + "Separator", + "BlockUrl", + ...(this.props.showPrivacyInfo ? ["ShowPrivacyInfo"] : []), + ]; + } + const type = this.props.type || "DISCOVERY_STREAM"; + const title = this.props.title || this.props.source; + + return ( + <div className="context-menu-position-container"> + <ContextMenuButton + tooltip={"newtab-menu-content-tooltip"} + tooltipArgs={{ title }} + onUpdate={this.props.onMenuUpdate} + > + <LinkMenu + dispatch={dispatch} + index={index} + source={type.toUpperCase()} + onShow={this.props.onMenuShow} + options={TOP_STORIES_CONTEXT_MENU_OPTIONS} + shouldSendImpressionStats={true} + userEvent={ac.DiscoveryStreamUserEvent} + site={{ + referrer: "https://getpocket.com/recommendations", + title: this.props.title, + type: this.props.type, + url: this.props.url, + guid: this.props.id, + pocket_id: this.props.pocket_id, + shim: this.props.shim, + bookmarkGuid: this.props.bookmarkGuid, + flight_id: this.props.flightId, + }} + /> + </ContextMenuButton> + </div> + ); + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/_DSLinkMenu.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/_DSLinkMenu.scss new file mode 100644 index 0000000000..e85eab11e7 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/_DSLinkMenu.scss @@ -0,0 +1,28 @@ +.ds-card, +.ds-signup { + @include context-menu-button; + + .context-menu { + opacity: 0; + } + + &.active { + .context-menu { + opacity: 1; + } + } + + &.last-item { + @include context-menu-open-left; + + .context-menu { + opacity: 1; + } + } + + &:is(:hover, :focus, .active) { + @include context-menu-button-hover; + + outline: none; + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage.jsx new file mode 100644 index 0000000000..df9ad4f641 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage.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"; +import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; +import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText"; + +export class DSMessage extends React.PureComponent { + render() { + return ( + <div className="ds-message"> + <header className="title"> + {this.props.icon && ( + <div + className="glyph" + style={{ backgroundImage: `url(${this.props.icon})` }} + /> + )} + {this.props.title && ( + <span className="title-text"> + <FluentOrText message={this.props.title} /> + </span> + )} + {this.props.link_text && this.props.link_url && ( + <SafeAnchor className="link" url={this.props.link_url}> + <FluentOrText message={this.props.link_text} /> + </SafeAnchor> + )} + </header> + </div> + ); + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/_DSMessage.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/_DSMessage.scss new file mode 100644 index 0000000000..bb9666ae38 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/_DSMessage.scss @@ -0,0 +1,37 @@ +.ds-message { + margin: 8px 0 0; + + .title { + display: flex; + align-items: center; + + .glyph { + width: 16px; + height: 16px; + margin: 0 6px 0 0; + -moz-context-properties: fill; + fill: var(--newtab-text-secondary-color); + background-position: center center; + background-size: 16px; + background-repeat: no-repeat; + } + + .title-text { + line-height: 20px; + font-size: 13px; + color: var(--newtab-text-secondary-color); + font-weight: 600; + padding-right: 12px; + } + + .link { + line-height: 20px; + font-size: 13px; + + &:hover, + &:focus { + text-decoration: underline; + } + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx new file mode 100644 index 0000000000..06ec1f7e78 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx @@ -0,0 +1,72 @@ +/* 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 { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { ModalOverlayWrapper } from "content-src/asrouter/components/ModalOverlay/ModalOverlay"; + +export class DSPrivacyModal extends React.PureComponent { + constructor(props) { + super(props); + this.closeModal = this.closeModal.bind(this); + this.onLearnLinkClick = this.onLearnLinkClick.bind(this); + this.onManageLinkClick = this.onManageLinkClick.bind(this); + } + + onLearnLinkClick(event) { + this.props.dispatch( + ac.DiscoveryStreamUserEvent({ + event: "CLICK_PRIVACY_INFO", + source: "DS_PRIVACY_MODAL", + }) + ); + } + + onManageLinkClick(event) { + this.props.dispatch(ac.OnlyToMain({ type: at.SETTINGS_OPEN })); + } + + closeModal() { + this.props.dispatch({ + type: `HIDE_PRIVACY_INFO`, + data: {}, + }); + } + + render() { + return ( + <ModalOverlayWrapper + onClose={this.closeModal} + innerClassName="ds-privacy-modal" + > + <div className="privacy-notice"> + <h3 data-l10n-id="newtab-privacy-modal-header" /> + <p data-l10n-id="newtab-privacy-modal-paragraph-2" /> + <a + className="modal-link modal-link-privacy" + data-l10n-id="newtab-privacy-modal-link" + onClick={this.onLearnLinkClick} + href="https://help.getpocket.com/article/1142-firefox-new-tab-recommendations-faq" + /> + <button + className="modal-link modal-link-manage" + data-l10n-id="newtab-privacy-modal-button-manage" + onClick={this.onManageLinkClick} + /> + </div> + <section className="actions"> + <button + className="done" + type="submit" + onClick={this.closeModal} + data-l10n-id="newtab-privacy-modal-button-done" + /> + </section> + </ModalOverlayWrapper> + ); + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/_DSPrivacyModal.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/_DSPrivacyModal.scss new file mode 100644 index 0000000000..2077f35709 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/_DSPrivacyModal.scss @@ -0,0 +1,48 @@ +.ds-privacy-modal { + .modal-link { + display: flex; + align-items: center; + margin: 0 0 8px; + border: 0; + padding: 0; + color: var(--newtab-primary-action-background); + width: max-content; + + &:hover { + text-decoration: underline; + cursor: pointer; + } + + &::before { + -moz-context-properties: fill; + fill: var(--newtab-primary-action-background); + content: ''; + display: inline-block; + width: 16px; + height: 16px; + margin: 0; + margin-inline-end: 8px; + background-position: center center; + background-repeat: no-repeat; + background-size: 16px; + } + + &.modal-link-privacy::before { + background-image: url('chrome://global/skin/icons/info.svg'); + } + + &.modal-link-manage::before { + background-image: url('chrome://global/skin/icons/settings.svg'); + } + } + + p { + line-height: 24px; + } + + .privacy-notice { + max-width: 572px; + padding: 40px; + margin: auto; + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.jsx new file mode 100644 index 0000000000..b7e3205646 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.jsx @@ -0,0 +1,168 @@ +/* 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 { actionCreators as ac } from "common/Actions.sys.mjs"; +import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu"; +import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton"; +import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats"; +import React from "react"; +import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; + +export class DSSignup extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + active: false, + lastItem: false, + }; + this.onMenuButtonUpdate = this.onMenuButtonUpdate.bind(this); + this.onLinkClick = this.onLinkClick.bind(this); + this.onMenuShow = this.onMenuShow.bind(this); + } + + onMenuButtonUpdate(showContextMenu) { + if (!showContextMenu) { + this.setState({ + active: false, + lastItem: false, + }); + } + } + + nextAnimationFrame() { + return new Promise(resolve => + this.props.windowObj.requestAnimationFrame(resolve) + ); + } + + async onMenuShow() { + let { lastItem } = this.state; + // Wait for next frame before computing scrollMaxX to allow fluent menu strings to be visible + await this.nextAnimationFrame(); + if (this.props.windowObj.scrollMaxX > 0) { + lastItem = true; + } + this.setState({ + active: true, + lastItem, + }); + } + + onLinkClick() { + const { data } = this.props; + if (this.props.dispatch && data && data.spocs && data.spocs.length) { + const source = this.props.type.toUpperCase(); + // Grab the first item in the array as we only have 1 spoc position. + const [spoc] = data.spocs; + this.props.dispatch( + ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source, + action_position: 0, + }) + ); + + this.props.dispatch( + ac.ImpressionStats({ + source, + click: 0, + tiles: [ + { + id: spoc.id, + pos: 0, + ...(spoc.shim && spoc.shim.click + ? { shim: spoc.shim.click } + : {}), + }, + ], + }) + ); + } + } + + render() { + const { data, dispatch, type } = this.props; + if (!data || !data.spocs || !data.spocs[0]) { + return null; + } + // Grab the first item in the array as we only have 1 spoc position. + const [spoc] = data.spocs; + const { title, url, excerpt, flight_id, id, shim } = spoc; + + const SIGNUP_CONTEXT_MENU_OPTIONS = [ + "OpenInNewWindow", + "OpenInPrivateWindow", + "Separator", + "BlockUrl", + ...(flight_id ? ["ShowPrivacyInfo"] : []), + ]; + + const outerClassName = [ + "ds-signup", + this.state.active && "active", + this.state.lastItem && "last-item", + ] + .filter(v => v) + .join(" "); + + return ( + <div className={outerClassName}> + <div className="ds-signup-content"> + <span className="icon icon-small-spacer icon-mail"></span> + <span> + {title}{" "} + <SafeAnchor + className="ds-chevron-link" + dispatch={dispatch} + onLinkClick={this.onLinkClick} + url={url} + > + {excerpt} + </SafeAnchor> + </span> + <ImpressionStats + flightId={flight_id} + rows={[ + { + id, + pos: 0, + shim: shim && shim.impression, + }, + ]} + dispatch={dispatch} + source={type} + /> + </div> + <ContextMenuButton + tooltip={"newtab-menu-content-tooltip"} + tooltipArgs={{ title }} + onUpdate={this.onMenuButtonUpdate} + > + <LinkMenu + dispatch={dispatch} + index={0} + source={type.toUpperCase()} + onShow={this.onMenuShow} + options={SIGNUP_CONTEXT_MENU_OPTIONS} + shouldSendImpressionStats={true} + userEvent={ac.DiscoveryStreamUserEvent} + site={{ + referrer: "https://getpocket.com/recommendations", + title, + type, + url, + guid: id, + shim, + flight_id, + }} + /> + </ContextMenuButton> + </div> + ); + } +} + +DSSignup.defaultProps = { + windowObj: window, // Added to support unit tests +}; diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.scss new file mode 100644 index 0000000000..dcaf0e804a --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.scss @@ -0,0 +1,52 @@ +.ds-signup { + max-width: 300px; + margin: 0 auto; + padding: 8px; + position: relative; + text-align: center; + font-size: 17px; + font-weight: 600; + + &:hover { + background: var(--newtab-element-hover-color); + border-radius: 4px; + } + + .icon-mail { + height: 40px; + width: 40px; + margin-inline-end: 8px; + fill: var(--newtab-text-secondary-color); + background-size: 30px; + flex-shrink: 0; + } + + .ds-signup-content { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + + .ds-chevron-link { + margin-top: 4px; + box-shadow: none; + display: block; + white-space: nowrap; + } + } + + @media (min-width: $break-point-large) { + min-width: 756px; + width: max-content; + text-align: start; + + .ds-signup-content { + flex-direction: row; + + .ds-chevron-link { + margin-top: 0; + display: inline; + } + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo.jsx new file mode 100644 index 0000000000..02a3326eb7 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo.jsx @@ -0,0 +1,143 @@ +/* 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 { actionCreators as ac } from "common/Actions.sys.mjs"; +import { DSDismiss } from "content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss"; +import { DSImage } from "../DSImage/DSImage.jsx"; +import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats"; +import { LinkMenuOptions } from "content-src/lib/link-menu-options"; +import React from "react"; +import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; + +export class DSTextPromo extends React.PureComponent { + constructor(props) { + super(props); + this.onLinkClick = this.onLinkClick.bind(this); + this.onDismissClick = this.onDismissClick.bind(this); + } + + onLinkClick() { + const { data } = this.props; + if (this.props.dispatch && data && data.spocs && data.spocs.length) { + const source = this.props.type.toUpperCase(); + // Grab the first item in the array as we only have 1 spoc position. + const [spoc] = data.spocs; + this.props.dispatch( + ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source, + action_position: 0, + }) + ); + + this.props.dispatch( + ac.ImpressionStats({ + source, + click: 0, + tiles: [ + { + id: spoc.id, + pos: 0, + ...(spoc.shim && spoc.shim.click + ? { shim: spoc.shim.click } + : {}), + }, + ], + }) + ); + } + } + + onDismissClick() { + const { data } = this.props; + if (this.props.dispatch && data && data.spocs && data.spocs.length) { + const index = 0; + const source = this.props.type.toUpperCase(); + // Grab the first item in the array as we only have 1 spoc position. + const [spoc] = data.spocs; + const spocData = { + url: spoc.url, + guid: spoc.id, + shim: spoc.shim, + }; + const blockUrlOption = LinkMenuOptions.BlockUrl(spocData, index, source); + + const { action, impression, userEvent } = blockUrlOption; + + this.props.dispatch(action); + this.props.dispatch( + ac.DiscoveryStreamUserEvent({ + event: userEvent, + source, + action_position: index, + }) + ); + if (impression) { + this.props.dispatch(impression); + } + } + } + + render() { + const { data } = this.props; + if (!data || !data.spocs || !data.spocs[0]) { + return null; + } + // Grab the first item in the array as we only have 1 spoc position. + const [spoc] = data.spocs; + const { + image_src, + raw_image_src, + alt_text, + title, + url, + context, + cta, + flight_id, + id, + shim, + } = spoc; + + return ( + <DSDismiss + onDismissClick={this.onDismissClick} + extraClasses={`ds-dismiss-ds-text-promo`} + > + <div className="ds-text-promo"> + <DSImage + alt_text={alt_text} + source={image_src} + rawSource={raw_image_src} + /> + <div className="text"> + <h3> + {`${title}\u2003`} + <SafeAnchor + className="ds-chevron-link" + dispatch={this.props.dispatch} + onLinkClick={this.onLinkClick} + url={url} + > + {cta} + </SafeAnchor> + </h3> + <p className="subtitle">{context}</p> + </div> + <ImpressionStats + flightId={flight_id} + rows={[ + { + id, + pos: 0, + shim: shim && shim.impression, + }, + ]} + dispatch={this.props.dispatch} + source={this.props.type} + /> + </div> + </DSDismiss> + ); + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/_DSTextPromo.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/_DSTextPromo.scss new file mode 100644 index 0000000000..b0abea1213 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/_DSTextPromo.scss @@ -0,0 +1,92 @@ +.ds-dismiss-ds-text-promo { + max-width: 744px; + margin: auto; + overflow: hidden; + + &.hovering { + background: var(--newtab-element-hover-color); + } + + .ds-dismiss-button { + margin-inline: 0 18px; + margin-block: 18px 0; + } +} + +.ds-text-promo { + max-width: 640px; + margin: 0; + padding: 18px; + + @media(min-width: $break-point-medium) { + display: flex; + margin: 18px 24px; + padding: 0 32px 0 0; + } + + .ds-image { + width: 40px; + height: 40px; + flex-shrink: 0; + margin: 0 0 18px; + + @media(min-width: $break-point-medium) { + margin: 4px 12px 0 0; + } + + img { + border-radius: 4px; + } + } + + .text { + line-height: 24px; + } + + h3 { + color: var(--newtab-text-primary-color); + margin: 0; + font-weight: 600; + font-size: 15px; + } + + .subtitle { + font-size: 13px; + margin: 0; + color: var(--newtab-text-primary-color); + } +} + +.ds-chevron-link { + color: var(--newtab-primary-action-background); + display: inline-block; + outline: 0; + + &:hover { + text-decoration: underline; + } + + &:active { + color: var(--newtab-primary-element-active-color); + + &::after { + background-color: var(--newtab-primary-element-active-color); + } + } + + &:focus { + box-shadow: $shadow-secondary; + border-radius: 2px; + } + + &::after { + background-color: var(--newtab-primary-action-background); + content: ' '; + mask: url('chrome://global/skin/icons/arrow-right-12.svg') 0 -8px no-repeat; + margin: 0 0 0 4px; + width: 5px; + height: 8px; + text-decoration: none; + display: inline-block; + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/Highlights.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/Highlights.jsx new file mode 100644 index 0000000000..d0cc87cce3 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/Highlights.jsx @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { connect } from "react-redux"; +import React from "react"; +import { SectionIntl } from "content-src/components/Sections/Sections"; + +export class _Highlights extends React.PureComponent { + render() { + const section = this.props.Sections.find(s => s.id === "highlights"); + if (!section || !section.enabled) { + return null; + } + + return ( + <div className="ds-highlights sections-list"> + <SectionIntl {...section} isFixed={true} /> + </div> + ); + } +} + +export const Highlights = connect(state => ({ Sections: state.Sections }))( + _Highlights +); diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/_Highlights.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/_Highlights.scss new file mode 100644 index 0000000000..3c5b60e946 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/_Highlights.scss @@ -0,0 +1,45 @@ +.ds-highlights { + .section { + .section-list { + grid-gap: var(--gridRowGap); + grid-template-columns: repeat(1, 1fr); + + @media (min-width: $break-point-medium) { + grid-template-columns: repeat(2, 1fr); + } + + @media (min-width: $break-point-large) { + grid-template-columns: repeat(4, 1fr); + } + + .card-outer { + $line-height: 20px; + + height: 175px; + + .card-host-name { + font-size: 13px; + line-height: $line-height; + margin-bottom: 2px; + padding-bottom: 0; + text-transform: unset; + } + + .card-title { + font-size: 14px; + font-weight: 600; + line-height: $line-height; + max-height: $line-height; + } + + a { + text-decoration: none; + } + } + } + } + + .hide-for-narrow { + display: block; + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule.jsx new file mode 100644 index 0000000000..4cdfc7594f --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule.jsx @@ -0,0 +1,11 @@ +/* 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 class HorizontalRule extends React.PureComponent { + render() { + return <hr className="ds-hr" />; + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/_HorizontalRule.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/_HorizontalRule.scss new file mode 100644 index 0000000000..aa5d6ff9f3 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/_HorizontalRule.scss @@ -0,0 +1,7 @@ +.ds-hr { + @include ds-border-top { + border: 0; + }; + + height: 0; +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/Navigation.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/Navigation.jsx new file mode 100644 index 0000000000..1062c3cade --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/Navigation.jsx @@ -0,0 +1,112 @@ +/* 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 { actionCreators as ac } from "common/Actions.sys.mjs"; +import React from "react"; +import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; +import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText"; + +export class Topic extends React.PureComponent { + constructor(props) { + super(props); + + this.onLinkClick = this.onLinkClick.bind(this); + } + + onLinkClick(event) { + if (this.props.dispatch) { + this.props.dispatch( + ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source: "POPULAR_TOPICS", + action_position: 0, + value: { + topic: event.target.text.toLowerCase().replace(` `, `-`), + }, + }) + ); + } + } + + render() { + const { url, name } = this.props; + return ( + <SafeAnchor + onLinkClick={this.onLinkClick} + className={this.props.className} + url={url} + > + {name} + </SafeAnchor> + ); + } +} + +export class Navigation extends React.PureComponent { + render() { + let links = this.props.links || []; + const alignment = this.props.alignment || "centered"; + const header = this.props.header || {}; + const english = this.props.locale.startsWith("en-"); + const privacyNotice = this.props.privacyNoticeURL || {}; + const { newFooterSection } = this.props; + const className = `ds-navigation ds-navigation-${alignment} ${ + newFooterSection ? `ds-navigation-new-topics` : `` + }`; + let { title } = header; + if (newFooterSection) { + title = { id: "newtab-pocket-new-topics-title" }; + if (this.props.extraLinks) { + links = [ + ...links.slice(0, links.length - 1), + ...this.props.extraLinks, + links[links.length - 1], + ]; + } + } + + return ( + <div className={className}> + {title && english ? ( + <FluentOrText message={title}> + <span className="ds-navigation-header" /> + </FluentOrText> + ) : null} + + {english ? ( + <ul> + {links && + links.map(t => ( + <li key={t.name}> + <Topic + url={t.url} + name={t.name} + dispatch={this.props.dispatch} + /> + </li> + ))} + </ul> + ) : null} + + {!newFooterSection ? ( + <SafeAnchor className="ds-navigation-privacy" url={privacyNotice.url}> + <FluentOrText message={privacyNotice.title} /> + </SafeAnchor> + ) : null} + + {newFooterSection ? ( + <div className="ds-navigation-family"> + <span className="icon firefox-logo" /> + <span>|</span> + <span className="icon pocket-logo" /> + <span + className="ds-navigation-family-message" + data-l10n-id="newtab-pocket-pocket-firefox-family" + /> + </div> + ) : null} + </div> + ); + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/_Navigation.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/_Navigation.scss new file mode 100644 index 0000000000..f9b5e5c704 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/_Navigation.scss @@ -0,0 +1,180 @@ +.ds-navigation { + color: var(--newtab-text-primary-color); + font-size: 11.5px; + font-weight: 500; + line-height: 22px; + padding: 4px 0; + + @media (min-width: $break-point-widest) { + line-height: 32px; + font-size: 14px; + } + + &.ds-navigation-centered { + text-align: center; + } + + &.ds-navigation-right-aligned { + text-align: end; + } + + ul { + display: inline; + margin: 0; + padding: 0; + } + + ul li { + display: inline-block; + + &::after { + content: '·'; + padding: 6px; + } + + &:last-child::after { + content: none; + } + + a { + &:hover, + &:active { + text-decoration: none; + } + + &:active { + color: var(--newtab-primary-element-active-color); + } + } + } + + .ds-navigation-header { + padding-inline-end: 6px; + } + + .ds-navigation-privacy { + padding-inline-start: 6px; + float: inline-end; + + &:hover { + text-decoration: none; + } + } + + &.ds-navigation-new-topics { + display: block; + padding-top: 32px; + + .ds-navigation-header { + font-size: 14px; + line-height: 20px; + font-weight: 700; + display: inline-block; + margin-bottom: 8px; + } + + .ds-navigation-family { + text-align: center; + font-size: 14px; + line-height: 20px; + margin: 16px auto 28px; + + span { + margin: 0 6px; + } + + .firefox-logo, + .pocket-logo { + height: 20px; + width: 20px; + background-size: cover; + } + + .firefox-logo { + background-image: url('chrome://activity-stream/content/data/content/assets/firefox.svg'); + } + + .pocket-logo { + background-image: url('chrome://global/skin/icons/pocket.svg'); + fill: $pocket-icon-fill; + } + + .ds-navigation-family-message { + font-weight: 400; + display: block; + + @media (min-width: $break-point-medium) { + display: inline; + } + } + + @media (min-width: $break-point-medium) { + margin-top: 43px; + } + } + + ul { + display: grid; + grid-gap: 0 24px; + grid-auto-flow: column; + grid-template: repeat(8, 1fr) / repeat(1, 1fr); + + li { + border-top: $border-primary; + line-height: 24px; + font-size: 13px; + font-weight: 500; + + &::after { + content: ''; + padding: 0; + } + + &:nth-last-child(2), + &:nth-last-child(3) { + display: none; + } + + &:nth-last-child(1) { + border-bottom: $border-primary; + } + } + + @media (min-width: $break-point-medium) { + grid-template: repeat(3, 1fr) / repeat(2, 1fr); + + li { + &:nth-child(3) { + border-bottom: $border-primary; + } + } + } + + @media (min-width: $break-point-large) { + grid-template: repeat(2, 1fr) / repeat(3, 1fr); + + + li { + &:nth-child(odd) { + border-bottom: 0; + } + + &:nth-child(even) { + border-bottom: $border-primary; + } + } + } + + @media (min-width: $break-point-widest) { + grid-template: repeat(2, 1fr) / repeat(4, 1fr); + + li { + &:nth-last-child(2), + &:nth-last-child(3) { + display: block; + } + } + } + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/PrivacyLink.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/PrivacyLink.jsx new file mode 100644 index 0000000000..8f7d88be85 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/PrivacyLink.jsx @@ -0,0 +1,20 @@ +/* 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 { SafeAnchor } from "../SafeAnchor/SafeAnchor"; +import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText"; + +export class PrivacyLink extends React.PureComponent { + render() { + const { properties } = this.props; + return ( + <div className="ds-privacy-link"> + <SafeAnchor url={properties.url}> + <FluentOrText message={properties.title} /> + </SafeAnchor> + </div> + ); + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/_PrivacyLink.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/_PrivacyLink.scss new file mode 100644 index 0000000000..08ce093c27 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/_PrivacyLink.scss @@ -0,0 +1,10 @@ +.ds-privacy-link { + text-align: center; + font-size: 13px; + font-weight: 500; + line-height: 24px; + + a:hover { + text-decoration: none; + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.jsx new file mode 100644 index 0000000000..cfbc6fe6cb --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.jsx @@ -0,0 +1,65 @@ +/* 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 { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import React from "react"; + +export class SafeAnchor extends React.PureComponent { + constructor(props) { + super(props); + this.onClick = this.onClick.bind(this); + } + + onClick(event) { + // Use dispatch instead of normal link click behavior to include referrer + if (this.props.dispatch) { + event.preventDefault(); + const { altKey, button, ctrlKey, metaKey, shiftKey } = event; + this.props.dispatch( + ac.OnlyToMain({ + type: at.OPEN_LINK, + data: { + event: { altKey, button, ctrlKey, metaKey, shiftKey }, + referrer: "https://getpocket.com/recommendations", + // Use the anchor's url, which could have been cleaned up + url: event.currentTarget.href, + }, + }) + ); + } + + // Propagate event if there's a handler + if (this.props.onLinkClick) { + this.props.onLinkClick(event); + } + } + + safeURI(url) { + let protocol = null; + try { + protocol = new URL(url).protocol; + } catch (e) { + return ""; + } + + const isAllowed = ["http:", "https:"].includes(protocol); + if (!isAllowed) { + console.warn(`${url} is not allowed for anchor targets.`); // eslint-disable-line no-console + return ""; + } + return url; + } + + render() { + const { url, className } = this.props; + return ( + <a href={this.safeURI(url)} className={className} onClick={this.onClick}> + {this.props.children} + </a> + ); + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle.jsx new file mode 100644 index 0000000000..646dc2263e --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle.jsx @@ -0,0 +1,19 @@ +/* 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 class SectionTitle extends React.PureComponent { + render() { + const { + header: { title, subtitle }, + } = this.props; + return ( + <div className="ds-section-title"> + <div className="title">{title}</div> + {subtitle ? <div className="subtitle">{subtitle}</div> : null} + </div> + ); + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/_SectionTitle.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/_SectionTitle.scss new file mode 100644 index 0000000000..453001b1b7 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/_SectionTitle.scss @@ -0,0 +1,18 @@ +.ds-section-title { + text-align: center; + margin-top: 24px; + + .title { + color: var(--newtab-text-primary-color); + line-height: 48px; + font-size: 36px; + font-weight: 300; + } + + .subtitle { + line-height: 24px; + font-size: 14px; + color: var(--newtab-text-secondary-color); + margin-top: 4px; + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/_TopSites.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/_TopSites.scss new file mode 100644 index 0000000000..e0c7c1a8eb --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/_TopSites.scss @@ -0,0 +1,77 @@ +.outer-wrapper { + .ds-top-sites { + .top-sites { + .top-site-outer { + .top-site-inner > a:is(.active, :focus) .tile { + @include ds-focus; + } + + .top-site-inner > a:is(:hover) .top-site-inner { + @include ds-fade-in(var(--newtab-background-color-secondary)); + } + } + + .top-sites-list { + margin: 0 -12px; + } + } + } +} + +// Size overrides for topsites in the 2/3 view. +.ds-column-5, +.ds-column-6, +.ds-column-7, +.ds-column-8 { + .ds-top-sites { + .top-site-outer { + padding: 0 10px; + } + + .top-sites-list { + margin: 0 -10px; + } + + .top-site-inner { + --leftPanelIconWidth: 84.67px; + + .tile { + width: var(--leftPanelIconWidth); + height: var(--leftPanelIconWidth); + } + + .title { + width: var(--leftPanelIconWidth); + } + } + } +} + +// Size overrides for topsites in the 1/3 view. +.ds-column-1, +.ds-column-2, +.ds-column-3, +.ds-column-4 { + .ds-top-sites { + .top-site-outer { + padding: 0 8px; + } + + .top-sites-list { + margin: 0 -8px; + } + + .top-site-inner { + --rightPanelIconWidth: 82.67px; + + .tile { + width: var(--rightPanelIconWidth); + height: var(--rightPanelIconWidth); + } + + .title { + width: var(--rightPanelIconWidth); + } + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/TopicsWidget.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/TopicsWidget.jsx new file mode 100644 index 0000000000..1fe2343b94 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/TopicsWidget.jsx @@ -0,0 +1,125 @@ +/* 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 { actionCreators as ac } from "common/Actions.sys.mjs"; +import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; +import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats"; +import { connect } from "react-redux"; + +export function _TopicsWidget(props) { + const { id, source, position, DiscoveryStream, dispatch } = props; + + const { utmCampaign, utmContent, utmSource } = DiscoveryStream.experimentData; + + let queryParams = `?utm_source=${utmSource}`; + if (utmCampaign && utmContent) { + queryParams += `&utm_content=${utmContent}&utm_campaign=${utmCampaign}`; + } + + const topics = [ + { label: "Technology", name: "technology" }, + { label: "Science", name: "science" }, + { label: "Self-Improvement", name: "self-improvement" }, + { label: "Travel", name: "travel" }, + { label: "Career", name: "career" }, + { label: "Entertainment", name: "entertainment" }, + { label: "Food", name: "food" }, + { label: "Health", name: "health" }, + { + label: "Must-Reads", + name: "must-reads", + url: `https://getpocket.com/collections${queryParams}`, + }, + ]; + + function onLinkClick(topic, positionInCard) { + if (dispatch) { + dispatch( + ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source, + action_position: position, + value: { + card_type: "topics_widget", + topic, + ...(positionInCard || positionInCard === 0 + ? { position_in_card: positionInCard } + : {}), + }, + }) + ); + dispatch( + ac.ImpressionStats({ + source, + click: 0, + window_inner_width: props.windowObj.innerWidth, + window_inner_height: props.windowObj.innerHeight, + tiles: [ + { + id, + pos: position, + }, + ], + }) + ); + } + } + + function mapTopicItem(topic, index) { + return ( + <li + key={topic.name} + className={topic.overflow ? "ds-topics-widget-list-overflow-item" : ""} + > + <SafeAnchor + url={ + topic.url || + `https://getpocket.com/explore/${topic.name}${queryParams}` + } + dispatch={dispatch} + onLinkClick={() => onLinkClick(topic.name, index)} + > + {topic.label} + </SafeAnchor> + </li> + ); + } + + return ( + <div className="ds-topics-widget"> + <header className="ds-topics-widget-header">Popular Topics</header> + <hr /> + <div className="ds-topics-widget-list-container"> + <ul>{topics.map(mapTopicItem)}</ul> + </div> + <SafeAnchor + className="ds-topics-widget-button button primary" + url={`https://getpocket.com/${queryParams}`} + dispatch={dispatch} + onLinkClick={() => onLinkClick("more-topics")} + > + More Topics + </SafeAnchor> + <ImpressionStats + dispatch={dispatch} + rows={[ + { + id, + pos: position, + }, + ]} + source={source} + /> + </div> + ); +} + +_TopicsWidget.defaultProps = { + windowObj: window, // Added to support unit tests +}; + +export const TopicsWidget = connect(state => ({ + DiscoveryStream: state.DiscoveryStream, +}))(_TopicsWidget); diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/_TopicsWidget.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/_TopicsWidget.scss new file mode 100644 index 0000000000..d05d46cd07 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/_TopicsWidget.scss @@ -0,0 +1,88 @@ +.ds-topics-widget { + display: flex; + position: relative; + flex-direction: column; + + .ds-topics-widget-header { + font-size: 18px; + line-height: 20px; + } + + hr { + background-color: color-mix(in srgb, var(--newtab-border-color) 52%, transparent); + height: 1px; + border: 0; + margin: 10px 0 0; + } + + .ds-topics-widget-list-container { + flex-grow: 1; + + ul { + margin: 14px 0 0; + padding: 0; + display: flex; + align-items: center; + grid-gap: 10px; + flex-wrap: wrap; + + li { + display: flex; + + a { + font-size: 14px; + line-height: 16px; + text-decoration: none; + padding: 8px 15px; + background: var(--newtab-background-color-secondary); + border: 1px solid color-mix(in srgb, var(--newtab-border-color) 52%, transparent); + color: var(--newtab-text-primary-color); + border-radius: 8px; + + &:hover { + background: var(--newtab-element-hover-color); + } + + &:focus { + outline: 0; + box-shadow: 0 0 0 3px var(--newtab-primary-action-background-dimmed), 0 0 0 1px var(--newtab-primary-action-background); + transition: box-shadow 150ms; + } + } + } + + .ds-topics-widget-list-overflow-item { + display: flex; + + @media (min-width: $break-point-medium) { + display: none; + } + + @media (min-width: $break-point-widest) { + display: flex; + } + } + } + } + + .ds-topics-widget-button { + margin: 14px 0 0; + font-size: 16px; + line-height: 24px; + text-align: center; + padding: 8px; + border-radius: 4px; + background-color: var(--newtab-primary-action-background-pocket); + border: 0; + + &:hover { + background: var(--newtab-primary-element-hover-pocket-color); + } + + &:focus { + outline: 0; + box-shadow: 0 0 0 3px var(--newtab-primary-action-background-pocket-dimmed), 0 0 0 1px var(--newtab-primary-action-background-pocket); + transition: box-shadow 150ms; + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx new file mode 100644 index 0000000000..b385c2ebf7 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx @@ -0,0 +1,250 @@ +/* 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 { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { TOP_SITES_SOURCE } from "../TopSites/TopSitesConstants"; +import React from "react"; + +const VISIBLE = "visible"; +const VISIBILITY_CHANGE_EVENT = "visibilitychange"; + +// Per analytical requirement, we set the minimal intersection ratio to +// 0.5, and an impression is identified when the wrapped item has at least +// 50% visibility. +// +// This constant is exported for unit test +export const INTERSECTION_RATIO = 0.5; + +/** + * Impression wrapper for Discovery Stream related React components. + * + * It makses use of the Intersection Observer API to detect the visibility, + * and relies on page visibility to ensure the impression is reported + * only when the component is visible on the page. + * + * Note: + * * This wrapper used to be used either at the individual card level, + * or by the card container components. + * It is now only used for individual card level. + * * Each impression will be sent only once as soon as the desired + * visibility is detected + * * Batching is not yet implemented, hence it might send multiple + * impression pings separately + */ +export class ImpressionStats extends React.PureComponent { + // This checks if the given cards are the same as those in the last impression ping. + // If so, it should not send the same impression ping again. + _needsImpressionStats(cards) { + if ( + !this.impressionCardGuids || + this.impressionCardGuids.length !== cards.length + ) { + return true; + } + + for (let i = 0; i < cards.length; i++) { + if (cards[i].id !== this.impressionCardGuids[i]) { + return true; + } + } + + return false; + } + + _dispatchImpressionStats() { + const { props } = this; + const cards = props.rows; + + if (this.props.flightId) { + this.props.dispatch( + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_SPOC_IMPRESSION, + data: { flightId: this.props.flightId }, + }) + ); + + // Record sponsored topsites impressions if the source is `TOP_SITES_SOURCE`. + if (this.props.source === TOP_SITES_SOURCE) { + for (const card of cards) { + this.props.dispatch( + ac.OnlyToMain({ + type: at.TOP_SITES_SPONSORED_IMPRESSION_STATS, + data: { + type: "impression", + tile_id: card.id, + source: "newtab", + advertiser: card.advertiser, + // Keep the 0-based position, can be adjusted by the telemetry + // sender if necessary. + position: card.pos, + }, + }) + ); + } + } + } + + if (this._needsImpressionStats(cards)) { + props.dispatch( + ac.DiscoveryStreamImpressionStats({ + source: props.source.toUpperCase(), + window_inner_width: window.innerWidth, + window_inner_height: window.innerHeight, + tiles: cards.map(link => ({ + id: link.id, + pos: link.pos, + type: this.props.flightId ? "spoc" : "organic", + ...(link.shim ? { shim: link.shim } : {}), + })), + }) + ); + this.impressionCardGuids = cards.map(link => link.id); + } + } + + // This checks if the given cards are the same as those in the last loaded content ping. + // If so, it should not send the same loaded content ping again. + _needsLoadedContent(cards) { + if ( + !this.loadedContentGuids || + this.loadedContentGuids.length !== cards.length + ) { + return true; + } + + for (let i = 0; i < cards.length; i++) { + if (cards[i].id !== this.loadedContentGuids[i]) { + return true; + } + } + + return false; + } + + _dispatchLoadedContent() { + const { props } = this; + const cards = props.rows; + + if (this._needsLoadedContent(cards)) { + props.dispatch( + ac.DiscoveryStreamLoadedContent({ + source: props.source.toUpperCase(), + tiles: cards.map(link => ({ id: link.id, pos: link.pos })), + }) + ); + this.loadedContentGuids = cards.map(link => link.id); + } + } + + setImpressionObserverOrAddListener() { + const { props } = this; + + if (!props.dispatch) { + return; + } + + if (props.document.visibilityState === VISIBLE) { + // Send the loaded content ping once the page is visible. + this._dispatchLoadedContent(); + this.setImpressionObserver(); + } else { + // We should only ever send the latest impression stats ping, so remove any + // older listeners. + if (this._onVisibilityChange) { + props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + + this._onVisibilityChange = () => { + if (props.document.visibilityState === VISIBLE) { + // Send the loaded content ping once the page is visible. + this._dispatchLoadedContent(); + this.setImpressionObserver(); + props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + }; + props.document.addEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + /** + * Set an impression observer for the wrapped component. It makes use of + * the Intersection Observer API to detect if the wrapped component is + * visible with a desired ratio, and only sends impression if that's the case. + * + * See more details about Intersection Observer API at: + * https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API + */ + setImpressionObserver() { + const { props } = this; + + if (!props.rows.length) { + return; + } + + this._handleIntersect = entries => { + if ( + entries.some( + entry => + entry.isIntersecting && + entry.intersectionRatio >= INTERSECTION_RATIO + ) + ) { + this._dispatchImpressionStats(); + this.impressionObserver.unobserve(this.refs.impression); + } + }; + + const options = { threshold: INTERSECTION_RATIO }; + this.impressionObserver = new props.IntersectionObserver( + this._handleIntersect, + options + ); + this.impressionObserver.observe(this.refs.impression); + } + + componentDidMount() { + if (this.props.rows.length) { + this.setImpressionObserverOrAddListener(); + } + } + + componentWillUnmount() { + if (this._handleIntersect && this.impressionObserver) { + this.impressionObserver.unobserve(this.refs.impression); + } + if (this._onVisibilityChange) { + this.props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + render() { + return ( + <div ref={"impression"} className="impression-observer"> + {this.props.children} + </div> + ); + } +} + +ImpressionStats.defaultProps = { + IntersectionObserver: global.IntersectionObserver, + document: global.document, + rows: [], + source: "", +}; diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/_ImpressionStats.scss b/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/_ImpressionStats.scss new file mode 100644 index 0000000000..943e4e34a9 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/_ImpressionStats.scss @@ -0,0 +1,7 @@ +.impression-observer { + position: absolute; + top: 0; + width: 100%; + height: 100%; + pointer-events: none; +} diff --git a/browser/components/newtab/content-src/components/ErrorBoundary/ErrorBoundary.jsx b/browser/components/newtab/content-src/components/ErrorBoundary/ErrorBoundary.jsx new file mode 100644 index 0000000000..1834a0a521 --- /dev/null +++ b/browser/components/newtab/content-src/components/ErrorBoundary/ErrorBoundary.jsx @@ -0,0 +1,68 @@ +/* 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 { A11yLinkButton } from "content-src/components/A11yLinkButton/A11yLinkButton"; +import React from "react"; + +export class ErrorBoundaryFallback extends React.PureComponent { + constructor(props) { + super(props); + this.windowObj = this.props.windowObj || window; + this.onClick = this.onClick.bind(this); + } + + /** + * Since we only get here if part of the page has crashed, do a + * forced reload to give us the best chance at recovering. + */ + onClick() { + this.windowObj.location.reload(true); + } + + render() { + const defaultClass = "as-error-fallback"; + let className; + if ("className" in this.props) { + className = `${this.props.className} ${defaultClass}`; + } else { + className = defaultClass; + } + + // "A11yLinkButton" to force normal link styling stuff (eg cursor on hover) + return ( + <div className={className}> + <div data-l10n-id="newtab-error-fallback-info" /> + <span> + <A11yLinkButton + className="reload-button" + onClick={this.onClick} + data-l10n-id="newtab-error-fallback-refresh-link" + /> + </span> + </div> + ); + } +} +ErrorBoundaryFallback.defaultProps = { className: "as-error-fallback" }; + +export class ErrorBoundary extends React.PureComponent { + constructor(props) { + super(props); + this.state = { hasError: false }; + } + + componentDidCatch(error, info) { + this.setState({ hasError: true }); + } + + render() { + if (!this.state.hasError) { + return this.props.children; + } + + return <this.props.FallbackComponent className={this.props.className} />; + } +} + +ErrorBoundary.defaultProps = { FallbackComponent: ErrorBoundaryFallback }; diff --git a/browser/components/newtab/content-src/components/ErrorBoundary/_ErrorBoundary.scss b/browser/components/newtab/content-src/components/ErrorBoundary/_ErrorBoundary.scss new file mode 100644 index 0000000000..cc54f78a27 --- /dev/null +++ b/browser/components/newtab/content-src/components/ErrorBoundary/_ErrorBoundary.scss @@ -0,0 +1,21 @@ +.as-error-fallback { + align-items: center; + border-radius: $border-radius; + box-shadow: inset $inner-box-shadow; + color: var(--newtab-text-secondary-color); + display: flex; + flex-direction: column; + font-size: $error-fallback-font-size; + justify-content: center; + justify-items: center; + line-height: $error-fallback-line-height; + + &.borderless-error { + box-shadow: none; + } + + a { + color: var(--newtab-text-secondary-color); + text-decoration: underline; + } +} diff --git a/browser/components/newtab/content-src/components/FluentOrText/FluentOrText.jsx b/browser/components/newtab/content-src/components/FluentOrText/FluentOrText.jsx new file mode 100644 index 0000000000..583a5e4a01 --- /dev/null +++ b/browser/components/newtab/content-src/components/FluentOrText/FluentOrText.jsx @@ -0,0 +1,36 @@ +/* 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"; + +/** + * Set text on a child element/component depending on if the message is already + * translated plain text or a fluent id with optional args. + */ +export class FluentOrText extends React.PureComponent { + render() { + // Ensure we have a single child to attach attributes + const { children, message } = this.props; + const child = children ? React.Children.only(children) : <span />; + + // For a string message, just use it as the child's text + let grandChildren = message; + let extraProps; + + // Convert a message object to set desired fluent-dom attributes + if (typeof message === "object") { + const args = message.args || message.values; + extraProps = { + "data-l10n-args": args && JSON.stringify(args), + "data-l10n-id": message.id || message.string_id, + }; + + // Use original children potentially with data-l10n-name attributes + grandChildren = child.props.children; + } + + // Add the message to the child via fluent attributes or text node + return React.cloneElement(child, extraProps, grandChildren); + } +} diff --git a/browser/components/newtab/content-src/components/LinkMenu/LinkMenu.jsx b/browser/components/newtab/content-src/components/LinkMenu/LinkMenu.jsx new file mode 100644 index 0000000000..650a03eb95 --- /dev/null +++ b/browser/components/newtab/content-src/components/LinkMenu/LinkMenu.jsx @@ -0,0 +1,110 @@ +/* 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 { actionCreators as ac } from "common/Actions.sys.mjs"; +import { connect } from "react-redux"; +import { ContextMenu } from "content-src/components/ContextMenu/ContextMenu"; +import { LinkMenuOptions } from "content-src/lib/link-menu-options"; +import React from "react"; + +const DEFAULT_SITE_MENU_OPTIONS = [ + "CheckPinTopSite", + "EditTopSite", + "Separator", + "OpenInNewWindow", + "OpenInPrivateWindow", + "Separator", + "BlockUrl", +]; + +export class _LinkMenu extends React.PureComponent { + getOptions() { + const { props } = this; + const { + site, + index, + source, + isPrivateBrowsingEnabled, + siteInfo, + platform, + userEvent = ac.UserEvent, + } = props; + + // Handle special case of default site + const propOptions = + site.isDefault && !site.searchTopSite && !site.sponsored_position + ? DEFAULT_SITE_MENU_OPTIONS + : props.options; + + const options = propOptions + .map(o => + LinkMenuOptions[o]( + site, + index, + source, + isPrivateBrowsingEnabled, + siteInfo, + platform + ) + ) + .map(option => { + const { action, impression, id, type, userEvent: eventName } = option; + if (!type && id) { + option.onClick = (event = {}) => { + const { ctrlKey, metaKey, shiftKey, button } = event; + // Only send along event info if there's something non-default to send + if (ctrlKey || metaKey || shiftKey || button === 1) { + action.data = Object.assign( + { + event: { ctrlKey, metaKey, shiftKey, button }, + }, + action.data + ); + } + props.dispatch(action); + if (eventName) { + const userEventData = Object.assign( + { + event: eventName, + source, + action_position: index, + value: { card_type: site.flight_id ? "spoc" : "organic" }, + }, + siteInfo + ); + props.dispatch(userEvent(userEventData)); + } + if (impression && props.shouldSendImpressionStats) { + props.dispatch(impression); + } + }; + } + return option; + }); + + // This is for accessibility to support making each item tabbable. + // We want to know which item is the first and which item + // is the last, so we can close the context menu accordingly. + options[0].first = true; + options[options.length - 1].last = true; + return options; + } + + render() { + return ( + <ContextMenu + onUpdate={this.props.onUpdate} + onShow={this.props.onShow} + options={this.getOptions()} + keyboardAccess={this.props.keyboardAccess} + /> + ); + } +} + +const getState = state => ({ + isPrivateBrowsingEnabled: state.Prefs.values.isPrivateBrowsingEnabled, + platform: state.Prefs.values.platform, +}); +export const LinkMenu = connect(getState)(_LinkMenu); diff --git a/browser/components/newtab/content-src/components/MoreRecommendations/MoreRecommendations.jsx b/browser/components/newtab/content-src/components/MoreRecommendations/MoreRecommendations.jsx new file mode 100644 index 0000000000..f2c332e5bd --- /dev/null +++ b/browser/components/newtab/content-src/components/MoreRecommendations/MoreRecommendations.jsx @@ -0,0 +1,21 @@ +/* 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 class MoreRecommendations extends React.PureComponent { + render() { + const { read_more_endpoint } = this.props; + if (read_more_endpoint) { + return ( + <a + className="more-recommendations" + href={read_more_endpoint} + data-l10n-id="newtab-pocket-more-recommendations" + /> + ); + } + return null; + } +} diff --git a/browser/components/newtab/content-src/components/MoreRecommendations/_MoreRecommendations.scss b/browser/components/newtab/content-src/components/MoreRecommendations/_MoreRecommendations.scss new file mode 100644 index 0000000000..12a66b7c11 --- /dev/null +++ b/browser/components/newtab/content-src/components/MoreRecommendations/_MoreRecommendations.scss @@ -0,0 +1,24 @@ +@use 'sass:math'; + +.more-recommendations { + display: flex; + align-items: center; + white-space: nowrap; + line-height: math.div(16, 13); // (16 / 13) -> 16px computed + + &::after { + background: url('chrome://global/skin/icons/arrow-right-12.svg') no-repeat center center; + content: ''; + -moz-context-properties: fill; + display: inline-block; + fill: var(--newtab-primary-action-background); + height: 16px; + margin-inline-start: 5px; + vertical-align: top; + width: 12px; + } + + &:dir(rtl)::after { + background-image: url('chrome://global/skin/icons/arrow-left-12.svg'); + } +} diff --git a/browser/components/newtab/content-src/components/PocketLoggedInCta/PocketLoggedInCta.jsx b/browser/components/newtab/content-src/components/PocketLoggedInCta/PocketLoggedInCta.jsx new file mode 100644 index 0000000000..53c22f319c --- /dev/null +++ b/browser/components/newtab/content-src/components/PocketLoggedInCta/PocketLoggedInCta.jsx @@ -0,0 +1,42 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { connect } from "react-redux"; +import React from "react"; + +export class _PocketLoggedInCta extends React.PureComponent { + render() { + const { pocketCta } = this.props.Pocket; + return ( + <span className="pocket-logged-in-cta"> + <a + className="pocket-cta-button" + href={pocketCta.ctaUrl ? pocketCta.ctaUrl : "https://getpocket.com/"} + > + {pocketCta.ctaButton ? ( + pocketCta.ctaButton + ) : ( + <span data-l10n-id="newtab-pocket-cta-button" /> + )} + </a> + + <a + href={pocketCta.ctaUrl ? pocketCta.ctaUrl : "https://getpocket.com/"} + > + <span className="cta-text"> + {pocketCta.ctaText ? ( + pocketCta.ctaText + ) : ( + <span data-l10n-id="newtab-pocket-cta-text" /> + )} + </span> + </a> + </span> + ); + } +} + +export const PocketLoggedInCta = connect(state => ({ Pocket: state.Pocket }))( + _PocketLoggedInCta +); diff --git a/browser/components/newtab/content-src/components/PocketLoggedInCta/_PocketLoggedInCta.scss b/browser/components/newtab/content-src/components/PocketLoggedInCta/_PocketLoggedInCta.scss new file mode 100644 index 0000000000..e1eed58e9c --- /dev/null +++ b/browser/components/newtab/content-src/components/PocketLoggedInCta/_PocketLoggedInCta.scss @@ -0,0 +1,42 @@ +@use 'sass:math'; + +.pocket-logged-in-cta { + $max-button-width: 130px; + $min-button-height: 18px; + + font-size: 13px; + margin-inline-end: 20px; + display: flex; + align-items: flex-start; + + .pocket-cta-button { + white-space: nowrap; + background: var(--newtab-primary-action-background); + letter-spacing: -0.34px; + color: $white; + border-radius: 4px; + cursor: pointer; + max-width: $max-button-width; + // The button height is 2px taller than the rest of the cta text. + // So I move it up by 1px to align with the rest of the cta text. + margin-top: -1px; + min-height: $min-button-height; + padding: 0 8px; + display: inline-flex; + justify-content: center; + align-items: center; + font-size: 11px; + margin-inline-end: 10px; + } + + .cta-text { + font-weight: normal; + font-size: 13px; + line-height: math.div(16, 13); // (16 / 13) -> 16px computed + } + + .pocket-cta-button, + .cta-text { + vertical-align: top; + } +} diff --git a/browser/components/newtab/content-src/components/Search/Search.jsx b/browser/components/newtab/content-src/components/Search/Search.jsx new file mode 100644 index 0000000000..b131c884c1 --- /dev/null +++ b/browser/components/newtab/content-src/components/Search/Search.jsx @@ -0,0 +1,223 @@ +/* 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/. */ + +/* globals ContentSearchUIController, ContentSearchHandoffUIController */ +"use strict"; + +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { connect } from "react-redux"; +import { IS_NEWTAB } from "content-src/lib/constants"; +import React from "react"; + +export class _Search extends React.PureComponent { + constructor(props) { + super(props); + this.onSearchClick = this.onSearchClick.bind(this); + this.onSearchHandoffClick = this.onSearchHandoffClick.bind(this); + this.onSearchHandoffPaste = this.onSearchHandoffPaste.bind(this); + this.onSearchHandoffDrop = this.onSearchHandoffDrop.bind(this); + this.onInputMount = this.onInputMount.bind(this); + this.onInputMountHandoff = this.onInputMountHandoff.bind(this); + this.onSearchHandoffButtonMount = + this.onSearchHandoffButtonMount.bind(this); + } + + handleEvent(event) { + // Also track search events with our own telemetry + if (event.detail.type === "Search") { + this.props.dispatch(ac.UserEvent({ event: "SEARCH" })); + } + } + + onSearchClick(event) { + window.gContentSearchController.search(event); + } + + doSearchHandoff(text) { + this.props.dispatch( + ac.OnlyToMain({ type: at.HANDOFF_SEARCH_TO_AWESOMEBAR, data: { text } }) + ); + this.props.dispatch({ type: at.FAKE_FOCUS_SEARCH }); + this.props.dispatch(ac.UserEvent({ event: "SEARCH_HANDOFF" })); + if (text) { + this.props.dispatch({ type: at.DISABLE_SEARCH }); + } + } + + onSearchHandoffClick(event) { + // When search hand-off is enabled, we render a big button that is styled to + // look like a search textbox. If the button is clicked, we style + // the button as if it was a focused search box and show a fake cursor but + // really focus the awesomebar without the focus styles ("hidden focus"). + event.preventDefault(); + this.doSearchHandoff(); + } + + onSearchHandoffPaste(event) { + event.preventDefault(); + this.doSearchHandoff(event.clipboardData.getData("Text")); + } + + onSearchHandoffDrop(event) { + event.preventDefault(); + let text = event.dataTransfer.getData("text"); + if (text) { + this.doSearchHandoff(text); + } + } + + componentWillUnmount() { + delete window.gContentSearchController; + } + + onInputMount(input) { + if (input) { + // The "healthReportKey" and needs to be "newtab" or "abouthome" so that + // BrowserUsageTelemetry.jsm knows to handle events with this name, and + // can add the appropriate telemetry probes for search. Without the correct + // name, certain tests like browser_UsageTelemetry_content.js will fail + // (See github ticket #2348 for more details) + const healthReportKey = IS_NEWTAB ? "newtab" : "abouthome"; + + // The "searchSource" needs to be "newtab" or "homepage" and is sent with + // the search data and acts as context for the search request (See + // nsISearchEngine.getSubmission). It is necessary so that search engine + // plugins can correctly atribute referrals. (See github ticket #3321 for + // more details) + const searchSource = IS_NEWTAB ? "newtab" : "homepage"; + + // gContentSearchController needs to exist as a global so that tests for + // the existing about:home can find it; and so it allows these tests to pass. + // In the future, when activity stream is default about:home, this can be renamed + window.gContentSearchController = new ContentSearchUIController( + input, + input.parentNode, + healthReportKey, + searchSource + ); + addEventListener("ContentSearchClient", this); + } else { + window.gContentSearchController = null; + removeEventListener("ContentSearchClient", this); + } + } + + onInputMountHandoff(input) { + if (input) { + // The handoff UI controller helps us set the search icon and reacts to + // changes to default engine to keep everything in sync. + this._handoffSearchController = new ContentSearchHandoffUIController(); + } + } + + getDefaultEngineName() { + // _handoffSearchController will manage engine names once it is initialized. + return this.props.Prefs.values["urlbar.placeholderName"]; + } + + getHandoffInputL10nAttributes() { + let defaultEngineName = this.getDefaultEngineName(); + return defaultEngineName + ? { + "data-l10n-id": "newtab-search-box-handoff-input", + "data-l10n-args": `{"engine": "${defaultEngineName}"}`, + } + : { + "data-l10n-id": "newtab-search-box-handoff-input-no-engine", + }; + } + + getHandoffTextL10nAttributes() { + let defaultEngineName = this.getDefaultEngineName(); + return defaultEngineName + ? { + "data-l10n-id": "newtab-search-box-handoff-text", + "data-l10n-args": `{"engine": "${defaultEngineName}"}`, + } + : { + "data-l10n-id": "newtab-search-box-handoff-text-no-engine", + }; + } + + onSearchHandoffButtonMount(button) { + // Keep a reference to the button for use during "paste" event handling. + this._searchHandoffButton = button; + } + + /* + * Do not change the ID on the input field, as legacy newtab code + * specifically looks for the id 'newtab-search-text' on input fields + * in order to execute searches in various tests + */ + render() { + const wrapperClassName = [ + "search-wrapper", + this.props.disable && "search-disabled", + this.props.fakeFocus && "fake-focus", + ] + .filter(v => v) + .join(" "); + + return ( + <div className={wrapperClassName}> + {this.props.showLogo && ( + <div className="logo-and-wordmark"> + <div className="logo" /> + <div className="wordmark" /> + </div> + )} + {!this.props.handoffEnabled && ( + <div className="search-inner-wrapper"> + <input + id="newtab-search-text" + data-l10n-id="newtab-search-box-input" + maxLength="256" + ref={this.onInputMount} + type="search" + /> + <button + id="searchSubmit" + className="search-button" + data-l10n-id="newtab-search-box-search-button" + onClick={this.onSearchClick} + /> + </div> + )} + {this.props.handoffEnabled && ( + <div className="search-inner-wrapper"> + <button + className="search-handoff-button" + {...this.getHandoffInputL10nAttributes()} + ref={this.onSearchHandoffButtonMount} + onClick={this.onSearchHandoffClick} + tabIndex="-1" + > + <div + className="fake-textbox" + {...this.getHandoffTextL10nAttributes()} + /> + <input + type="search" + className="fake-editable" + tabIndex="-1" + aria-hidden="true" + onDrop={this.onSearchHandoffDrop} + onPaste={this.onSearchHandoffPaste} + ref={this.onInputMountHandoff} + /> + <div className="fake-caret" /> + </button> + </div> + )} + </div> + ); + } +} + +export const Search = connect(state => ({ + Prefs: state.Prefs, +}))(_Search); diff --git a/browser/components/newtab/content-src/components/Search/_Search.scss b/browser/components/newtab/content-src/components/Search/_Search.scss new file mode 100644 index 0000000000..189198a16c --- /dev/null +++ b/browser/components/newtab/content-src/components/Search/_Search.scss @@ -0,0 +1,412 @@ +$search-height: 48px; +$search-height-new: 52px; +$search-icon-size: 24px; +$search-icon-padding: 16px; +$search-icon-width: 2 * $search-icon-padding + $search-icon-size - 4px; +$search-button-width: 48px; +$glyph-forward: url('chrome://browser/skin/forward.svg'); + +.search-wrapper { + padding: 34px 0 38px; + + .only-search & { + padding: 0 0 38px; + } + + .logo-and-wordmark { + $logo-size: 82px; + $wordmark-size: 134px; + + align-items: center; + display: flex; + justify-content: center; + margin-bottom: 48px; + + .logo { + display: inline-block; + height: $logo-size; + width: $logo-size; + background: url('chrome://branding/content/about-logo.png') no-repeat center; + background-size: $logo-size; + + @media (min-resolution: 2x) { + background-image: url('chrome://branding/content/about-logo@2x.png'); + } + } + + .wordmark { + background: url('chrome://branding/content/firefox-wordmark.svg') no-repeat center center; + background-size: $wordmark-size; + -moz-context-properties: fill; + display: inline-block; + fill: var(--newtab-wordmark-color); + height: $logo-size; + margin-inline-start: 16px; + width: $wordmark-size; + } + + @media (max-width: $break-point-medium - 1) { + $logo-size-small: 64px; + $wordmark-small-size: 100px; + + .logo { + background-size: $logo-size-small; + height: $logo-size-small; + width: $logo-size-small; + } + + .wordmark { + background-size: $wordmark-small-size; + height: $logo-size-small; + width: $wordmark-small-size; + margin-inline-start: 12px; + } + } + } + + .search-inner-wrapper { + cursor: default; + display: flex; + min-height: $search-height-new; + margin: 0 auto; + position: relative; + width: $searchbar-width-small; + + @media (min-width: $break-point-medium) { + width: $searchbar-width-medium; + } + + @media (min-width: $break-point-large) { + width: $searchbar-width-large; + } + + @media (min-width: $break-point-widest) { + width: $searchbar-width-largest; + } + } + + .search-handoff-button, + input { + background: var(--newtab-background-color-secondary) var(--newtab-search-icon) $search-icon-padding center no-repeat; + background-size: $search-icon-size; + padding-inline-start: $search-icon-width; + padding-inline-end: 10px; + padding-block: 0; + width: 100%; + box-shadow: $shadow-card; + border: 1px solid transparent; + border-radius: 8px; + color: var(--newtab-text-primary-color); + -moz-context-properties: fill; + fill: var(--newtab-text-secondary-color); + + &:dir(rtl) { + background-position-x: right $search-icon-padding; + } + } + + .search-inner-wrapper:active input, + input:focus { + border: 1px solid var(--newtab-primary-action-background); + outline: 0; + box-shadow: $shadow-focus; + } + + .search-button { + background: $glyph-forward no-repeat center center; + background-size: 16px 16px; + border: 0; + border-radius: 0 $border-radius $border-radius 0; + -moz-context-properties: fill; + fill: var(--newtab-text-secondary-color); + height: 100%; + inset-inline-end: 0; + position: absolute; + width: $search-button-width; + + &:focus, + &:hover { + background-color: var(--newtab-element-hover-color); + cursor: pointer; + } + + &:focus { + outline: 0; + box-shadow: $shadow-focus; + border: 1px solid var(--newtab-primary-action-background); + border-radius: 0 $border-radius-new $border-radius-new 0; + } + + &:active { + background-color: var(--newtab-element-hover-color); + } + + &:dir(rtl) { + transform: scaleX(-1); + } + } + + &.fake-focus:not(.search.disabled) { + .search-handoff-button { + border: 1px solid var(--newtab-primary-action-background); + box-shadow: $shadow-focus; + } + } + + .search-handoff-button { + padding-inline-end: 15px; + color: var(--newtab-text-primary-color); + fill: var(--newtab-text-secondary-color); + -moz-context-properties: fill; + + .fake-caret { + top: 18px; + inset-inline-start: $search-icon-width; + + &:dir(rtl) { + background-position-x: right $search-icon-padding; + } + } + } + + &.visible-logo { + .logo-and-wordmark { + .wordmark { + fill: var(--newtab-wordmark-color); + } + } + } +} + +.non-collapsible-section + .below-search-snippet-wrapper { + // If search is enabled, we need to invade its large bottom padding. + margin-top: -48px; +} + +@media (max-height: 700px) { + .search-wrapper { + padding: 0 0 30px; + } + + .non-collapsible-section + .below-search-snippet-wrapper { + // In shorter windows, search doesn't have such a large padding. + margin-top: -14px; + } + + .below-search-snippet-wrapper { + min-height: 0; + } +} + +.search-handoff-button { + background: var(--newtab-background-color-secondary) var(--newtab-search-icon) $search-icon-padding center no-repeat; + background-size: $search-icon-size; + border: solid 1px transparent; + border-radius: 3px; + box-shadow: $shadow-secondary, 0 0 0 1px $black-15; + cursor: text; + font-size: 15px; + padding: 0; + padding-inline-end: 48px; + padding-inline-start: 46px; + opacity: 1; + width: 100%; + + &:dir(rtl) { + background-position-x: right $search-icon-padding; + } + + .fake-focus:not(.search-disabled) & { + border: $input-border-active; + box-shadow: var(--newtab-textbox-focus-boxshadow); + + .fake-caret { + display: block; + } + } + + .search-disabled & { + opacity: 0.5; + box-shadow: none; + } + + .fake-editable:focus { + outline: none; + caret-color: transparent; + } + + .fake-editable { + color: transparent; + height: 100%; + opacity: 0; + position: absolute; + inset: 0; + } + + .fake-textbox { + opacity: 0.54; + text-align: start; + } + + .fake-caret { + animation: caret-animation 1.3s steps(5, start) infinite; + background: var(--newtab-text-primary-color); + display: none; + inset-inline-start: 47px; + height: 17px; + position: absolute; + top: 16px; + width: 1px; + + @keyframes caret-animation { + to { + visibility: hidden; + } + } + } +} + +@media (min-height: 701px) { + body:not(.inline-onboarding) .fixed-search { + main { + padding-top: 124px; + } + + &.visible-logo { + main { + padding-top: 254px; + } + } + + .search-wrapper { + $search-height: 45px; + $search-icon-size: 24px; + $search-icon-padding: 16px; + $search-header-bar-height: 95px; + + border-bottom: solid 1px var(--newtab-border-color); + padding: 27px 0; + background-color: var(--newtab-overlay-color); + min-height: $search-header-bar-height; + left: 0; + position: fixed; + top: 0; + width: 100%; + z-index: 9; + + .search-inner-wrapper { + min-height: $search-height; + } + + input { + background-position-x: $search-icon-padding; + background-size: $search-icon-size; + + &:dir(rtl) { + background-position-x: right $search-icon-padding; + } + } + + .search-handoff-button .fake-caret { + top: 14px; + } + + .logo-and-wordmark { + display: none; + } + } + + .search-handoff-button { + background-position-x: $search-icon-padding; + background-size: $search-icon-size; + + &:dir(rtl) { + background-position-x: right $search-icon-padding; + } + + .fake-caret { + top: 10px; + } + } + } +} + +@at-root { + // Adjust the style of the contentSearchUI-generated table + .contentSearchSuggestionTable { + border: 0; + box-shadow: $context-menu-shadow; + transform: translateY($textbox-shadow-size); + background-color: var(--newtab-background-color); + + .contentSearchHeader { + color: var(--newtab-text-secondary-color); + background-color: var(--newtab-background-color-secondary); + } + + .contentSearchHeader, + .contentSearchSettingsButton { + border-color: var(--newtab-border-color); + } + + .contentSearchSuggestionsList { + color: var(--newtab-text-primary-color); + border: 0; + } + + .contentSearchOneOffsTable { + border-top: solid 1px var(--newtab-border-color); + background-color: var(--newtab-background-color); + } + + .contentSearchSearchWithHeaderSearchText { + color: var(--newtab-text-primary-color); + } + + .contentSearchSuggestionRow { + &.selected { + background: var(--newtab-element-hover-color); + color: var(--newtab-text-primary-color); + + &:active { + background: var(--newtab-element-active-color); + } + + .historyIcon { + fill: var(--newtab-text-secondary-color); + } + } + } + + .contentSearchOneOffItem { + // Make the border slightly shorter by offsetting from the top and bottom + $border-offset: 18%; + + background-image: none; + border-image: linear-gradient(transparent $border-offset, var(--newtab-border-color) $border-offset, var(--newtab-border-color) 100% - $border-offset, transparent 100% - $border-offset) 1; + border-inline-end: 1px solid; + position: relative; + + &.selected { + background: var(--newtab-element-hover-color); + } + + &:active { + background: var(--newtab-element-active-color); + } + } + + .contentSearchSettingsButton { + &:hover { + background: var(--newtab-element-hover-color); + color: var(--newtab-text-primary-color); + } + } + } + + .contentSearchHeaderRow > td > img, + .contentSearchSuggestionRow > td > .historyIcon { + margin-inline-start: 7px; + margin-inline-end: 15px; + } +} diff --git a/browser/components/newtab/content-src/components/Sections/Sections.jsx b/browser/components/newtab/content-src/components/Sections/Sections.jsx new file mode 100644 index 0000000000..e72e9145ad --- /dev/null +++ b/browser/components/newtab/content-src/components/Sections/Sections.jsx @@ -0,0 +1,378 @@ +/* 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 { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { Card, PlaceholderCard } from "content-src/components/Card/Card"; +import { CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection"; +import { ComponentPerfTimer } from "content-src/components/ComponentPerfTimer/ComponentPerfTimer"; +import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText"; +import { connect } from "react-redux"; +import { MoreRecommendations } from "content-src/components/MoreRecommendations/MoreRecommendations"; +import { PocketLoggedInCta } from "content-src/components/PocketLoggedInCta/PocketLoggedInCta"; +import React from "react"; +import { Topics } from "content-src/components/Topics/Topics"; +import { TopSites } from "content-src/components/TopSites/TopSites"; + +const VISIBLE = "visible"; +const VISIBILITY_CHANGE_EVENT = "visibilitychange"; +const CARDS_PER_ROW_DEFAULT = 3; +const CARDS_PER_ROW_COMPACT_WIDE = 4; + +export class Section extends React.PureComponent { + get numRows() { + const { rowsPref, maxRows, Prefs } = this.props; + return rowsPref ? Prefs.values[rowsPref] : maxRows; + } + + _dispatchImpressionStats() { + const { props } = this; + let cardsPerRow = CARDS_PER_ROW_DEFAULT; + if ( + props.compactCards && + global.matchMedia(`(min-width: 1072px)`).matches + ) { + // If the section has compact cards and the viewport is wide enough, we show + // 4 columns instead of 3. + // $break-point-widest = 1072px (from _variables.scss) + cardsPerRow = CARDS_PER_ROW_COMPACT_WIDE; + } + const maxCards = cardsPerRow * this.numRows; + const cards = props.rows.slice(0, maxCards); + + if (this.needsImpressionStats(cards)) { + props.dispatch( + ac.ImpressionStats({ + source: props.eventSource, + tiles: cards.map(link => ({ id: link.guid })), + }) + ); + this.impressionCardGuids = cards.map(link => link.guid); + } + } + + // This sends an event when a user sees a set of new content. If content + // changes while the page is hidden (i.e. preloaded or on a hidden tab), + // only send the event if the page becomes visible again. + sendImpressionStatsOrAddListener() { + const { props } = this; + + if (!props.shouldSendImpressionStats || !props.dispatch) { + return; + } + + if (props.document.visibilityState === VISIBLE) { + this._dispatchImpressionStats(); + } else { + // We should only ever send the latest impression stats ping, so remove any + // older listeners. + if (this._onVisibilityChange) { + props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + + // When the page becomes visible, send the impression stats ping if the section isn't collapsed. + this._onVisibilityChange = () => { + if (props.document.visibilityState === VISIBLE) { + if (!this.props.pref.collapsed) { + this._dispatchImpressionStats(); + } + props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + }; + props.document.addEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + componentWillMount() { + this.sendNewTabRehydrated(this.props.initialized); + } + + componentDidMount() { + if (this.props.rows.length && !this.props.pref.collapsed) { + this.sendImpressionStatsOrAddListener(); + } + } + + componentDidUpdate(prevProps) { + const { props } = this; + const isCollapsed = props.pref.collapsed; + const wasCollapsed = prevProps.pref.collapsed; + if ( + // Don't send impression stats for the empty state + props.rows.length && + // We only want to send impression stats if the content of the cards has changed + // and the section is not collapsed... + ((props.rows !== prevProps.rows && !isCollapsed) || + // or if we are expanding a section that was collapsed. + (wasCollapsed && !isCollapsed)) + ) { + this.sendImpressionStatsOrAddListener(); + } + } + + componentWillUpdate(nextProps) { + this.sendNewTabRehydrated(nextProps.initialized); + } + + componentWillUnmount() { + if (this._onVisibilityChange) { + this.props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + needsImpressionStats(cards) { + if ( + !this.impressionCardGuids || + this.impressionCardGuids.length !== cards.length + ) { + return true; + } + + for (let i = 0; i < cards.length; i++) { + if (cards[i].guid !== this.impressionCardGuids[i]) { + return true; + } + } + + return false; + } + + // The NEW_TAB_REHYDRATED event is used to inform feeds that their + // data has been consumed e.g. for counting the number of tabs that + // have rendered that data. + sendNewTabRehydrated(initialized) { + if (initialized && !this.renderNotified) { + this.props.dispatch( + ac.AlsoToMain({ type: at.NEW_TAB_REHYDRATED, data: {} }) + ); + this.renderNotified = true; + } + } + + render() { + const { + id, + eventSource, + title, + rows, + Pocket, + topics, + emptyState, + dispatch, + compactCards, + read_more_endpoint, + contextMenuOptions, + initialized, + learnMore, + pref, + privacyNoticeURL, + isFirst, + isLast, + } = this.props; + + const waitingForSpoc = + id === "topstories" && this.props.Pocket.waitingForSpoc; + const maxCardsPerRow = compactCards + ? CARDS_PER_ROW_COMPACT_WIDE + : CARDS_PER_ROW_DEFAULT; + const { numRows } = this; + const maxCards = maxCardsPerRow * numRows; + const maxCardsOnNarrow = CARDS_PER_ROW_DEFAULT * numRows; + + const { pocketCta, isUserLoggedIn } = Pocket || {}; + const { useCta } = pocketCta || {}; + + // Don't display anything until we have a definitve result from Pocket, + // to avoid a flash of logged out state while we render. + const isPocketLoggedInDefined = + isUserLoggedIn === true || isUserLoggedIn === false; + + const hasTopics = topics && !!topics.length; + + const shouldShowPocketCta = + id === "topstories" && useCta && isUserLoggedIn === false; + + // Show topics only for top stories and if it has loaded with topics. + // The classs .top-stories-bottom-container ensures content doesn't shift as things load. + const shouldShowTopics = + id === "topstories" && + hasTopics && + ((useCta && isUserLoggedIn === true) || + (!useCta && isPocketLoggedInDefined)); + + // We use topics to determine language support for read more. + const shouldShowReadMore = read_more_endpoint && hasTopics; + + const realRows = rows.slice(0, maxCards); + + // The empty state should only be shown after we have initialized and there is no content. + // Otherwise, we should show placeholders. + const shouldShowEmptyState = initialized && !rows.length; + + const cards = []; + if (!shouldShowEmptyState) { + for (let i = 0; i < maxCards; i++) { + const link = realRows[i]; + // On narrow viewports, we only show 3 cards per row. We'll mark the rest as + // .hide-for-narrow to hide in CSS via @media query. + const className = i >= maxCardsOnNarrow ? "hide-for-narrow" : ""; + let usePlaceholder = !link; + // If we are in the third card and waiting for spoc, + // use the placeholder. + if (!usePlaceholder && i === 2 && waitingForSpoc) { + usePlaceholder = true; + } + cards.push( + !usePlaceholder ? ( + <Card + key={i} + index={i} + className={className} + dispatch={dispatch} + link={link} + contextMenuOptions={contextMenuOptions} + eventSource={eventSource} + shouldSendImpressionStats={this.props.shouldSendImpressionStats} + isWebExtension={this.props.isWebExtension} + /> + ) : ( + <PlaceholderCard key={i} className={className} /> + ) + ); + } + } + + const sectionClassName = [ + "section", + compactCards ? "compact-cards" : "normal-cards", + ].join(" "); + + // <Section> <-- React component + // <section> <-- HTML5 element + return ( + <ComponentPerfTimer {...this.props}> + <CollapsibleSection + className={sectionClassName} + title={title} + id={id} + eventSource={eventSource} + collapsed={this.props.pref.collapsed} + showPrefName={(pref && pref.feed) || id} + privacyNoticeURL={privacyNoticeURL} + Prefs={this.props.Prefs} + isFixed={this.props.isFixed} + isFirst={isFirst} + isLast={isLast} + learnMore={learnMore} + dispatch={this.props.dispatch} + isWebExtension={this.props.isWebExtension} + > + {!shouldShowEmptyState && ( + <ul className="section-list" style={{ padding: 0 }}> + {cards} + </ul> + )} + {shouldShowEmptyState && ( + <div className="section-empty-state"> + <div className="empty-state"> + <FluentOrText message={emptyState.message}> + <p className="empty-state-message" /> + </FluentOrText> + </div> + </div> + )} + {id === "topstories" && ( + <div className="top-stories-bottom-container"> + {shouldShowTopics && ( + <div className="wrapper-topics"> + <Topics topics={this.props.topics} /> + </div> + )} + + {shouldShowPocketCta && ( + <div className="wrapper-cta"> + <PocketLoggedInCta /> + </div> + )} + + <div className="wrapper-more-recommendations"> + {shouldShowReadMore && ( + <MoreRecommendations + read_more_endpoint={read_more_endpoint} + /> + )} + </div> + </div> + )} + </CollapsibleSection> + </ComponentPerfTimer> + ); + } +} + +Section.defaultProps = { + document: global.document, + rows: [], + emptyState: {}, + pref: {}, + title: "", +}; + +export const SectionIntl = connect(state => ({ + Prefs: state.Prefs, + Pocket: state.Pocket, +}))(Section); + +export class _Sections extends React.PureComponent { + renderSections() { + const sections = []; + const enabledSections = this.props.Sections.filter( + section => section.enabled + ); + const { sectionOrder, "feeds.topsites": showTopSites } = + this.props.Prefs.values; + // Enabled sections doesn't include Top Sites, so we add it if enabled. + const expectedCount = enabledSections.length + ~~showTopSites; + + for (const sectionId of sectionOrder.split(",")) { + const commonProps = { + key: sectionId, + isFirst: sections.length === 0, + isLast: sections.length === expectedCount - 1, + }; + if (sectionId === "topsites" && showTopSites) { + sections.push(<TopSites {...commonProps} />); + } else { + const section = enabledSections.find(s => s.id === sectionId); + if (section) { + sections.push(<SectionIntl {...section} {...commonProps} />); + } + } + } + return sections; + } + + render() { + return <div className="sections-list">{this.renderSections()}</div>; + } +} + +export const Sections = connect(state => ({ + Sections: state.Sections, + Prefs: state.Prefs, +}))(_Sections); diff --git a/browser/components/newtab/content-src/components/Sections/_Sections.scss b/browser/components/newtab/content-src/components/Sections/_Sections.scss new file mode 100644 index 0000000000..e3fe15f762 --- /dev/null +++ b/browser/components/newtab/content-src/components/Sections/_Sections.scss @@ -0,0 +1,123 @@ +.sections-list { + .section-list { + display: grid; + grid-gap: $base-gutter; + grid-template-columns: repeat(auto-fit, $card-width); + margin: 0; + + @media (max-width: $break-point-medium) { + @include context-menu-open-left; + } + + @media (min-width: $break-point-medium) and (max-width: $break-point-large) { + :nth-child(2n) { + @include context-menu-open-left; + } + } + + @media (min-width: $break-point-large) and (max-width: $break-point-large + 2 * $card-width) { + :nth-child(3n) { + @include context-menu-open-left; + } + } + + @media (min-width: $break-point-widest) and (max-width: $break-point-widest + 2 * $card-width) { + // 3n for normal cards, 4n for compact cards + :nth-child(3n), + :nth-child(4n) { + @include context-menu-open-left; + } + } + } + + .section-empty-state { + border: $border-secondary; + border-radius: $border-radius; + display: flex; + height: $card-height; + width: 100%; + + .empty-state { + margin: auto; + max-width: 350px; + + .empty-state-message { + color: var(--newtab-text-primary-color); + font-size: 13px; + margin-bottom: 0; + text-align: center; + } + } + + @media (min-width: $break-point-widest) { + height: $card-height-large; + } + } +} + +.top-stories-bottom-container { + color: var(--newtab-text-primary-color); + font-size: 12px; + line-height: 1.6; + margin-top: $topic-margin-top; + display: flex; + justify-content: space-between; + + a { + color: var(--newtab-primary-action-background); + font-weight: bold; + + &.more-recommendations { + font-weight: normal; + font-size: 13px; + } + } + + .wrapper-topics, + .wrapper-cta + .wrapper-more-recommendations { + @media (max-width: $break-point-large - 1) { + display: none; + } + } + + @media (max-width: $break-point-medium - 1) { + .wrapper-cta { + text-align: center; + + .pocket-logged-in-cta { + display: block; + margin-inline-end: 0; + + .pocket-cta-button { + max-width: none; + display: block; + margin-inline-end: 0; + margin: 5px 0 10px; + } + } + } + + .wrapper-more-recommendations { + width: 100%; + + .more-recommendations { + justify-content: center; + + &::after { + display: none; + } + } + } + } +} + +@media (min-width: $break-point-widest) { + .sections-list { + // Compact cards stay the same size but normal cards get bigger. + .normal-cards { + .section-list { + grid-template-columns: repeat(auto-fit, $card-width-large); + } + } + } +} diff --git a/browser/components/newtab/content-src/components/TopSites/SearchShortcutsForm.jsx b/browser/components/newtab/content-src/components/TopSites/SearchShortcutsForm.jsx new file mode 100644 index 0000000000..4324c019f6 --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/SearchShortcutsForm.jsx @@ -0,0 +1,192 @@ +/* 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 { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import React from "react"; +import { TOP_SITES_SOURCE } from "./TopSitesConstants"; + +export class SelectableSearchShortcut extends React.PureComponent { + render() { + const { shortcut, selected } = this.props; + const imageStyle = { backgroundImage: `url("${shortcut.tippyTopIcon}")` }; + return ( + <div className="top-site-outer search-shortcut"> + <input + type="checkbox" + id={shortcut.keyword} + name={shortcut.keyword} + checked={selected} + onChange={this.props.onChange} + /> + <label htmlFor={shortcut.keyword}> + <div className="top-site-inner"> + <span> + <div className="tile"> + <div + className="top-site-icon rich-icon" + style={imageStyle} + data-fallback="@" + /> + <div className="top-site-icon search-topsite" /> + </div> + <div className="title"> + <span dir="auto">{shortcut.keyword}</span> + </div> + </span> + </div> + </label> + </div> + ); + } +} + +export class SearchShortcutsForm extends React.PureComponent { + constructor(props) { + super(props); + this.handleChange = this.handleChange.bind(this); + this.onCancelButtonClick = this.onCancelButtonClick.bind(this); + this.onSaveButtonClick = this.onSaveButtonClick.bind(this); + + // clone the shortcuts and add them to the state so we can add isSelected property + const shortcuts = []; + const { rows, searchShortcuts } = props.TopSites; + searchShortcuts.forEach(shortcut => { + shortcuts.push({ + ...shortcut, + isSelected: !!rows.find( + row => + row && + row.isPinned && + row.searchTopSite && + row.label === shortcut.keyword + ), + }); + }); + this.state = { shortcuts }; + } + + handleChange(event) { + const { target } = event; + const { name, checked } = target; + this.setState(prevState => { + const shortcuts = prevState.shortcuts.slice(); + let shortcut = shortcuts.find(({ keyword }) => keyword === name); + shortcut.isSelected = checked; + return { shortcuts }; + }); + } + + onCancelButtonClick(ev) { + ev.preventDefault(); + this.props.onClose(); + } + + onSaveButtonClick(ev) { + ev.preventDefault(); + + // Check if there were any changes and act accordingly + const { rows } = this.props.TopSites; + const pinQueue = []; + const unpinQueue = []; + this.state.shortcuts.forEach(shortcut => { + const alreadyPinned = rows.find( + row => + row && + row.isPinned && + row.searchTopSite && + row.label === shortcut.keyword + ); + if (shortcut.isSelected && !alreadyPinned) { + pinQueue.push(this._searchTopSite(shortcut)); + } else if (!shortcut.isSelected && alreadyPinned) { + unpinQueue.push({ + url: alreadyPinned.url, + searchVendor: shortcut.shortURL, + }); + } + }); + + // Tell the feed to do the work. + this.props.dispatch( + ac.OnlyToMain({ + type: at.UPDATE_PINNED_SEARCH_SHORTCUTS, + data: { + addedShortcuts: pinQueue, + deletedShortcuts: unpinQueue, + }, + }) + ); + + // Send the Telemetry pings. + pinQueue.forEach(shortcut => { + this.props.dispatch( + ac.UserEvent({ + source: TOP_SITES_SOURCE, + event: "SEARCH_EDIT_ADD", + value: { search_vendor: shortcut.searchVendor }, + }) + ); + }); + unpinQueue.forEach(shortcut => { + this.props.dispatch( + ac.UserEvent({ + source: TOP_SITES_SOURCE, + event: "SEARCH_EDIT_DELETE", + value: { search_vendor: shortcut.searchVendor }, + }) + ); + }); + + this.props.onClose(); + } + + _searchTopSite(shortcut) { + return { + url: shortcut.url, + searchTopSite: true, + label: shortcut.keyword, + searchVendor: shortcut.shortURL, + }; + } + + render() { + return ( + <form className="topsite-form"> + <div className="search-shortcuts-container"> + <h3 + className="section-title grey-title" + data-l10n-id="newtab-topsites-add-search-engine-header" + /> + <div> + {this.state.shortcuts.map(shortcut => ( + <SelectableSearchShortcut + key={shortcut.keyword} + shortcut={shortcut} + selected={shortcut.isSelected} + onChange={this.handleChange} + /> + ))} + </div> + </div> + <section className="actions"> + <button + className="cancel" + type="button" + onClick={this.onCancelButtonClick} + data-l10n-id="newtab-topsites-cancel-button" + /> + <button + className="done" + type="submit" + onClick={this.onSaveButtonClick} + data-l10n-id="newtab-topsites-save-button" + /> + </section> + </form> + ); + } +} diff --git a/browser/components/newtab/content-src/components/TopSites/TopSite.jsx b/browser/components/newtab/content-src/components/TopSites/TopSite.jsx new file mode 100644 index 0000000000..90641008be --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/TopSite.jsx @@ -0,0 +1,873 @@ +/* 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 { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { + MIN_RICH_FAVICON_SIZE, + MIN_SMALL_FAVICON_SIZE, + TOP_SITES_CONTEXT_MENU_OPTIONS, + TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS, + TOP_SITES_SPONSORED_POSITION_CONTEXT_MENU_OPTIONS, + TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS, + TOP_SITES_SOURCE, +} from "./TopSitesConstants"; +import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu"; +import { ImpressionStats } from "../DiscoveryStreamImpressionStats/ImpressionStats"; +import React from "react"; +import { ScreenshotUtils } from "content-src/lib/screenshot-utils"; +import { TOP_SITES_MAX_SITES_PER_ROW } from "common/Reducers.sys.mjs"; +import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton"; +import { TopSiteImpressionWrapper } from "./TopSiteImpressionWrapper"; +import { connect } from "react-redux"; + +const SPOC_TYPE = "SPOC"; +const NEWTAB_SOURCE = "newtab"; + +// For cases if we want to know if this is sponsored by either sponsored_position or type. +// We have two sources for sponsored topsites, and +// sponsored_position is set by one sponsored source, and type is set by another. +// This is not called in all cases, sometimes we want to know if it's one source +// or the other. This function is only applicable in cases where we only care if it's either. +function isSponsored(link) { + return link?.sponsored_position || link?.type === SPOC_TYPE; +} + +export class TopSiteLink extends React.PureComponent { + constructor(props) { + super(props); + this.state = { screenshotImage: null }; + this.onDragEvent = this.onDragEvent.bind(this); + this.onKeyPress = this.onKeyPress.bind(this); + } + + /* + * Helper to determine whether the drop zone should allow a drop. We only allow + * dropping top sites for now. We don't allow dropping on sponsored top sites + * as their position is fixed. + */ + _allowDrop(e) { + return ( + (this.dragged || !isSponsored(this.props.link)) && + e.dataTransfer.types.includes("text/topsite-index") + ); + } + + onDragEvent(event) { + switch (event.type) { + case "click": + // Stop any link clicks if we started any dragging + if (this.dragged) { + event.preventDefault(); + } + break; + case "dragstart": + event.target.blur(); + if (isSponsored(this.props.link)) { + event.preventDefault(); + break; + } + this.dragged = true; + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/topsite-index", this.props.index); + this.props.onDragEvent( + event, + this.props.index, + this.props.link, + this.props.title + ); + break; + case "dragend": + this.props.onDragEvent(event); + break; + case "dragenter": + case "dragover": + case "drop": + if (this._allowDrop(event)) { + event.preventDefault(); + this.props.onDragEvent(event, this.props.index); + } + break; + case "mousedown": + // Block the scroll wheel from appearing for middle clicks on search top sites + if (event.button === 1 && this.props.link.searchTopSite) { + event.preventDefault(); + } + // Reset at the first mouse event of a potential drag + this.dragged = false; + break; + } + } + + /** + * Helper to obtain the next state based on nextProps and prevState. + * + * NOTE: Rename this method to getDerivedStateFromProps when we update React + * to >= 16.3. We will need to update tests as well. We cannot rename this + * method to getDerivedStateFromProps now because there is a mismatch in + * the React version that we are using for both testing and production. + * (i.e. react-test-render => "16.3.2", react => "16.2.0"). + * + * See https://github.com/airbnb/enzyme/blob/master/packages/enzyme-adapter-react-16/package.json#L43. + */ + static getNextStateFromProps(nextProps, prevState) { + const { screenshot } = nextProps.link; + const imageInState = ScreenshotUtils.isRemoteImageLocal( + prevState.screenshotImage, + screenshot + ); + if (imageInState) { + return null; + } + + // Since image was updated, attempt to revoke old image blob URL, if it exists. + ScreenshotUtils.maybeRevokeBlobObjectURL(prevState.screenshotImage); + + return { + screenshotImage: ScreenshotUtils.createLocalImageObject(screenshot), + }; + } + + // NOTE: Remove this function when we update React to >= 16.3 since React will + // call getDerivedStateFromProps automatically. We will also need to + // rename getNextStateFromProps to getDerivedStateFromProps. + componentWillMount() { + const nextState = TopSiteLink.getNextStateFromProps(this.props, this.state); + if (nextState) { + this.setState(nextState); + } + } + + // NOTE: Remove this function when we update React to >= 16.3 since React will + // call getDerivedStateFromProps automatically. We will also need to + // rename getNextStateFromProps to getDerivedStateFromProps. + componentWillReceiveProps(nextProps) { + const nextState = TopSiteLink.getNextStateFromProps(nextProps, this.state); + if (nextState) { + this.setState(nextState); + } + } + + componentWillUnmount() { + ScreenshotUtils.maybeRevokeBlobObjectURL(this.state.screenshotImage); + } + + onKeyPress(event) { + // If we have tabbed to a search shortcut top site, and we click 'enter', + // we should execute the onClick function. This needs to be added because + // search top sites are anchor tags without an href. See bug 1483135 + if (this.props.link.searchTopSite && event.key === "Enter") { + this.props.onClick(event); + } + } + + /* + * Takes the url as a string, runs it through a simple (non-secure) hash turning it into a random number + * Apply that random number to the color array. The same url will always generate the same color. + */ + generateColor() { + let { title, colors } = this.props; + if (!colors) { + return ""; + } + + let colorArray = colors.split(","); + + const hashStr = str => { + let hash = 0; + for (let i = 0; i < str.length; i++) { + let charCode = str.charCodeAt(i); + hash += charCode; + } + return hash; + }; + + let hash = hashStr(title); + let index = hash % colorArray.length; + return colorArray[index]; + } + + calculateStyle() { + const { defaultStyle, link } = this.props; + + const { tippyTopIcon, faviconSize } = link; + let imageClassName; + let imageStyle; + let showSmallFavicon = false; + let smallFaviconStyle; + let hasScreenshotImage = + this.state.screenshotImage && this.state.screenshotImage.url; + let selectedColor; + + if (defaultStyle) { + // force no styles (letter fallback) even if the link has imagery + selectedColor = this.generateColor(); + } else if (link.searchTopSite) { + imageClassName = "top-site-icon rich-icon"; + imageStyle = { + backgroundColor: link.backgroundColor, + backgroundImage: `url(${tippyTopIcon})`, + }; + smallFaviconStyle = { backgroundImage: `url(${tippyTopIcon})` }; + } else if (link.customScreenshotURL) { + // assume high quality custom screenshot and use rich icon styles and class names + imageClassName = "top-site-icon rich-icon"; + imageStyle = { + backgroundColor: link.backgroundColor, + backgroundImage: hasScreenshotImage + ? `url(${this.state.screenshotImage.url})` + : "", + }; + } else if ( + tippyTopIcon || + link.type === SPOC_TYPE || + faviconSize >= MIN_RICH_FAVICON_SIZE + ) { + // styles and class names for top sites with rich icons + imageClassName = "top-site-icon rich-icon"; + imageStyle = { + backgroundColor: link.backgroundColor, + backgroundImage: `url(${tippyTopIcon || link.favicon})`, + }; + } else if (faviconSize >= MIN_SMALL_FAVICON_SIZE) { + showSmallFavicon = true; + smallFaviconStyle = { backgroundImage: `url(${link.favicon})` }; + } else { + selectedColor = this.generateColor(); + imageClassName = ""; + } + + return { + showSmallFavicon, + smallFaviconStyle, + imageStyle, + imageClassName, + selectedColor, + }; + } + + render() { + const { children, className, isDraggable, link, onClick, title } = + this.props; + const topSiteOuterClassName = `top-site-outer${ + className ? ` ${className}` : "" + }${link.isDragged ? " dragged" : ""}${ + link.searchTopSite ? " search-shortcut" : "" + }`; + const [letterFallback] = title; + const { + showSmallFavicon, + smallFaviconStyle, + imageStyle, + imageClassName, + selectedColor, + } = this.calculateStyle(); + + let draggableProps = {}; + if (isDraggable) { + draggableProps = { + onClick: this.onDragEvent, + onDragEnd: this.onDragEvent, + onDragStart: this.onDragEvent, + onMouseDown: this.onDragEvent, + }; + } + + let impressionStats = null; + if (link.type === SPOC_TYPE) { + // Record impressions for Pocket tiles. + impressionStats = ( + <ImpressionStats + flightId={link.flightId} + rows={[ + { + id: link.id, + pos: link.pos, + shim: link.shim && link.shim.impression, + advertiser: title.toLocaleLowerCase(), + }, + ]} + dispatch={this.props.dispatch} + source={TOP_SITES_SOURCE} + /> + ); + } else if (isSponsored(link)) { + // Record impressions for non-Pocket sponsored tiles. + impressionStats = ( + <TopSiteImpressionWrapper + actionType={at.TOP_SITES_SPONSORED_IMPRESSION_STATS} + tile={{ + position: this.props.index, + tile_id: link.sponsored_tile_id || -1, + reporting_url: link.sponsored_impression_url, + advertiser: title.toLocaleLowerCase(), + source: NEWTAB_SOURCE, + }} + // For testing. + IntersectionObserver={this.props.IntersectionObserver} + document={this.props.document} + dispatch={this.props.dispatch} + /> + ); + } else { + // Record impressions for organic tiles. + impressionStats = ( + <TopSiteImpressionWrapper + actionType={at.TOP_SITES_ORGANIC_IMPRESSION_STATS} + tile={{ + position: this.props.index, + source: NEWTAB_SOURCE, + }} + // For testing. + IntersectionObserver={this.props.IntersectionObserver} + document={this.props.document} + dispatch={this.props.dispatch} + /> + ); + } + + return ( + <li + className={topSiteOuterClassName} + onDrop={this.onDragEvent} + onDragOver={this.onDragEvent} + onDragEnter={this.onDragEvent} + onDragLeave={this.onDragEvent} + {...draggableProps} + > + <div className="top-site-inner"> + {/* We don't yet support an accessible drag-and-drop implementation, see Bug 1552005 */} + {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} + <a + className="top-site-button" + href={link.searchTopSite ? undefined : link.url} + tabIndex="0" + onKeyPress={this.onKeyPress} + onClick={onClick} + draggable={true} + data-is-sponsored-link={!!link.sponsored_tile_id} + > + <div className="tile" aria-hidden={true}> + <div + className={ + selectedColor + ? "icon-wrapper letter-fallback" + : "icon-wrapper" + } + data-fallback={letterFallback} + style={selectedColor ? { backgroundColor: selectedColor } : {}} + > + <div className={imageClassName} style={imageStyle} /> + {showSmallFavicon && ( + <div + className="top-site-icon default-icon" + data-fallback={smallFaviconStyle ? "" : letterFallback} + style={smallFaviconStyle} + /> + )} + </div> + {link.searchTopSite && ( + <div className="top-site-icon search-topsite" /> + )} + </div> + <div + className={`title${link.isPinned ? " has-icon pinned" : ""}${ + link.type === SPOC_TYPE || link.show_sponsored_label + ? " sponsored" + : "" + }`} + > + <span dir="auto"> + {link.isPinned && <div className="icon icon-pin-small" />} + {title || <br />} + <span + className="sponsored-label" + data-l10n-id="newtab-topsite-sponsored" + /> + </span> + </div> + </a> + {children} + {impressionStats} + </div> + </li> + ); + } +} +TopSiteLink.defaultProps = { + title: "", + link: {}, + isDraggable: true, +}; + +export class TopSite extends React.PureComponent { + constructor(props) { + super(props); + this.state = { showContextMenu: false }; + this.onLinkClick = this.onLinkClick.bind(this); + this.onMenuUpdate = this.onMenuUpdate.bind(this); + } + + /** + * Report to telemetry additional information about the item. + */ + _getTelemetryInfo() { + const value = { icon_type: this.props.link.iconType }; + // Filter out "not_pinned" type for being the default + if (this.props.link.isPinned) { + value.card_type = "pinned"; + } + if (this.props.link.searchTopSite) { + // Set the card_type as "search" regardless of its pinning status + value.card_type = "search"; + value.search_vendor = this.props.link.hostname; + } + if (isSponsored(this.props.link)) { + value.card_type = "spoc"; + } + return { value }; + } + + userEvent(event) { + this.props.dispatch( + ac.UserEvent( + Object.assign( + { + event, + source: TOP_SITES_SOURCE, + action_position: this.props.index, + }, + this._getTelemetryInfo() + ) + ) + ); + } + + onLinkClick(event) { + this.userEvent("CLICK"); + + // Specially handle a top site link click for "typed" frecency bonus as + // specified as a property on the link. + event.preventDefault(); + const { altKey, button, ctrlKey, metaKey, shiftKey } = event; + if (!this.props.link.searchTopSite) { + this.props.dispatch( + ac.OnlyToMain({ + type: at.OPEN_LINK, + data: Object.assign(this.props.link, { + event: { altKey, button, ctrlKey, metaKey, shiftKey }, + }), + }) + ); + + if (this.props.link.type === SPOC_TYPE) { + // Record a Pocket-specific click. + this.props.dispatch( + ac.ImpressionStats({ + source: TOP_SITES_SOURCE, + click: 0, + tiles: [ + { + id: this.props.link.id, + pos: this.props.link.pos, + shim: this.props.link.shim && this.props.link.shim.click, + }, + ], + }) + ); + + // Record a click for a Pocket sponsored tile. + const title = this.props.link.label || this.props.link.hostname; + this.props.dispatch( + ac.OnlyToMain({ + type: at.TOP_SITES_SPONSORED_IMPRESSION_STATS, + data: { + type: "click", + position: this.props.link.pos, + tile_id: this.props.link.id, + advertiser: title.toLocaleLowerCase(), + source: NEWTAB_SOURCE, + }, + }) + ); + } else if (isSponsored(this.props.link)) { + // Record a click for a non-Pocket sponsored tile. + const title = this.props.link.label || this.props.link.hostname; + this.props.dispatch( + ac.OnlyToMain({ + type: at.TOP_SITES_SPONSORED_IMPRESSION_STATS, + data: { + type: "click", + position: this.props.index, + tile_id: this.props.link.sponsored_tile_id || -1, + reporting_url: this.props.link.sponsored_click_url, + advertiser: title.toLocaleLowerCase(), + source: NEWTAB_SOURCE, + }, + }) + ); + } else { + // Record a click for an organic tile. + this.props.dispatch( + ac.OnlyToMain({ + type: at.TOP_SITES_ORGANIC_IMPRESSION_STATS, + data: { + type: "click", + position: this.props.index, + source: NEWTAB_SOURCE, + }, + }) + ); + } + + if (this.props.link.sendAttributionRequest) { + this.props.dispatch( + ac.OnlyToMain({ + type: at.PARTNER_LINK_ATTRIBUTION, + data: { + targetURL: this.props.link.url, + source: "newtab", + }, + }) + ); + } + } else { + this.props.dispatch( + ac.OnlyToMain({ + type: at.FILL_SEARCH_TERM, + data: { label: this.props.link.label }, + }) + ); + } + } + + onMenuUpdate(isOpen) { + if (isOpen) { + this.props.onActivate(this.props.index); + } else { + this.props.onActivate(); + } + } + + render() { + const { props } = this; + const { link } = props; + const isContextMenuOpen = props.activeIndex === props.index; + const title = link.label || link.hostname; + let menuOptions; + if (link.sponsored_position) { + menuOptions = TOP_SITES_SPONSORED_POSITION_CONTEXT_MENU_OPTIONS; + } else if (link.searchTopSite) { + menuOptions = TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS; + } else if (link.type === SPOC_TYPE) { + menuOptions = TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS; + } else { + menuOptions = TOP_SITES_CONTEXT_MENU_OPTIONS; + } + + return ( + <TopSiteLink + {...props} + onClick={this.onLinkClick} + onDragEvent={this.props.onDragEvent} + className={`${props.className || ""}${ + isContextMenuOpen ? " active" : "" + }`} + title={title} + > + <div> + <ContextMenuButton + tooltip="newtab-menu-content-tooltip" + tooltipArgs={{ title }} + onUpdate={this.onMenuUpdate} + > + <LinkMenu + dispatch={props.dispatch} + index={props.index} + onUpdate={this.onMenuUpdate} + options={menuOptions} + site={link} + shouldSendImpressionStats={link.type === SPOC_TYPE} + siteInfo={this._getTelemetryInfo()} + source={TOP_SITES_SOURCE} + /> + </ContextMenuButton> + </div> + </TopSiteLink> + ); + } +} +TopSite.defaultProps = { + link: {}, + onActivate() {}, +}; + +export class TopSitePlaceholder extends React.PureComponent { + constructor(props) { + super(props); + this.onEditButtonClick = this.onEditButtonClick.bind(this); + } + + onEditButtonClick() { + this.props.dispatch({ + type: at.TOP_SITES_EDIT, + data: { index: this.props.index }, + }); + } + + render() { + return ( + <TopSiteLink + {...this.props} + className={`placeholder ${this.props.className || ""}`} + isDraggable={false} + > + <button + aria-haspopup="dialog" + className="context-menu-button edit-button icon" + data-l10n-id="newtab-menu-topsites-placeholder-tooltip" + onClick={this.onEditButtonClick} + /> + </TopSiteLink> + ); + } +} + +export class _TopSiteList extends React.PureComponent { + static get DEFAULT_STATE() { + return { + activeIndex: null, + draggedIndex: null, + draggedSite: null, + draggedTitle: null, + topSitesPreview: null, + }; + } + + constructor(props) { + super(props); + this.state = _TopSiteList.DEFAULT_STATE; + this.onDragEvent = this.onDragEvent.bind(this); + this.onActivate = this.onActivate.bind(this); + } + + componentWillReceiveProps(nextProps) { + if (this.state.draggedSite) { + const prevTopSites = this.props.TopSites && this.props.TopSites.rows; + const newTopSites = nextProps.TopSites && nextProps.TopSites.rows; + if ( + prevTopSites && + prevTopSites[this.state.draggedIndex] && + prevTopSites[this.state.draggedIndex].url === + this.state.draggedSite.url && + (!newTopSites[this.state.draggedIndex] || + newTopSites[this.state.draggedIndex].url !== + this.state.draggedSite.url) + ) { + // We got the new order from the redux store via props. We can clear state now. + this.setState(_TopSiteList.DEFAULT_STATE); + } + } + } + + userEvent(event, index) { + this.props.dispatch( + ac.UserEvent({ + event, + source: TOP_SITES_SOURCE, + action_position: index, + }) + ); + } + + onDragEvent(event, index, link, title) { + switch (event.type) { + case "dragstart": + this.dropped = false; + this.setState({ + draggedIndex: index, + draggedSite: link, + draggedTitle: title, + activeIndex: null, + }); + this.userEvent("DRAG", index); + break; + case "dragend": + if (!this.dropped) { + // If there was no drop event, reset the state to the default. + this.setState(_TopSiteList.DEFAULT_STATE); + } + break; + case "dragenter": + if (index === this.state.draggedIndex) { + this.setState({ topSitesPreview: null }); + } else { + this.setState({ + topSitesPreview: this._makeTopSitesPreview(index), + }); + } + break; + case "drop": + if (index !== this.state.draggedIndex) { + this.dropped = true; + this.props.dispatch( + ac.AlsoToMain({ + type: at.TOP_SITES_INSERT, + data: { + site: { + url: this.state.draggedSite.url, + label: this.state.draggedTitle, + customScreenshotURL: + this.state.draggedSite.customScreenshotURL, + // Only if the search topsites experiment is enabled + ...(this.state.draggedSite.searchTopSite && { + searchTopSite: true, + }), + }, + index, + draggedFromIndex: this.state.draggedIndex, + }, + }) + ); + this.userEvent("DROP", index); + } + break; + } + } + + _getTopSites() { + // Make a copy of the sites to truncate or extend to desired length + let topSites = this.props.TopSites.rows.slice(); + topSites.length = this.props.TopSitesRows * TOP_SITES_MAX_SITES_PER_ROW; + return topSites; + } + + /** + * Make a preview of the topsites that will be the result of dropping the currently + * dragged site at the specified index. + */ + _makeTopSitesPreview(index) { + const topSites = this._getTopSites(); + topSites[this.state.draggedIndex] = null; + const preview = topSites.map(site => + site && (site.isPinned || isSponsored(site)) ? site : null + ); + const unpinned = topSites.filter( + site => site && !site.isPinned && !isSponsored(site) + ); + const siteToInsert = Object.assign({}, this.state.draggedSite, { + isPinned: true, + isDragged: true, + }); + + if (!preview[index]) { + preview[index] = siteToInsert; + } else { + // Find the hole to shift the pinned site(s) towards. We shift towards the + // hole left by the site being dragged. + let holeIndex = index; + const indexStep = index > this.state.draggedIndex ? -1 : 1; + while (preview[holeIndex]) { + holeIndex += indexStep; + } + + // Shift towards the hole. + const shiftingStep = index > this.state.draggedIndex ? 1 : -1; + while ( + index > this.state.draggedIndex ? holeIndex < index : holeIndex > index + ) { + let nextIndex = holeIndex + shiftingStep; + while (isSponsored(preview[nextIndex])) { + nextIndex += shiftingStep; + } + preview[holeIndex] = preview[nextIndex]; + holeIndex = nextIndex; + } + preview[index] = siteToInsert; + } + + // Fill in the remaining holes with unpinned sites. + for (let i = 0; i < preview.length; i++) { + if (!preview[i]) { + preview[i] = unpinned.shift() || null; + } + } + + return preview; + } + + onActivate(index) { + this.setState({ activeIndex: index }); + } + + render() { + const { props } = this; + const topSites = this.state.topSitesPreview || this._getTopSites(); + const topSitesUI = []; + const commonProps = { + onDragEvent: this.onDragEvent, + dispatch: props.dispatch, + }; + // We assign a key to each placeholder slot. We need it to be independent + // of the slot index (i below) so that the keys used stay the same during + // drag and drop reordering and the underlying DOM nodes are reused. + // This mostly (only?) affects linux so be sure to test on linux before changing. + let holeIndex = 0; + + // On narrow viewports, we only show 6 sites per row. We'll mark the rest as + // .hide-for-narrow to hide in CSS via @media query. + const maxNarrowVisibleIndex = props.TopSitesRows * 6; + + for (let i = 0, l = topSites.length; i < l; i++) { + const link = + topSites[i] && + Object.assign({}, topSites[i], { + iconType: this.props.topSiteIconType(topSites[i]), + }); + + const slotProps = { + key: link ? link.url : holeIndex++, + index: i, + }; + if (i >= maxNarrowVisibleIndex) { + slotProps.className = "hide-for-narrow"; + } + + let topSiteLink; + // Use a placeholder if the link is empty or it's rendering a sponsored + // tile for the about:home startup cache. + if (!link || (props.App.isForStartupCache && isSponsored(link))) { + topSiteLink = <TopSitePlaceholder {...slotProps} {...commonProps} />; + } else { + topSiteLink = ( + <TopSite + link={link} + activeIndex={this.state.activeIndex} + onActivate={this.onActivate} + {...slotProps} + {...commonProps} + colors={props.colors} + /> + ); + } + + topSitesUI.push(topSiteLink); + } + return ( + <ul + className={`top-sites-list${ + this.state.draggedSite ? " dnd-active" : "" + }`} + > + {topSitesUI} + </ul> + ); + } +} + +export const TopSiteList = connect(state => ({ + App: state.App, +}))(_TopSiteList); diff --git a/browser/components/newtab/content-src/components/TopSites/TopSiteForm.jsx b/browser/components/newtab/content-src/components/TopSites/TopSiteForm.jsx new file mode 100644 index 0000000000..7dd61bdc93 --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/TopSiteForm.jsx @@ -0,0 +1,323 @@ +/* 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 { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { A11yLinkButton } from "content-src/components/A11yLinkButton/A11yLinkButton"; +import React from "react"; +import { TOP_SITES_SOURCE } from "./TopSitesConstants"; +import { TopSiteFormInput } from "./TopSiteFormInput"; +import { TopSiteLink } from "./TopSite"; + +export class TopSiteForm extends React.PureComponent { + constructor(props) { + super(props); + const { site } = props; + this.state = { + label: site ? site.label || site.hostname : "", + url: site ? site.url : "", + validationError: false, + customScreenshotUrl: site ? site.customScreenshotURL : "", + showCustomScreenshotForm: site ? site.customScreenshotURL : false, + }; + this.onClearScreenshotInput = this.onClearScreenshotInput.bind(this); + this.onLabelChange = this.onLabelChange.bind(this); + this.onUrlChange = this.onUrlChange.bind(this); + this.onCancelButtonClick = this.onCancelButtonClick.bind(this); + this.onClearUrlClick = this.onClearUrlClick.bind(this); + this.onDoneButtonClick = this.onDoneButtonClick.bind(this); + this.onCustomScreenshotUrlChange = + this.onCustomScreenshotUrlChange.bind(this); + this.onPreviewButtonClick = this.onPreviewButtonClick.bind(this); + this.onEnableScreenshotUrlForm = this.onEnableScreenshotUrlForm.bind(this); + this.validateUrl = this.validateUrl.bind(this); + } + + onLabelChange(event) { + this.setState({ label: event.target.value }); + } + + onUrlChange(event) { + this.setState({ + url: event.target.value, + validationError: false, + }); + } + + onClearUrlClick() { + this.setState({ + url: "", + validationError: false, + }); + } + + onEnableScreenshotUrlForm() { + this.setState({ showCustomScreenshotForm: true }); + } + + _updateCustomScreenshotInput(customScreenshotUrl) { + this.setState({ + customScreenshotUrl, + validationError: false, + }); + this.props.dispatch({ type: at.PREVIEW_REQUEST_CANCEL }); + } + + onCustomScreenshotUrlChange(event) { + this._updateCustomScreenshotInput(event.target.value); + } + + onClearScreenshotInput() { + this._updateCustomScreenshotInput(""); + } + + onCancelButtonClick(ev) { + ev.preventDefault(); + this.props.onClose(); + } + + onDoneButtonClick(ev) { + ev.preventDefault(); + + if (this.validateForm()) { + const site = { url: this.cleanUrl(this.state.url) }; + const { index } = this.props; + if (this.state.label !== "") { + site.label = this.state.label; + } + + if (this.state.customScreenshotUrl) { + site.customScreenshotURL = this.cleanUrl( + this.state.customScreenshotUrl + ); + } else if (this.props.site && this.props.site.customScreenshotURL) { + // Used to flag that previously cached screenshot should be removed + site.customScreenshotURL = null; + } + this.props.dispatch( + ac.AlsoToMain({ + type: at.TOP_SITES_PIN, + data: { site, index }, + }) + ); + this.props.dispatch( + ac.UserEvent({ + source: TOP_SITES_SOURCE, + event: "TOP_SITES_EDIT", + action_position: index, + }) + ); + + this.props.onClose(); + } + } + + onPreviewButtonClick(event) { + event.preventDefault(); + if (this.validateForm()) { + this.props.dispatch( + ac.AlsoToMain({ + type: at.PREVIEW_REQUEST, + data: { url: this.cleanUrl(this.state.customScreenshotUrl) }, + }) + ); + this.props.dispatch( + ac.UserEvent({ + source: TOP_SITES_SOURCE, + event: "PREVIEW_REQUEST", + }) + ); + } + } + + cleanUrl(url) { + // If we are missing a protocol, prepend http:// + if (!url.startsWith("http:") && !url.startsWith("https:")) { + return `http://${url}`; + } + return url; + } + + _tryParseUrl(url) { + try { + return new URL(url); + } catch (e) { + return null; + } + } + + validateUrl(url) { + const validProtocols = ["http:", "https:"]; + const urlObj = + this._tryParseUrl(url) || this._tryParseUrl(this.cleanUrl(url)); + + return urlObj && validProtocols.includes(urlObj.protocol); + } + + validateCustomScreenshotUrl() { + const { customScreenshotUrl } = this.state; + return !customScreenshotUrl || this.validateUrl(customScreenshotUrl); + } + + validateForm() { + const validate = + this.validateUrl(this.state.url) && this.validateCustomScreenshotUrl(); + + if (!validate) { + this.setState({ validationError: true }); + } + + return validate; + } + + _renderCustomScreenshotInput() { + const { customScreenshotUrl } = this.state; + const requestFailed = this.props.previewResponse === ""; + const validationError = + (this.state.validationError && !this.validateCustomScreenshotUrl()) || + requestFailed; + // Set focus on error if the url field is valid or when the input is first rendered and is empty + const shouldFocus = + (validationError && this.validateUrl(this.state.url)) || + !customScreenshotUrl; + const isLoading = + this.props.previewResponse === null && + customScreenshotUrl && + this.props.previewUrl === this.cleanUrl(customScreenshotUrl); + + if (!this.state.showCustomScreenshotForm) { + return ( + <A11yLinkButton + onClick={this.onEnableScreenshotUrlForm} + className="enable-custom-image-input" + data-l10n-id="newtab-topsites-use-image-link" + /> + ); + } + return ( + <div className="custom-image-input-container"> + <TopSiteFormInput + errorMessageId={ + requestFailed + ? "newtab-topsites-image-validation" + : "newtab-topsites-url-validation" + } + loading={isLoading} + onChange={this.onCustomScreenshotUrlChange} + onClear={this.onClearScreenshotInput} + shouldFocus={shouldFocus} + typeUrl={true} + value={customScreenshotUrl} + validationError={validationError} + titleId="newtab-topsites-image-url-label" + placeholderId="newtab-topsites-url-input" + /> + </div> + ); + } + + render() { + const { customScreenshotUrl } = this.state; + const requestFailed = this.props.previewResponse === ""; + // For UI purposes, editing without an existing link is "add" + const showAsAdd = !this.props.site; + const previous = + (this.props.site && this.props.site.customScreenshotURL) || ""; + const changed = + customScreenshotUrl && this.cleanUrl(customScreenshotUrl) !== previous; + // Preview mode if changes were made to the custom screenshot URL and no preview was received yet + // or the request failed + const previewMode = changed && !this.props.previewResponse; + const previewLink = Object.assign({}, this.props.site); + if (this.props.previewResponse) { + previewLink.screenshot = this.props.previewResponse; + previewLink.customScreenshotURL = this.props.previewUrl; + } + // Handles the form submit so an enter press performs the correct action + const onSubmit = previewMode + ? this.onPreviewButtonClick + : this.onDoneButtonClick; + + const addTopsitesHeaderL10nId = "newtab-topsites-add-shortcut-header"; + const editTopsitesHeaderL10nId = "newtab-topsites-edit-shortcut-header"; + return ( + <form className="topsite-form" onSubmit={onSubmit}> + <div className="form-input-container"> + <h3 + className="section-title grey-title" + data-l10n-id={ + showAsAdd ? addTopsitesHeaderL10nId : editTopsitesHeaderL10nId + } + /> + <div className="fields-and-preview"> + <div className="form-wrapper"> + <TopSiteFormInput + onChange={this.onLabelChange} + value={this.state.label} + titleId="newtab-topsites-title-label" + placeholderId="newtab-topsites-title-input" + autoFocusOnOpen={true} + /> + <TopSiteFormInput + onChange={this.onUrlChange} + shouldFocus={ + this.state.validationError && + !this.validateUrl(this.state.url) + } + value={this.state.url} + onClear={this.onClearUrlClick} + validationError={ + this.state.validationError && + !this.validateUrl(this.state.url) + } + titleId="newtab-topsites-url-label" + typeUrl={true} + placeholderId="newtab-topsites-url-input" + errorMessageId="newtab-topsites-url-validation" + /> + {this._renderCustomScreenshotInput()} + </div> + <TopSiteLink + link={previewLink} + defaultStyle={requestFailed} + title={this.state.label} + /> + </div> + </div> + <section className="actions"> + <button + className="cancel" + type="button" + onClick={this.onCancelButtonClick} + data-l10n-id="newtab-topsites-cancel-button" + /> + {previewMode ? ( + <button + className="done preview" + type="submit" + data-l10n-id="newtab-topsites-preview-button" + /> + ) : ( + <button + className="done" + type="submit" + data-l10n-id={ + showAsAdd + ? "newtab-topsites-add-button" + : "newtab-topsites-save-button" + } + /> + )} + </section> + </form> + ); + } +} + +TopSiteForm.defaultProps = { + site: null, + index: -1, +}; diff --git a/browser/components/newtab/content-src/components/TopSites/TopSiteFormInput.jsx b/browser/components/newtab/content-src/components/TopSites/TopSiteFormInput.jsx new file mode 100644 index 0000000000..c680edc7e4 --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/TopSiteFormInput.jsx @@ -0,0 +1,111 @@ +/* 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 class TopSiteFormInput extends React.PureComponent { + constructor(props) { + super(props); + this.state = { validationError: this.props.validationError }; + this.onChange = this.onChange.bind(this); + this.onMount = this.onMount.bind(this); + this.onClearIconPress = this.onClearIconPress.bind(this); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.shouldFocus && !this.props.shouldFocus) { + this.input.focus(); + } + if (nextProps.validationError && !this.props.validationError) { + this.setState({ validationError: true }); + } + // If the component is in an error state but the value was cleared by the parent + if (this.state.validationError && !nextProps.value) { + this.setState({ validationError: false }); + } + } + + onClearIconPress(event) { + // If there is input in the URL or custom image URL fields, + // and we hit 'enter' while tabbed over the clear icon, + // we should execute the function to clear the field. + if (event.key === "Enter") { + this.props.onClear(); + } + } + + onChange(ev) { + if (this.state.validationError) { + this.setState({ validationError: false }); + } + this.props.onChange(ev); + } + + onMount(input) { + this.input = input; + } + + renderLoadingOrCloseButton() { + const showClearButton = this.props.value && this.props.onClear; + + if (this.props.loading) { + return ( + <div className="loading-container"> + <div className="loading-animation" /> + </div> + ); + } else if (showClearButton) { + return ( + <button + type="button" + className="icon icon-clear-input icon-button-style" + onClick={this.props.onClear} + onKeyPress={this.onClearIconPress} + /> + ); + } + return null; + } + + render() { + const { typeUrl } = this.props; + const { validationError } = this.state; + + return ( + <label> + <span data-l10n-id={this.props.titleId} /> + <div + className={`field ${typeUrl ? "url" : ""}${ + validationError ? " invalid" : "" + }`} + > + <input + type="text" + value={this.props.value} + ref={this.onMount} + onChange={this.onChange} + data-l10n-id={this.props.placeholderId} + // Set focus on error if the url field is valid or when the input is first rendered and is empty + // eslint-disable-next-line jsx-a11y/no-autofocus + autoFocus={this.props.autoFocusOnOpen} + disabled={this.props.loading} + /> + {this.renderLoadingOrCloseButton()} + {validationError && ( + <aside + className="error-tooltip" + data-l10n-id={this.props.errorMessageId} + /> + )} + </div> + </label> + ); + } +} + +TopSiteFormInput.defaultProps = { + showClearButton: false, + value: "", + validationError: false, +}; diff --git a/browser/components/newtab/content-src/components/TopSites/TopSiteImpressionWrapper.jsx b/browser/components/newtab/content-src/components/TopSites/TopSiteImpressionWrapper.jsx new file mode 100644 index 0000000000..580809dd57 --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/TopSiteImpressionWrapper.jsx @@ -0,0 +1,149 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { actionCreators as ac } from "common/Actions.sys.mjs"; +import React from "react"; + +const VISIBLE = "visible"; +const VISIBILITY_CHANGE_EVENT = "visibilitychange"; + +// Per analytical requirement, we set the minimal intersection ratio to +// 0.5, and an impression is identified when the wrapped item has at least +// 50% visibility. +// +// This constant is exported for unit test +export const INTERSECTION_RATIO = 0.5; + +/** + * Impression wrapper for a TopSite tile. + * + * It makses use of the Intersection Observer API to detect the visibility, + * and relies on page visibility to ensure the impression is reported + * only when the component is visible on the page. + */ +export class TopSiteImpressionWrapper extends React.PureComponent { + _dispatchImpressionStats() { + const { actionType, tile } = this.props; + if (!actionType) { + return; + } + + this.props.dispatch( + ac.OnlyToMain({ + type: actionType, + data: { + type: "impression", + ...tile, + }, + }) + ); + } + + setImpressionObserverOrAddListener() { + const { props } = this; + + if (!props.dispatch) { + return; + } + + if (props.document.visibilityState === VISIBLE) { + this.setImpressionObserver(); + } else { + // We should only ever send the latest impression stats ping, so remove any + // older listeners. + if (this._onVisibilityChange) { + props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + + this._onVisibilityChange = () => { + if (props.document.visibilityState === VISIBLE) { + this.setImpressionObserver(); + props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + }; + props.document.addEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + /** + * Set an impression observer for the wrapped component. It makes use of + * the Intersection Observer API to detect if the wrapped component is + * visible with a desired ratio, and only sends impression if that's the case. + * + * See more details about Intersection Observer API at: + * https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API + */ + setImpressionObserver() { + const { props } = this; + + if (!props.tile) { + return; + } + + this._handleIntersect = entries => { + if ( + entries.some( + entry => + entry.isIntersecting && + entry.intersectionRatio >= INTERSECTION_RATIO + ) + ) { + this._dispatchImpressionStats(); + this.impressionObserver.unobserve(this.refs.topsite_impression_wrapper); + } + }; + + const options = { threshold: INTERSECTION_RATIO }; + this.impressionObserver = new props.IntersectionObserver( + this._handleIntersect, + options + ); + this.impressionObserver.observe(this.refs.topsite_impression_wrapper); + } + + componentDidMount() { + if (this.props.tile) { + this.setImpressionObserverOrAddListener(); + } + } + + componentWillUnmount() { + if (this._handleIntersect && this.impressionObserver) { + this.impressionObserver.unobserve(this.refs.topsite_impression_wrapper); + } + if (this._onVisibilityChange) { + this.props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + render() { + return ( + <div + ref={"topsite_impression_wrapper"} + className="topsite-impression-observer" + > + {this.props.children} + </div> + ); + } +} + +TopSiteImpressionWrapper.defaultProps = { + IntersectionObserver: global.IntersectionObserver, + document: global.document, + actionType: null, + tile: null, +}; diff --git a/browser/components/newtab/content-src/components/TopSites/TopSites.jsx b/browser/components/newtab/content-src/components/TopSites/TopSites.jsx new file mode 100644 index 0000000000..fd1e3048a5 --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/TopSites.jsx @@ -0,0 +1,213 @@ +/* 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 { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { MIN_RICH_FAVICON_SIZE, TOP_SITES_SOURCE } from "./TopSitesConstants"; +import { CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection"; +import { ComponentPerfTimer } from "content-src/components/ComponentPerfTimer/ComponentPerfTimer"; +import { connect } from "react-redux"; +import { ModalOverlayWrapper } from "../../asrouter/components/ModalOverlay/ModalOverlay"; +import React from "react"; +import { SearchShortcutsForm } from "./SearchShortcutsForm"; +import { TOP_SITES_MAX_SITES_PER_ROW } from "common/Reducers.sys.mjs"; +import { TopSiteForm } from "./TopSiteForm"; +import { TopSiteList } from "./TopSite"; + +function topSiteIconType(link) { + if (link.customScreenshotURL) { + return "custom_screenshot"; + } + if (link.tippyTopIcon || link.faviconRef === "tippytop") { + return "tippytop"; + } + if (link.faviconSize >= MIN_RICH_FAVICON_SIZE) { + return "rich_icon"; + } + if (link.screenshot) { + return "screenshot"; + } + return "no_image"; +} + +/** + * Iterates through TopSites and counts types of images. + * @param acc Accumulator for reducer. + * @param topsite Entry in TopSites. + */ +function countTopSitesIconsTypes(topSites) { + const countTopSitesTypes = (acc, link) => { + acc[topSiteIconType(link)]++; + return acc; + }; + + return topSites.reduce(countTopSitesTypes, { + custom_screenshot: 0, + screenshot: 0, + tippytop: 0, + rich_icon: 0, + no_image: 0, + }); +} + +export class _TopSites extends React.PureComponent { + constructor(props) { + super(props); + this.onEditFormClose = this.onEditFormClose.bind(this); + this.onSearchShortcutsFormClose = + this.onSearchShortcutsFormClose.bind(this); + } + + /** + * Dispatch session statistics about the quality of TopSites icons and pinned count. + */ + _dispatchTopSitesStats() { + const topSites = this._getVisibleTopSites().filter( + topSite => topSite !== null && topSite !== undefined + ); + const topSitesIconsStats = countTopSitesIconsTypes(topSites); + const topSitesPinned = topSites.filter(site => !!site.isPinned).length; + const searchShortcuts = topSites.filter( + site => !!site.searchTopSite + ).length; + // Dispatch telemetry event with the count of TopSites images types. + this.props.dispatch( + ac.AlsoToMain({ + type: at.SAVE_SESSION_PERF_DATA, + data: { + topsites_icon_stats: topSitesIconsStats, + topsites_pinned: topSitesPinned, + topsites_search_shortcuts: searchShortcuts, + }, + }) + ); + } + + /** + * Return the TopSites that are visible based on prefs and window width. + */ + _getVisibleTopSites() { + // We hide 2 sites per row when not in the wide layout. + let sitesPerRow = TOP_SITES_MAX_SITES_PER_ROW; + // $break-point-widest = 1072px (from _variables.scss) + if (!global.matchMedia(`(min-width: 1072px)`).matches) { + sitesPerRow -= 2; + } + return this.props.TopSites.rows.slice( + 0, + this.props.TopSitesRows * sitesPerRow + ); + } + + componentDidUpdate() { + this._dispatchTopSitesStats(); + } + + componentDidMount() { + this._dispatchTopSitesStats(); + } + + onEditFormClose() { + this.props.dispatch( + ac.UserEvent({ + source: TOP_SITES_SOURCE, + event: "TOP_SITES_EDIT_CLOSE", + }) + ); + this.props.dispatch({ type: at.TOP_SITES_CANCEL_EDIT }); + } + + onSearchShortcutsFormClose() { + this.props.dispatch( + ac.UserEvent({ + source: TOP_SITES_SOURCE, + event: "SEARCH_EDIT_CLOSE", + }) + ); + this.props.dispatch({ type: at.TOP_SITES_CLOSE_SEARCH_SHORTCUTS_MODAL }); + } + + render() { + const { props } = this; + const { editForm, showSearchShortcutsForm } = props.TopSites; + const extraMenuOptions = ["AddTopSite"]; + const colors = props.Prefs.values["newNewtabExperience.colors"]; + + if (props.Prefs.values["improvesearch.topSiteSearchShortcuts"]) { + extraMenuOptions.push("AddSearchShortcut"); + } + + return ( + <ComponentPerfTimer + id="topsites" + initialized={props.TopSites.initialized} + dispatch={props.dispatch} + > + <CollapsibleSection + className="top-sites" + id="topsites" + title={props.title || { id: "newtab-section-header-topsites" }} + hideTitle={true} + extraMenuOptions={extraMenuOptions} + showPrefName="feeds.topsites" + eventSource={TOP_SITES_SOURCE} + collapsed={false} + isFixed={props.isFixed} + isFirst={props.isFirst} + isLast={props.isLast} + dispatch={props.dispatch} + > + <TopSiteList + TopSites={props.TopSites} + TopSitesRows={props.TopSitesRows} + dispatch={props.dispatch} + topSiteIconType={topSiteIconType} + colors={colors} + /> + <div className="edit-topsites-wrapper"> + {editForm && ( + <div className="edit-topsites"> + <ModalOverlayWrapper + unstyled={true} + onClose={this.onEditFormClose} + innerClassName="modal" + > + <TopSiteForm + site={props.TopSites.rows[editForm.index]} + onClose={this.onEditFormClose} + dispatch={this.props.dispatch} + {...editForm} + /> + </ModalOverlayWrapper> + </div> + )} + {showSearchShortcutsForm && ( + <div className="edit-search-shortcuts"> + <ModalOverlayWrapper + unstyled={true} + onClose={this.onSearchShortcutsFormClose} + innerClassName="modal" + > + <SearchShortcutsForm + TopSites={props.TopSites} + onClose={this.onSearchShortcutsFormClose} + dispatch={this.props.dispatch} + /> + </ModalOverlayWrapper> + </div> + )} + </div> + </CollapsibleSection> + </ComponentPerfTimer> + ); + } +} + +export const TopSites = connect((state, props) => ({ + TopSites: state.TopSites, + Prefs: state.Prefs, + TopSitesRows: state.Prefs.values.topSitesRows, +}))(_TopSites); diff --git a/browser/components/newtab/content-src/components/TopSites/TopSitesConstants.js b/browser/components/newtab/content-src/components/TopSites/TopSitesConstants.js new file mode 100644 index 0000000000..f488896238 --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/TopSitesConstants.js @@ -0,0 +1,39 @@ +/* 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/. */ + +export const TOP_SITES_SOURCE = "TOP_SITES"; +export const TOP_SITES_CONTEXT_MENU_OPTIONS = [ + "CheckPinTopSite", + "EditTopSite", + "Separator", + "OpenInNewWindow", + "OpenInPrivateWindow", + "Separator", + "BlockUrl", + "DeleteUrl", +]; +export const TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS = [ + "OpenInNewWindow", + "OpenInPrivateWindow", + "Separator", + "BlockUrl", + "ShowPrivacyInfo", +]; +export const TOP_SITES_SPONSORED_POSITION_CONTEXT_MENU_OPTIONS = [ + "OpenInNewWindow", + "OpenInPrivateWindow", + "Separator", + "BlockUrl", + "AboutSponsored", +]; +// the special top site for search shortcut experiment can only have the option to unpin (which removes) the topsite +export const TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS = [ + "CheckPinTopSite", + "Separator", + "BlockUrl", +]; +// minimum size necessary to show a rich icon instead of a screenshot +export const MIN_RICH_FAVICON_SIZE = 96; +// minimum size necessary to show any icon +export const MIN_SMALL_FAVICON_SIZE = 16; diff --git a/browser/components/newtab/content-src/components/TopSites/_TopSites.scss b/browser/components/newtab/content-src/components/TopSites/_TopSites.scss new file mode 100644 index 0000000000..b893b6b33e --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/_TopSites.scss @@ -0,0 +1,628 @@ +@use 'sass:math'; + +$top-sites-size: $grid-unit-small; +$top-sites-border-radius: 8px; +$top-sites-icon-border-radius: 4px; +$rich-icon-size: 96px; +$default-icon-wrapper-size: 32px; +$default-icon-size: 32px; +$default-icon-offset: 6px; +$half-base-gutter: math.div($base-gutter, 2); +$hover-transition-duration: 150ms; +$letter-fallback-color: $white; + +.top-sites-list { + list-style: none; + margin: 0 (-$half-base-gutter); + padding: 0; + + a { + text-decoration: none; + } + + &:not(.dnd-active) { + .top-site-outer:is(.active, :focus, :hover) { + background: var(--newtab-element-hover-color); + } + } + + // Two columns + @media (max-width: $break-point-medium) { + > :nth-child(2n+1) { + @include context-menu-open-middle; + } + + > :nth-child(2n) { + @include context-menu-open-left; + } + } + + // Four columns + @media (min-width: $break-point-medium) and (max-width: $break-point-large) { + :nth-child(4n) { + @include context-menu-open-left; + } + } + + @media (min-width: $break-point-medium) and (max-width: $break-point-medium + $card-width) { + :nth-child(4n+3) { + @include context-menu-open-left; + } + } + + // Six columns + @media (min-width: $break-point-large) and (max-width: $break-point-large + 2 * $card-width) { + :nth-child(6n) { + @include context-menu-open-left; + } + } + + @media (min-width: $break-point-large) and (max-width: $break-point-large + $card-width) { + :nth-child(6n+5) { + @include context-menu-open-left; + } + } + + // Eight columns + @media (min-width: $break-point-widest) and (max-width: $break-point-widest + 2 * $card-width) { + :nth-child(8n) { + @include context-menu-open-left; + } + } + + @media (min-width: $break-point-widest) and (max-width: $break-point-widest + $card-width) { + :nth-child(8n+7) { + @include context-menu-open-left; + } + } + + .hide-for-narrow { + display: none; + } + + @media (min-width: $break-point-medium) { + .hide-for-narrow { + display: inline-block; + } + } + + @media (min-width: $break-point-large) { + .hide-for-narrow { + display: none; + } + } + + @media (min-width: $break-point-widest) { + .hide-for-narrow { + display: inline-block; + } + } +} + +// container for drop zone +.top-site-outer { + width: 120px; + padding: 20px $half-base-gutter 4px; + border-radius: 8px; + display: inline-block; + + // container for context menu + .top-site-inner { + position: relative; + + > a { + color: inherit; + display: block; + outline: none; + } + } + + &:is(:hover) { + .context-menu-button { + opacity: 1; + } + } + + .context-menu-button { + background-image: url('chrome://global/skin/icons/more.svg'); + border: 0; + border-radius: 4px; + cursor: pointer; + fill: var(--newtab-text-primary-color); + -moz-context-properties: fill; + height: 20px; + width: 20px; + inset-inline-end: -9px; + opacity: 0; + position: absolute; + top: -20px; + transition: opacity 200ms; + + &:is(:active, :focus) { + outline: 0; + opacity: 1; + background-color: var(--newtab-element-hover-color); + fill: var(--newtab-primary-action-background); + } + } + + .tile { + border-radius: $top-sites-border-radius; + box-shadow: $shadow-card; + background-color: var(--newtab-background-color-secondary); + justify-content: center; + margin: 0 auto; + height: $top-sites-size; + width: $top-sites-size; + cursor: pointer; + position: relative; + + // For letter fallback + align-items: center; + color: var(--newtab-text-secondary-color); + display: flex; + font-size: 32px; + font-weight: 200; + text-transform: uppercase; + + .icon-wrapper { + border-radius: 4px; + width: 48px; + height: 48px; + overflow: hidden; + position: relative; + display: flex; + align-items: center; + justify-content: center; + + &.letter-fallback::before { + content: attr(data-fallback); + text-transform: uppercase; + display: flex; + align-items: center; + justify-content: center; + font-size: 64px; + font-weight: 800; + transform: rotate(-10deg); + top: 6px; + position: relative; + color: $letter-fallback-color; + } + } + } + + // Some common styles for all icons (rich and default) in top sites + .top-site-icon { + background-color: var(--newtab-background-color-secondary); + background-position: center center; + background-repeat: no-repeat; + border-radius: $top-sites-icon-border-radius; + position: absolute; + } + + .rich-icon { + background-size: cover; + height: 100%; + inset-inline-start: 0; + top: 0; + width: 100%; + } + + .default-icon, + .search-topsite { + background-size: $default-icon-size; + height: $default-icon-wrapper-size; + width: $default-icon-wrapper-size; + + // for corner letter fallback + align-items: center; + display: flex; + font-size: 20px; + justify-content: center; + + &[data-fallback]::before { + content: attr(data-fallback); + } + } + + .search-topsite { + background-image: url('chrome://global/skin/icons/search-glass.svg'); + background-size: 16px; + background-color: var(--newtab-primary-action-background); + border-radius: $default-icon-wrapper-size; + -moz-context-properties: fill; + fill: var(--newtab-primary-element-text-color); + box-shadow: $shadow-card; + transition-duration: $hover-transition-duration; + transition-property: background-size, bottom, inset-inline-end, height, width; + height: 32px; + width: 32px; + bottom: -$default-icon-offset; + inset-inline-end: -$default-icon-offset; + } + + &.placeholder { + .tile { + box-shadow: $inner-box-shadow; + } + } + + .title { + color: var(--newtab-text-primary-color); + padding-top: 8px; + font: caption; + text-align: center; + position: relative; + + .icon { + margin-inline-end: 2px; + fill: var(--newtab-text-primary-color); + } + + span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .sponsored-label { + color: var(--newtab-text-secondary-color); + font-size: 0.9em; + } + + &:not(.sponsored) .sponsored-label { + visibility: hidden; + } + } + + // We want all search shortcuts to have a white background in case they have transparency. + &.search-shortcut { + .rich-icon { + background-color: $white; + } + } + + .edit-button { + background-image: url('chrome://global/skin/icons/edit.svg'); + } + + &.dragged { + .tile { + *, + &::before { + display: none; + } + } + + .title { + visibility: hidden; + } + } +} + +.edit-topsites-wrapper { + .top-site-inner > .top-site-button > .tile { + border: 1px solid var(--newtab-border-color); + } + + .modal { + box-shadow: $shadow-secondary; + left: 0; + margin: 0 auto; + max-height: calc(100% - 40px); + position: fixed; + right: 0; + top: 40px; + width: $wrapper-default-width; + + @media (min-width: $break-point-medium) { + width: $wrapper-max-width-medium; + } + + @media (min-width: $break-point-large) { + width: $wrapper-max-width-large; + } + } +} + +.topsite-form { + $form-width: 300px; + $form-spacing: 32px; + + .section-title { + font-size: 16px; + margin: 0 0 16px; + } + + .form-input-container { + max-width: $form-width + 3 * $form-spacing + $rich-icon-size; + margin: 0 auto; + padding: $form-spacing; + + .top-site-outer { + pointer-events: none; + } + } + + .search-shortcuts-container { + max-width: 700px; + margin: 0 auto; + padding: $form-spacing; + + > div { + margin-inline-end: -39px; + } + + .top-site-outer { + margin-inline-start: 0; + margin-inline-end: 39px; + } + } + + .top-site-outer { + padding: 0; + margin: 24px 0 0; + margin-inline-start: $form-spacing; + } + + .fields-and-preview { + display: flex; + } + + label { + font-size: $section-title-font-size; + } + + .form-wrapper { + width: 100%; + + .field { + position: relative; + + .icon-clear-input { + position: absolute; + transform: translateY(-50%); + top: 50%; + inset-inline-end: 8px; + } + } + + .url { + input:dir(ltr) { + padding-right: 32px; + } + + input:dir(rtl) { + padding-left: 32px; + + &:not(:placeholder-shown) { + direction: ltr; + text-align: right; + } + } + } + + .enable-custom-image-input { + display: inline-block; + font-size: 13px; + margin-top: 4px; + cursor: pointer; + } + + .custom-image-input-container { + margin-top: 4px; + + .loading-container { + width: 16px; + height: 16px; + overflow: hidden; + position: absolute; + transform: translateY(-50%); + top: 50%; + inset-inline-end: 8px; + } + + // This animation is derived from Firefox's tab loading animation + // See https://searchfox.org/mozilla-central/rev/b29daa46443b30612415c35be0a3c9c13b9dc5f6/browser/themes/shared/tabs.inc.css#208-216 + .loading-animation { + @keyframes tab-throbber-animation { + 100% { transform: translateX(-960px); } + } + + @keyframes tab-throbber-animation-rtl { + 100% { transform: translateX(960px); } + } + + width: 960px; + height: 16px; + -moz-context-properties: fill; + fill: var(--newtab-primary-action-background); + background-image: url('chrome://browser/skin/tabbrowser/loading.svg'); + animation: tab-throbber-animation 1.05s steps(60) infinite; + + &:dir(rtl) { + animation-name: tab-throbber-animation-rtl; + } + } + } + + input { + &[type='text'] { + background-color: var(--newtab-background-color-secondary); + border: $input-border; + margin: 8px 0; + padding: 0 8px; + height: 32px; + width: 100%; + font-size: 15px; + + &[disabled] { + border: $input-border; + box-shadow: none; + opacity: 0.4; + } + } + } + + .invalid { + input { + &[type='text'] { + border: $input-error-border; + box-shadow: $input-error-boxshadow; + } + } + } + + .error-tooltip { + animation: fade-up-tt 450ms; + background: var(--newtab-status-error); + border-radius: 2px; + color: $white; + inset-inline-start: 3px; + padding: 5px 12px; + position: absolute; + top: 44px; + z-index: 1; + + // tooltip caret + &::before { + background: var(--newtab-status-error); + bottom: -8px; + content: '.'; + height: 16px; + inset-inline-start: 12px; + position: absolute; + text-indent: -999px; + top: -7px; + transform: rotate(45deg); + white-space: nowrap; + width: 16px; + z-index: -1; + } + } + } + + .actions { + justify-content: flex-end; + + button { + margin-inline-start: 10px; + margin-inline-end: 0; + } + } + + @media (max-width: $break-point-medium) { + .fields-and-preview { + flex-direction: column; + + .top-site-outer { + margin-inline-start: 0; + } + } + } + + // prevent text selection of keyword label when clicking to select + .title { + user-select: none; + } + + // CSS styled checkbox + [type='checkbox']:not(:checked), + [type='checkbox']:checked { + inset-inline-start: -9999px; + position: absolute; + } + + [type='checkbox']:not(:checked) + label, + [type='checkbox']:checked + label { + cursor: pointer; + display: block; + position: relative; + } + + $checkbox-offset: -8px; + + [type='checkbox']:not(:checked) + label::before, + [type='checkbox']:checked + label::before { + background: var(--newtab-background-color); + border: $input-border; + border-radius: $border-radius; + content: ''; + height: 21px; + left: $checkbox-offset; + position: absolute; + top: $checkbox-offset; + width: 21px; + z-index: 1; + + [dir='rtl'] & { + left: auto; + right: $checkbox-offset; + } + } + + // checkmark + [type='checkbox']:not(:checked) + label::after, + [type='checkbox']:checked + label::after { + background: url('chrome://global/skin/icons/check.svg') no-repeat center center; + content: ''; + height: 21px; + left: $checkbox-offset; + position: absolute; + top: $checkbox-offset; + width: 21px; + -moz-context-properties: fill; + fill: var(--newtab-primary-action-background); + z-index: 2; + + [dir='rtl'] & { + left: auto; + right: $checkbox-offset; + } + } + + // when selected, highlight the tile + [type='checkbox']:checked + label { + .tile { + box-shadow: $shadow-focus; + } + } + + // checkmark changes + [type='checkbox']:not(:checked) + label::after { + opacity: 0; + } + + [type='checkbox']:checked + label::after { + opacity: 1; + } + + // accessibility + [type='checkbox']:checked:focus + label::before, + [type='checkbox']:not(:checked):focus + label::before { + border: 1px dotted var(--newtab-primary-action-background); + } +} + +// used for tooltips below form element +@keyframes fade-up-tt { + 0% { + opacity: 0; + transform: translateY(15px); + } + + 100% { + opacity: 1; + transform: translateY(0); + } +} + +// used for TopSites impression wrapper +.topsite-impression-observer { + position: absolute; + top: 0; + width: 100%; + height: 100%; + pointer-events: none; +} diff --git a/browser/components/newtab/content-src/components/Topics/Topics.jsx b/browser/components/newtab/content-src/components/Topics/Topics.jsx new file mode 100644 index 0000000000..ef59094c65 --- /dev/null +++ b/browser/components/newtab/content-src/components/Topics/Topics.jsx @@ -0,0 +1,33 @@ +/* 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 class Topic extends React.PureComponent { + render() { + const { url, name } = this.props; + return ( + <li> + <a key={name} href={url}> + {name} + </a> + </li> + ); + } +} + +export class Topics extends React.PureComponent { + render() { + const { topics } = this.props; + return ( + <span className="topics"> + <span data-l10n-id="newtab-pocket-read-more" /> + <ul> + {topics && + topics.map(t => <Topic key={t.name} url={t.url} name={t.name} />)} + </ul> + </span> + ); + } +} diff --git a/browser/components/newtab/content-src/components/Topics/_Topics.scss b/browser/components/newtab/content-src/components/Topics/_Topics.scss new file mode 100644 index 0000000000..205f42e600 --- /dev/null +++ b/browser/components/newtab/content-src/components/Topics/_Topics.scss @@ -0,0 +1,24 @@ +.topics { + ul { + margin: 0; + padding: 0; + + @media (min-width: $break-point-large) { + display: inline; + padding-inline-start: 12px; + } + } + + ul li { + display: inline-block; + + &::after { + content: '•'; + padding: 8px; + } + + &:last-child::after { + content: none; + } + } +} diff --git a/browser/components/newtab/content-src/lib/aboutwelcome-utils.js b/browser/components/newtab/content-src/lib/aboutwelcome-utils.js new file mode 100644 index 0000000000..840aee0f19 --- /dev/null +++ b/browser/components/newtab/content-src/lib/aboutwelcome-utils.js @@ -0,0 +1,119 @@ +/* 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/. */ + +// If the container has a "page" data attribute, then this is +// a Spotlight modal or Feature Callout. Otherwise, this is +// about:welcome and we should return the current page. +const page = + document.querySelector( + "#multi-stage-message-root.onboardingContainer[data-page]" + )?.dataset.page || document.location.href; + +export const AboutWelcomeUtils = { + handleUserAction(action) { + return window.AWSendToParent("SPECIAL_ACTION", action); + }, + sendImpressionTelemetry(messageId, context) { + window.AWSendEventTelemetry?.({ + event: "IMPRESSION", + event_context: { + ...context, + page, + }, + message_id: messageId, + }); + }, + sendActionTelemetry(messageId, elementId, eventName = "CLICK_BUTTON") { + const ping = { + event: eventName, + event_context: { + source: elementId, + page, + }, + message_id: messageId, + }; + window.AWSendEventTelemetry?.(ping); + }, + sendDismissTelemetry(messageId, elementId) { + // Don't send DISMISS telemetry in spotlight modals since they already send + // their own equivalent telemetry. + if (page !== "spotlight") { + this.sendActionTelemetry(messageId, elementId, "DISMISS"); + } + }, + async fetchFlowParams(metricsFlowUri) { + let flowParams; + try { + const response = await fetch(metricsFlowUri, { + credentials: "omit", + }); + if (response.status === 200) { + const { deviceId, flowId, flowBeginTime } = await response.json(); + flowParams = { deviceId, flowId, flowBeginTime }; + } else { + console.error("Non-200 response", response); + } + } catch (e) { + flowParams = null; + } + return flowParams; + }, + sendEvent(type, detail) { + document.dispatchEvent( + new CustomEvent(`AWPage:${type}`, { + bubbles: true, + detail, + }) + ); + }, +}; + +export const DEFAULT_RTAMO_CONTENT = { + template: "return_to_amo", + utm_term: "rtamo", + content: { + position: "split", + title: { string_id: "mr1-return-to-amo-subtitle" }, + has_noodles: false, + subtitle: { + string_id: "mr1-return-to-amo-addon-title", + }, + backdrop: + "var(--mr-welcome-background-color) var(--mr-welcome-background-gradient)", + background: + "url('chrome://activity-stream/content/data/content/assets/mr-rtamo-background-image.svg') no-repeat center", + progress_bar: true, + primary_button: { + label: { string_id: "mr1-return-to-amo-add-extension-label" }, + source_id: "ADD_EXTENSION_BUTTON", + action: { + type: "INSTALL_ADDON_FROM_URL", + data: { url: null, telemetrySource: "rtamo" }, + }, + }, + secondary_button: { + label: { + string_id: "onboarding-not-now-button-label", + }, + source_id: "RTAMO_START_BROWSING_BUTTON", + action: { + type: "OPEN_AWESOME_BAR", + }, + }, + secondary_button_top: { + label: { + string_id: "mr1-onboarding-sign-in-button-label", + }, + source_id: "RTAMO_FXA_SIGNIN_BUTTON", + action: { + data: { + entrypoint: "activity-stream-firstrun", + where: "tab", + }, + type: "SHOW_FIREFOX_ACCOUNTS", + addFlowParams: true, + }, + }, + }, +}; diff --git a/browser/components/newtab/content-src/lib/constants.js b/browser/components/newtab/content-src/lib/constants.js new file mode 100644 index 0000000000..2c96160b4b --- /dev/null +++ b/browser/components/newtab/content-src/lib/constants.js @@ -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/. */ + +export const IS_NEWTAB = + global.document && global.document.documentURI === "about:newtab"; +export const NEWTAB_DARK_THEME = { + ntp_background: { + r: 42, + g: 42, + b: 46, + a: 1, + }, + ntp_card_background: { + r: 66, + g: 65, + b: 77, + a: 1, + }, + ntp_text: { + r: 249, + g: 249, + b: 250, + a: 1, + }, + sidebar: { + r: 56, + g: 56, + b: 61, + a: 1, + }, + sidebar_text: { + r: 249, + g: 249, + b: 250, + a: 1, + }, +}; diff --git a/browser/components/newtab/content-src/lib/detect-user-session-start.js b/browser/components/newtab/content-src/lib/detect-user-session-start.js new file mode 100644 index 0000000000..43aa388967 --- /dev/null +++ b/browser/components/newtab/content-src/lib/detect-user-session-start.js @@ -0,0 +1,82 @@ +/* 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 { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { perfService as perfSvc } from "content-src/lib/perf-service"; + +const VISIBLE = "visible"; +const VISIBILITY_CHANGE_EVENT = "visibilitychange"; + +export class DetectUserSessionStart { + constructor(store, options = {}) { + this._store = store; + // Overrides for testing + this.document = options.document || global.document; + this._perfService = options.perfService || perfSvc; + this._onVisibilityChange = this._onVisibilityChange.bind(this); + } + + /** + * sendEventOrAddListener - Notify immediately if the page is already visible, + * or else set up a listener for when visibility changes. + * This is needed for accurate session tracking for telemetry, + * because tabs are pre-loaded. + */ + sendEventOrAddListener() { + if (this.document.visibilityState === VISIBLE) { + // If the document is already visible, to the user, send a notification + // immediately that a session has started. + this._sendEvent(); + } else { + // If the document is not visible, listen for when it does become visible. + this.document.addEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + /** + * _sendEvent - Sends a message to the main process to indicate the current + * tab is now visible to the user, includes the + * visibility_event_rcvd_ts time in ms from the UNIX epoch. + */ + _sendEvent() { + this._perfService.mark("visibility_event_rcvd_ts"); + + try { + let visibility_event_rcvd_ts = + this._perfService.getMostRecentAbsMarkStartByName( + "visibility_event_rcvd_ts" + ); + + this._store.dispatch( + ac.AlsoToMain({ + type: at.SAVE_SESSION_PERF_DATA, + data: { visibility_event_rcvd_ts }, + }) + ); + } catch (ex) { + // If this failed, it's likely because the `privacy.resistFingerprinting` + // pref is true. We should at least not blow up. + } + } + + /** + * _onVisibilityChange - If the visibility has changed to visible, sends a notification + * and removes the event listener. This should only be called once per tab. + */ + _onVisibilityChange() { + if (this.document.visibilityState === VISIBLE) { + this._sendEvent(); + this.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } +} diff --git a/browser/components/newtab/content-src/lib/init-store.js b/browser/components/newtab/content-src/lib/init-store.js new file mode 100644 index 0000000000..77ae5363bf --- /dev/null +++ b/browser/components/newtab/content-src/lib/init-store.js @@ -0,0 +1,175 @@ +/* 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/. */ + +/* eslint-env mozilla/remote-page */ + +import { + actionCreators as ac, + actionTypes as at, + actionUtils as au, +} from "common/Actions.sys.mjs"; +import { applyMiddleware, combineReducers, createStore } from "redux"; + +export const MERGE_STORE_ACTION = "NEW_TAB_INITIAL_STATE"; +export const OUTGOING_MESSAGE_NAME = "ActivityStream:ContentToMain"; +export const INCOMING_MESSAGE_NAME = "ActivityStream:MainToContent"; +export const EARLY_QUEUED_ACTIONS = [at.SAVE_SESSION_PERF_DATA]; + +/** + * A higher-order function which returns a reducer that, on MERGE_STORE action, + * will return the action.data object merged into the previous state. + * + * For all other actions, it merely calls mainReducer. + * + * Because we want this to merge the entire state object, it's written as a + * higher order function which takes the main reducer (itself often a call to + * combineReducers) as a parameter. + * + * @param {function} mainReducer reducer to call if action != MERGE_STORE_ACTION + * @return {function} a reducer that, on MERGE_STORE_ACTION action, + * will return the action.data object merged + * into the previous state, and the result + * of calling mainReducer otherwise. + */ +function mergeStateReducer(mainReducer) { + return (prevState, action) => { + if (action.type === MERGE_STORE_ACTION) { + return { ...prevState, ...action.data }; + } + + return mainReducer(prevState, action); + }; +} + +/** + * messageMiddleware - Middleware that looks for SentToMain type actions, and sends them if necessary + */ +const messageMiddleware = store => next => action => { + const skipLocal = action.meta && action.meta.skipLocal; + if (au.isSendToMain(action)) { + RPMSendAsyncMessage(OUTGOING_MESSAGE_NAME, action); + } + if (!skipLocal) { + next(action); + } +}; + +export const rehydrationMiddleware = ({ getState }) => { + // NB: The parameter here is MiddlewareAPI which looks like a Store and shares + // the same getState, so attached properties are accessible from the store. + getState.didRehydrate = false; + getState.didRequestInitialState = false; + return next => action => { + if (getState.didRehydrate || window.__FROM_STARTUP_CACHE__) { + // Startup messages can be safely ignored by the about:home document + // stored in the startup cache. + if ( + window.__FROM_STARTUP_CACHE__ && + action.meta && + action.meta.isStartup + ) { + return null; + } + return next(action); + } + + const isMergeStoreAction = action.type === MERGE_STORE_ACTION; + const isRehydrationRequest = action.type === at.NEW_TAB_STATE_REQUEST; + + if (isRehydrationRequest) { + getState.didRequestInitialState = true; + return next(action); + } + + if (isMergeStoreAction) { + getState.didRehydrate = true; + return next(action); + } + + // If init happened after our request was made, we need to re-request + if (getState.didRequestInitialState && action.type === at.INIT) { + return next(ac.AlsoToMain({ type: at.NEW_TAB_STATE_REQUEST })); + } + + if ( + au.isBroadcastToContent(action) || + au.isSendToOneContent(action) || + au.isSendToPreloaded(action) + ) { + // Note that actions received before didRehydrate will not be dispatched + // because this could negatively affect preloading and the the state + // will be replaced by rehydration anyway. + return null; + } + + return next(action); + }; +}; + +/** + * This middleware queues up all the EARLY_QUEUED_ACTIONS until it receives + * the first action from main. This is useful for those actions for main which + * require higher reliability, i.e. the action will not be lost in the case + * that it gets sent before the main is ready to receive it. Conversely, any + * actions allowed early are accepted to be ignorable or re-sendable. + */ +export const queueEarlyMessageMiddleware = ({ getState }) => { + // NB: The parameter here is MiddlewareAPI which looks like a Store and shares + // the same getState, so attached properties are accessible from the store. + getState.earlyActionQueue = []; + getState.receivedFromMain = false; + return next => action => { + if (getState.receivedFromMain) { + next(action); + } else if (au.isFromMain(action)) { + next(action); + getState.receivedFromMain = true; + // Sending out all the early actions as main is ready now + getState.earlyActionQueue.forEach(next); + getState.earlyActionQueue.length = 0; + } else if (EARLY_QUEUED_ACTIONS.includes(action.type)) { + getState.earlyActionQueue.push(action); + } else { + // Let any other type of action go through + next(action); + } + }; +}; + +/** + * initStore - Create a store and listen for incoming actions + * + * @param {object} reducers An object containing Redux reducers + * @param {object} intialState (optional) The initial state of the store, if desired + * @return {object} A redux store + */ +export function initStore(reducers, initialState) { + const store = createStore( + mergeStateReducer(combineReducers(reducers)), + initialState, + global.RPMAddMessageListener && + applyMiddleware( + queueEarlyMessageMiddleware, + rehydrationMiddleware, + messageMiddleware + ) + ); + + if (global.RPMAddMessageListener) { + global.RPMAddMessageListener(INCOMING_MESSAGE_NAME, msg => { + try { + store.dispatch(msg.data); + } catch (ex) { + console.error("Content msg:", msg, "Dispatch error: ", ex); + dump( + `Content msg: ${JSON.stringify(msg)}\nDispatch error: ${ex}\n${ + ex.stack + }` + ); + } + }); + } + + return store; +} diff --git a/browser/components/newtab/content-src/lib/link-menu-options.js b/browser/components/newtab/content-src/lib/link-menu-options.js new file mode 100644 index 0000000000..792522b52d --- /dev/null +++ b/browser/components/newtab/content-src/lib/link-menu-options.js @@ -0,0 +1,292 @@ +/* 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 { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; + +const _OpenInPrivateWindow = site => ({ + id: "newtab-menu-open-new-private-window", + icon: "new-window-private", + action: ac.OnlyToMain({ + type: at.OPEN_PRIVATE_WINDOW, + data: { url: site.url, referrer: site.referrer }, + }), + userEvent: "OPEN_PRIVATE_WINDOW", +}); + +/** + * List of functions that return items that can be included as menu options in a + * LinkMenu. All functions take the site as the first parameter, and optionally + * the index of the site. + */ +export const LinkMenuOptions = { + Separator: () => ({ type: "separator" }), + EmptyItem: () => ({ type: "empty" }), + ShowPrivacyInfo: site => ({ + id: "newtab-menu-show-privacy-info", + icon: "info", + action: { + type: at.SHOW_PRIVACY_INFO, + }, + userEvent: "SHOW_PRIVACY_INFO", + }), + AboutSponsored: site => ({ + id: "newtab-menu-show-privacy-info", + icon: "info", + action: ac.AlsoToMain({ + type: at.ABOUT_SPONSORED_TOP_SITES, + }), + userEvent: "TOPSITE_SPONSOR_INFO", + }), + RemoveBookmark: site => ({ + id: "newtab-menu-remove-bookmark", + icon: "bookmark-added", + action: ac.AlsoToMain({ + type: at.DELETE_BOOKMARK_BY_ID, + data: site.bookmarkGuid, + }), + userEvent: "BOOKMARK_DELETE", + }), + AddBookmark: site => ({ + id: "newtab-menu-bookmark", + icon: "bookmark-hollow", + action: ac.AlsoToMain({ + type: at.BOOKMARK_URL, + data: { url: site.url, title: site.title, type: site.type }, + }), + userEvent: "BOOKMARK_ADD", + }), + OpenInNewWindow: site => ({ + id: "newtab-menu-open-new-window", + icon: "new-window", + action: ac.AlsoToMain({ + type: at.OPEN_NEW_WINDOW, + data: { + referrer: site.referrer, + typedBonus: site.typedBonus, + url: site.url, + sponsored_tile_id: site.sponsored_tile_id, + }, + }), + userEvent: "OPEN_NEW_WINDOW", + }), + // This blocks the url for regular stories, + // but also sends a message to DiscoveryStream with flight_id. + // If DiscoveryStream sees this message for a flight_id + // it also blocks it on the flight_id. + BlockUrl: (site, index, eventSource) => { + return LinkMenuOptions.BlockUrls([site], index, eventSource); + }, + // Same as BlockUrl, cept can work on an array of sites. + BlockUrls: (tiles, pos, eventSource) => ({ + id: "newtab-menu-dismiss", + icon: "dismiss", + action: ac.AlsoToMain({ + type: at.BLOCK_URL, + data: tiles.map(site => ({ + url: site.original_url || site.open_url || site.url, + // pocket_id is only for pocket stories being in highlights, and then dismissed. + pocket_id: site.pocket_id, + // used by PlacesFeed and TopSitesFeed for sponsored top sites blocking. + isSponsoredTopSite: site.sponsored_position, + ...(site.flight_id ? { flight_id: site.flight_id } : {}), + })), + }), + impression: ac.ImpressionStats({ + source: eventSource, + block: 0, + tiles: tiles.map((site, index) => ({ + id: site.guid, + pos: pos + index, + ...(site.shim && site.shim.delete ? { shim: site.shim.delete } : {}), + })), + }), + userEvent: "BLOCK", + }), + + // This is an option for web extentions which will result in remove items from + // memory and notify the web extenion, rather than using the built-in block list. + WebExtDismiss: (site, index, eventSource) => ({ + id: "menu_action_webext_dismiss", + string_id: "newtab-menu-dismiss", + icon: "dismiss", + action: ac.WebExtEvent(at.WEBEXT_DISMISS, { + source: eventSource, + url: site.url, + action_position: index, + }), + }), + DeleteUrl: (site, index, eventSource, isEnabled, siteInfo) => ({ + id: "newtab-menu-delete-history", + icon: "delete", + action: { + type: at.DIALOG_OPEN, + data: { + onConfirm: [ + ac.AlsoToMain({ + type: at.DELETE_HISTORY_URL, + data: { + url: site.url, + pocket_id: site.pocket_id, + forceBlock: site.bookmarkGuid, + }, + }), + ac.UserEvent( + Object.assign( + { event: "DELETE", source: eventSource, action_position: index }, + siteInfo + ) + ), + ], + eventSource, + body_string_id: [ + "newtab-confirm-delete-history-p1", + "newtab-confirm-delete-history-p2", + ], + confirm_button_string_id: "newtab-topsites-delete-history-button", + cancel_button_string_id: "newtab-topsites-cancel-button", + icon: "modal-delete", + }, + }, + userEvent: "DIALOG_OPEN", + }), + ShowFile: site => ({ + id: "newtab-menu-show-file", + icon: "search", + action: ac.OnlyToMain({ + type: at.SHOW_DOWNLOAD_FILE, + data: { url: site.url }, + }), + }), + OpenFile: site => ({ + id: "newtab-menu-open-file", + icon: "open-file", + action: ac.OnlyToMain({ + type: at.OPEN_DOWNLOAD_FILE, + data: { url: site.url }, + }), + }), + CopyDownloadLink: site => ({ + id: "newtab-menu-copy-download-link", + icon: "copy", + action: ac.OnlyToMain({ + type: at.COPY_DOWNLOAD_LINK, + data: { url: site.url }, + }), + }), + GoToDownloadPage: site => ({ + id: "newtab-menu-go-to-download-page", + icon: "download", + action: ac.OnlyToMain({ + type: at.OPEN_LINK, + data: { url: site.referrer }, + }), + disabled: !site.referrer, + }), + RemoveDownload: site => ({ + id: "newtab-menu-remove-download", + icon: "delete", + action: ac.OnlyToMain({ + type: at.REMOVE_DOWNLOAD_FILE, + data: { url: site.url }, + }), + }), + PinTopSite: (site, index) => ({ + id: "newtab-menu-pin", + icon: "pin", + action: ac.AlsoToMain({ + type: at.TOP_SITES_PIN, + data: { + site, + index, + }, + }), + userEvent: "PIN", + }), + UnpinTopSite: site => ({ + id: "newtab-menu-unpin", + icon: "unpin", + action: ac.AlsoToMain({ + type: at.TOP_SITES_UNPIN, + data: { site: { url: site.url } }, + }), + userEvent: "UNPIN", + }), + SaveToPocket: (site, index, eventSource = "CARDGRID") => ({ + id: "newtab-menu-save-to-pocket", + icon: "pocket-save", + action: ac.AlsoToMain({ + type: at.SAVE_TO_POCKET, + data: { + site: { url: site.url, title: site.title }, + }, + }), + impression: ac.ImpressionStats({ + source: eventSource, + pocket: 0, + tiles: [ + { + id: site.guid, + pos: index, + ...(site.shim && site.shim.save ? { shim: site.shim.save } : {}), + }, + ], + }), + userEvent: "SAVE_TO_POCKET", + }), + DeleteFromPocket: site => ({ + id: "newtab-menu-delete-pocket", + icon: "pocket-delete", + action: ac.AlsoToMain({ + type: at.DELETE_FROM_POCKET, + data: { pocket_id: site.pocket_id }, + }), + userEvent: "DELETE_FROM_POCKET", + }), + ArchiveFromPocket: site => ({ + id: "newtab-menu-archive-pocket", + icon: "pocket-archive", + action: ac.AlsoToMain({ + type: at.ARCHIVE_FROM_POCKET, + data: { pocket_id: site.pocket_id }, + }), + userEvent: "ARCHIVE_FROM_POCKET", + }), + EditTopSite: (site, index) => ({ + id: "newtab-menu-edit-topsites", + icon: "edit", + action: { + type: at.TOP_SITES_EDIT, + data: { index }, + }, + }), + CheckBookmark: site => + site.bookmarkGuid + ? LinkMenuOptions.RemoveBookmark(site) + : LinkMenuOptions.AddBookmark(site), + CheckPinTopSite: (site, index) => + site.isPinned + ? LinkMenuOptions.UnpinTopSite(site) + : LinkMenuOptions.PinTopSite(site, index), + CheckSavedToPocket: (site, index, source) => + site.pocket_id + ? LinkMenuOptions.DeleteFromPocket(site) + : LinkMenuOptions.SaveToPocket(site, index, source), + CheckBookmarkOrArchive: site => + site.pocket_id + ? LinkMenuOptions.ArchiveFromPocket(site) + : LinkMenuOptions.CheckBookmark(site), + CheckArchiveFromPocket: site => + site.pocket_id + ? LinkMenuOptions.ArchiveFromPocket(site) + : LinkMenuOptions.EmptyItem(), + CheckDeleteFromPocket: site => + site.pocket_id + ? LinkMenuOptions.DeleteFromPocket(site) + : LinkMenuOptions.EmptyItem(), + OpenInPrivateWindow: (site, index, eventSource, isEnabled) => + isEnabled ? _OpenInPrivateWindow(site) : LinkMenuOptions.EmptyItem(), +}; diff --git a/browser/components/newtab/content-src/lib/perf-service.js b/browser/components/newtab/content-src/lib/perf-service.js new file mode 100644 index 0000000000..6ea99ce877 --- /dev/null +++ b/browser/components/newtab/content-src/lib/perf-service.js @@ -0,0 +1,104 @@ +/* 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 strict"; + +let usablePerfObj = window.performance; + +export function _PerfService(options) { + // For testing, so that we can use a fake Window.performance object with + // known state. + if (options && options.performanceObj) { + this._perf = options.performanceObj; + } else { + this._perf = usablePerfObj; + } +} + +_PerfService.prototype = { + /** + * Calls the underlying mark() method on the appropriate Window.performance + * object to add a mark with the given name to the appropriate performance + * timeline. + * + * @param {String} name the name to give the current mark + * @return {void} + */ + mark: function mark(str) { + this._perf.mark(str); + }, + + /** + * Calls the underlying getEntriesByName on the appropriate Window.performance + * object. + * + * @param {String} name + * @param {String} type eg "mark" + * @return {Array} Performance* objects + */ + getEntriesByName: function getEntriesByName(name, type) { + return this._perf.getEntriesByName(name, type); + }, + + /** + * The timeOrigin property from the appropriate performance object. + * Used to ensure that timestamps from the add-on code and the content code + * are comparable. + * + * @note If this is called from a context without a window + * (eg a JSM in chrome), it will return the timeOrigin of the XUL hidden + * window, which appears to be the first created window (and thus + * timeOrigin) in the browser. Note also, however, there is also a private + * hidden window, presumably for private browsing, which appears to be + * created dynamically later. Exactly how/when that shows up needs to be + * investigated. + * + * @return {Number} A double of milliseconds with a precision of 0.5us. + */ + get timeOrigin() { + return this._perf.timeOrigin; + }, + + /** + * Returns the "absolute" version of performance.now(), i.e. one that + * should ([bug 1401406](https://bugzilla.mozilla.org/show_bug.cgi?id=1401406) + * be comparable across both chrome and content. + * + * @return {Number} + */ + absNow: function absNow() { + return this.timeOrigin + this._perf.now(); + }, + + /** + * This returns the absolute startTime from the most recent performance.mark() + * with the given name. + * + * @param {String} name the name to lookup the start time for + * + * @return {Number} the returned start time, as a DOMHighResTimeStamp + * + * @throws {Error} "No Marks with the name ..." if none are available + * + * @note Always surround calls to this by try/catch. Otherwise your code + * may fail when the `privacy.resistFingerprinting` pref is true. When + * this pref is set, all attempts to get marks will likely fail, which will + * cause this method to throw. + * + * See [bug 1369303](https://bugzilla.mozilla.org/show_bug.cgi?id=1369303) + * for more info. + */ + getMostRecentAbsMarkStartByName(name) { + let entries = this.getEntriesByName(name, "mark"); + + if (!entries.length) { + throw new Error(`No marks with the name ${name}`); + } + + let mostRecentEntry = entries[entries.length - 1]; + return this._perf.timeOrigin + mostRecentEntry.startTime; + }, +}; + +export const perfService = new _PerfService(); diff --git a/browser/components/newtab/content-src/lib/screenshot-utils.js b/browser/components/newtab/content-src/lib/screenshot-utils.js new file mode 100644 index 0000000000..7ea93f12ae --- /dev/null +++ b/browser/components/newtab/content-src/lib/screenshot-utils.js @@ -0,0 +1,61 @@ +/* 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/. */ + +/** + * List of helper functions for screenshot-based images. + * + * There are two kinds of images: + * 1. Remote Image: This is the image from the main process and it refers to + * the image in the React props. This can either be an object with the `data` + * and `path` properties, if it is a blob, or a string, if it is a normal image. + * 2. Local Image: This is the image object in the content process and it refers + * to the image *object* in the React component's state. All local image + * objects have the `url` property, and an additional property `path`, if they + * are blobs. + */ +export const ScreenshotUtils = { + isBlob(isLocal, image) { + return !!( + image && + image.path && + ((!isLocal && image.data) || (isLocal && image.url)) + ); + }, + + // This should always be called with a remote image and not a local image. + createLocalImageObject(remoteImage) { + if (!remoteImage) { + return null; + } + if (this.isBlob(false, remoteImage)) { + return { + url: global.URL.createObjectURL(remoteImage.data), + path: remoteImage.path, + }; + } + return { url: remoteImage }; + }, + + // Revokes the object URL of the image if the local image is a blob. + // This should always be called with a local image and not a remote image. + maybeRevokeBlobObjectURL(localImage) { + if (this.isBlob(true, localImage)) { + global.URL.revokeObjectURL(localImage.url); + } + }, + + // Checks if remoteImage and localImage are the same. + isRemoteImageLocal(localImage, remoteImage) { + // Both remoteImage and localImage are present. + if (remoteImage && localImage) { + return this.isBlob(false, remoteImage) + ? localImage.path === remoteImage.path + : localImage.url === remoteImage; + } + + // This will only handle the remaining three possible outcomes. + // (i.e. everything except when both image and localImage are present) + return !remoteImage && !localImage; + }, +}; diff --git a/browser/components/newtab/content-src/lib/selectLayoutRender.js b/browser/components/newtab/content-src/lib/selectLayoutRender.js new file mode 100644 index 0000000000..aa3eb927d2 --- /dev/null +++ b/browser/components/newtab/content-src/lib/selectLayoutRender.js @@ -0,0 +1,255 @@ +/* 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/. */ + +export const selectLayoutRender = ({ state = {}, prefs = {}, locale = "" }) => { + const { layout, feeds, spocs } = state; + let spocIndexPlacementMap = {}; + + /* This function fills spoc positions on a per placement basis with available spocs. + * It does this by looping through each position for a placement and replacing a rec with a spoc. + * If it runs out of spocs or positions, it stops. + * If it sees the same placement again, it remembers the previous spoc index, and continues. + * If it sees a blocked spoc, it skips that position leaving in a regular story. + */ + function fillSpocPositionsForPlacement( + data, + spocsConfig, + spocsData, + placementName + ) { + if ( + !spocIndexPlacementMap[placementName] && + spocIndexPlacementMap[placementName] !== 0 + ) { + spocIndexPlacementMap[placementName] = 0; + } + const results = [...data]; + for (let position of spocsConfig.positions) { + const spoc = spocsData[spocIndexPlacementMap[placementName]]; + // If there are no spocs left, we can stop filling positions. + if (!spoc) { + break; + } + + // A placement could be used in two sections. + // In these cases, we want to maintain the index of the previous section. + // If we didn't do this, it might duplicate spocs. + spocIndexPlacementMap[placementName]++; + + // A spoc that's blocked is removed from the source for subsequent newtab loads. + // If we have a spoc in the source that's blocked, it means it was *just* blocked, + // and in this case, we skip this position, and show a regular spoc instead. + if (!spocs.blocked.includes(spoc.url)) { + results.splice(position.index, 0, spoc); + } + } + + return results; + } + + const positions = {}; + const DS_COMPONENTS = [ + "Message", + "TextPromo", + "SectionTitle", + "Signup", + "Navigation", + "CardGrid", + "CollectionCardGrid", + "HorizontalRule", + "PrivacyLink", + ]; + + const filterArray = []; + + if (!prefs["feeds.topsites"]) { + filterArray.push("TopSites"); + } + + const pocketEnabled = + prefs["feeds.section.topstories"] && prefs["feeds.system.topstories"]; + if (!pocketEnabled) { + filterArray.push(...DS_COMPONENTS); + } + + const placeholderComponent = component => { + if (!component.feed) { + // TODO we now need a placeholder for topsites and textPromo. + return { + ...component, + data: { + spocs: [], + }, + }; + } + const data = { + recommendations: [], + }; + + let items = 0; + if (component.properties && component.properties.items) { + items = component.properties.items; + } + for (let i = 0; i < items; i++) { + data.recommendations.push({ placeholder: true }); + } + + return { ...component, data }; + }; + + // TODO update devtools to show placements + const handleSpocs = (data, component) => { + let result = [...data]; + // Do we ever expect to possibly have a spoc. + if ( + component.spocs && + component.spocs.positions && + component.spocs.positions.length + ) { + const placement = component.placement || {}; + const placementName = placement.name || "spocs"; + const spocsData = spocs.data[placementName]; + // We expect a spoc, spocs are loaded, and the server returned spocs. + if ( + spocs.loaded && + spocsData && + spocsData.items && + spocsData.items.length + ) { + result = fillSpocPositionsForPlacement( + result, + component.spocs, + spocsData.items, + placementName + ); + } + } + return result; + }; + + const handleComponent = component => { + if ( + component.spocs && + component.spocs.positions && + component.spocs.positions.length + ) { + const placement = component.placement || {}; + const placementName = placement.name || "spocs"; + const spocsData = spocs.data[placementName]; + if ( + spocs.loaded && + spocsData && + spocsData.items && + spocsData.items.length + ) { + return { + ...component, + data: { + spocs: spocsData.items + .filter(spoc => spoc && !spocs.blocked.includes(spoc.url)) + .map((spoc, index) => ({ + ...spoc, + pos: index, + })), + }, + }; + } + } + return { + ...component, + data: { + spocs: [], + }, + }; + }; + + const handleComponentWithFeed = component => { + positions[component.type] = positions[component.type] || 0; + let data = { + recommendations: [], + }; + + const feed = feeds.data[component.feed.url]; + if (feed && feed.data) { + data = { + ...feed.data, + recommendations: [...(feed.data.recommendations || [])], + }; + } + + if (component && component.properties && component.properties.offset) { + data = { + ...data, + recommendations: data.recommendations.slice( + component.properties.offset + ), + }; + } + + data = { + ...data, + recommendations: handleSpocs(data.recommendations, component), + }; + + let items = 0; + if (component.properties && component.properties.items) { + items = Math.min(component.properties.items, data.recommendations.length); + } + + // loop through a component items + // Store the items position sequentially for multiple components of the same type. + // Example: A second card grid starts pos offset from the last card grid. + for (let i = 0; i < items; i++) { + data.recommendations[i] = { + ...data.recommendations[i], + pos: positions[component.type]++, + }; + } + + return { ...component, data }; + }; + + const renderLayout = () => { + const renderedLayoutArray = []; + for (const row of layout.filter( + r => r.components.filter(c => !filterArray.includes(c.type)).length + )) { + let components = []; + renderedLayoutArray.push({ + ...row, + components, + }); + for (const component of row.components.filter( + c => !filterArray.includes(c.type) + )) { + const spocsConfig = component.spocs; + if (spocsConfig || component.feed) { + // TODO make sure this still works for different loading cases. + if ( + (component.feed && !feeds.data[component.feed.url]) || + (spocsConfig && + spocsConfig.positions && + spocsConfig.positions.length && + !spocs.loaded) + ) { + components.push(placeholderComponent(component)); + return renderedLayoutArray; + } + if (component.feed) { + components.push(handleComponentWithFeed(component)); + } else { + components.push(handleComponent(component)); + } + } else { + components.push(component); + } + } + } + return renderedLayoutArray; + }; + + const layoutRender = renderLayout(); + + return { layoutRender }; +}; diff --git a/browser/components/newtab/content-src/styles/_activity-stream.scss b/browser/components/newtab/content-src/styles/_activity-stream.scss new file mode 100644 index 0000000000..1261b6e916 --- /dev/null +++ b/browser/components/newtab/content-src/styles/_activity-stream.scss @@ -0,0 +1,177 @@ +@import './normalize'; +@import './variables'; +@import './theme'; +@import './icons'; +@import './mixins'; + +html { + height: 100%; +} + +body, +#root { + min-height: 100vh; +} + +#root { + position: relative; +} + +body { + background-color: var(--newtab-background-color); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Ubuntu, 'Helvetica Neue', sans-serif; + font-size: 16px; +} + +.no-scroll { + overflow: hidden; +} + +h1, +h2 { + font-weight: normal; +} + +.inner-border { + border: $border-secondary; + border-radius: $border-radius; + height: 100%; + left: 0; + pointer-events: none; + position: absolute; + top: 0; + width: 100%; + z-index: 100; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.show-on-init { + opacity: 0; + transition: opacity 0.2s ease-in; + + &.on { + animation: fadeIn 0.2s; + opacity: 1; + } +} + +.actions { + border-top: $border-secondary; + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: flex-start; + margin: 0; + padding: 15px 25px 0; +} + +// Default button (grey) +.button, +.actions button { + background-color: var(--newtab-button-secondary-color); + border: $border-primary; + border-radius: 4px; + color: inherit; + cursor: pointer; + margin-bottom: 15px; + padding: 10px 30px; + white-space: nowrap; + + &:hover:not(.dismiss), + &:focus:not(.dismiss) { + box-shadow: $shadow-primary; + transition: box-shadow 150ms; + } + + &.dismiss { + background-color: transparent; + border: 0; + padding: 0; + text-decoration: underline; + } + + // Blue button + &.primary, + &.done { + background-color: var(--newtab-primary-action-background); + border: solid 1px var(--newtab-primary-action-background); + color: var(--newtab-primary-element-text-color); + margin-inline-start: auto; + } +} + +input { + &[type='text'], + &[type='search'] { + border-radius: $border-radius; + } +} + +// These styles are needed for -webkit-line-clamp to work correctly, so reuse +// this class name while separately setting a clamp value via CSS or JS. +.clamp { + -webkit-box-orient: vertical; + display: -webkit-box; + overflow: hidden; + word-break: break-word; +} + +// Components +// stylelint-disable no-invalid-position-at-import-rule +@import '../components/A11yLinkButton/A11yLinkButton'; +@import '../components/Base/Base'; +@import '../components/ErrorBoundary/ErrorBoundary'; +@import '../components/TopSites/TopSites'; +@import '../components/Sections/Sections'; +@import '../components/Topics/Topics'; +@import '../components/Search/Search'; +@import '../components/ContextMenu/ContextMenu'; +@import '../components/ConfirmDialog/ConfirmDialog'; +@import '../components/CustomizeMenu/CustomizeMenu'; +@import '../components/Card/Card'; +@import '../components/CollapsibleSection/CollapsibleSection'; +@import '../components/ASRouterAdmin/ASRouterAdmin'; +@import '../components/PocketLoggedInCta/PocketLoggedInCta'; +@import '../components/MoreRecommendations/MoreRecommendations'; +@import '../components/DiscoveryStreamBase/DiscoveryStreamBase'; + +// Discovery Stream Components +@import '../components/DiscoveryStreamComponents/CardGrid/CardGrid'; +@import '../components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid'; +@import '../components/DiscoveryStreamComponents/Highlights/Highlights'; +@import '../components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule'; +@import '../components/DiscoveryStreamComponents/Navigation/Navigation'; +@import '../components/DiscoveryStreamComponents/SectionTitle/SectionTitle'; +@import '../components/DiscoveryStreamComponents/TopSites/TopSites'; +@import '../components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu'; +@import '../components/DiscoveryStreamComponents/DSCard/DSCard'; +@import '../components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter'; +@import '../components/DiscoveryStreamComponents/DSImage/DSImage'; +@import '../components/DiscoveryStreamComponents/DSDismiss/DSDismiss'; +@import '../components/DiscoveryStreamComponents/DSMessage/DSMessage'; +@import '../components/DiscoveryStreamImpressionStats/ImpressionStats'; +@import '../components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState'; +@import '../components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo'; +@import '../components/DiscoveryStreamComponents/DSSignup/DSSignup'; +@import '../components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal'; +@import '../components/DiscoveryStreamComponents/PrivacyLink/PrivacyLink'; +@import '../components/DiscoveryStreamComponents/TopicsWidget/TopicsWidget'; + +// AS Router +@import '../asrouter/components/Button/Button'; +@import '../asrouter/components/SnippetBase/SnippetBase'; +@import '../asrouter/components/ModalOverlay/ModalOverlay'; +@import '../asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet'; +@import '../asrouter/templates/SimpleSnippet/SimpleSnippet'; +@import '../asrouter/templates/SubmitFormSnippet/SubmitFormSnippet'; +@import '../asrouter/templates/EOYSnippet/EOYSnippet'; +// stylelint-enable no-invalid-position-at-import-rule diff --git a/browser/components/newtab/content-src/styles/_feature-callout-theme.scss b/browser/components/newtab/content-src/styles/_feature-callout-theme.scss new file mode 100644 index 0000000000..b62591f3ac --- /dev/null +++ b/browser/components/newtab/content-src/styles/_feature-callout-theme.scss @@ -0,0 +1,56 @@ +// 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/. + +// stylelint-disable color-hex-length -- we want to preserve these values +// exactly, since they're drawn from other parts of the browser. + +@mixin light-theme { + --fc-background: var(--fc-background-light, #FFF); + --fc-color: var(--fc-color-light, #15141A); + --fc-border: var(--fc-border-light, #CFCFD8); + --fc-accent-color: var(--fc-accent-color-light, #0061E0); + --fc-button-background: var(--fc-button-background-light, #F0F0F4); + --fc-button-color: var(--fc-button-color-light, #15141A); + --fc-button-border: var(--fc-button-border-light, transparent); + --fc-button-background-hover: var(--fc-button-background-hover-light, #E0E0E6); + --fc-button-color-hover: var(--fc-button-color-hover-light, #15141A); + --fc-button-border-hover: var(--fc-button-border-hover-light, transparent); + --fc-button-background-active: var(--fc-button-background-active-light, #CFCFD8); + --fc-button-color-active: var(--fc-button-color-active-light, #15141A); + --fc-button-border-active: var(--fc-button-border-active-light, transparent); + --fc-step-color: color-mix(in srgb, currentColor 50%, transparent); +} + +@mixin dark-theme { + --fc-background: var(--fc-background-dark, #42414D); + --fc-color: var(--fc-color-dark, #FBFBFE); + --fc-border: var(--fc-border-dark, #3A3944); + --fc-accent-color: var(--fc-accent-color-dark, #00DDFF); + --fc-button-background: var(--fc-button-background-dark, #2B2A33); + --fc-button-color: var(--fc-button-color-dark, #FBFBFE); + --fc-button-border: var(--fc-button-border-dark, transparent); + --fc-button-background-hover: var(--fc-button-background-hover-dark, #52525E); + --fc-button-color-hover: var(--fc-button-color-hover-dark, #FBFBFE); + --fc-button-border-hover: var(--fc-button-border-hover-dark, transparent); + --fc-button-background-active: var(--fc-button-background-active-dark, #5B5B66); + --fc-button-color-active: var(--fc-button-color-active-dark, #FBFBFE); + --fc-button-border-active: var(--fc-button-border-active-dark, transparent); +} + +@mixin hcm-theme { + --fc-background: var(--fc-background-hcm, -moz-dialog); + --fc-color: var(--fc-color-hcm, -moz-dialogtext); + --fc-border: var(--fc-border-hcm, -moz-dialogtext); + --fc-accent-color: var(--fc-accent-color-hcm, LinkText); + --fc-button-background: var(--fc-button-background-hcm, ButtonFace); + --fc-button-color: var(--fc-button-color-hcm, ButtonText); + --fc-button-border: var(--fc-button-border-hcm, ButtonText); + --fc-button-background-hover: var(--fc-button-background-hover-hcm, ButtonText); + --fc-button-color-hover: var(--fc-button-color-hover-hcm, ButtonFace); + --fc-button-border-hover: var(--fc-button-border-hover-hcm, ButtonText); + --fc-button-background-active: var(--fc-button-background-active-hcm, ButtonText); + --fc-button-color-active: var(--fc-button-color-active-hcm, ButtonFace); + --fc-button-border-active: var(--fc-button-border-active-hcm, ButtonText); + --fc-step-color: var(--fc-accent-color-hcm, LinkText); +} diff --git a/browser/components/newtab/content-src/styles/_feature-callout.scss b/browser/components/newtab/content-src/styles/_feature-callout.scss new file mode 100644 index 0000000000..aa0ba0ed1e --- /dev/null +++ b/browser/components/newtab/content-src/styles/_feature-callout.scss @@ -0,0 +1,314 @@ +// 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 'feature-callout-theme'; + +$max-z-index: 2147483647; + +.onboardingContainer.featureCallout { + // See _feature-callout-theme.scss for the theme mixins and + // FeatureCallout.sys.mjs for the color values + @include light-theme; + + position: absolute; + transition: opacity 0.5s ease; + z-index: $max-z-index - 2; + outline: none; + color: var(--fc-color); + + @media (prefers-color-scheme: dark) { + @include dark-theme; + } + + @media (prefers-contrast) { + @include hcm-theme; + } + + // Account for feature callouts that may be rendered in the chrome but + // displayed on top of content. Each context has its own color scheme, so they + // may not match. In that case, we use the special media query below. + &.simulateContent { + @media (-moz-content-prefers-color-scheme: light) { + @include light-theme; + } + + @media (-moz-content-prefers-color-scheme: dark) { + @include dark-theme; + } + + @media (prefers-contrast) { + @include hcm-theme; + } + } + + &.hidden { + opacity: 0; + pointer-events: none; + } + + &, + & .outer-wrapper { + // auto height to allow for arrow positioning based on height + height: auto; + } + + .screen { + // override transform in about:welcome + &:dir(rtl) { + transform: none; + } + + &[pos='callout'] { + height: fit-content; + min-height: unset; + overflow: visible; + + .logo-container { + display: flex; + justify-content: center; + + .brand-logo { + margin: 30px 45px 0; + + // This may not work for all future messages, so we may want to make + // flipping the logo image in RTL mode configurable + &:dir(rtl) { + transform: rotateY(180deg); + } + } + } + + .welcome-text { + align-items: baseline; + text-align: start; + margin-inline: 30px; + padding: 20px 0 0; + + h1, + h2 { + font-size: 0.8em; + margin: 0; + color: inherit; + } + + h1 { + font-weight: bold; + } + + h2 { + margin-block: 10px; + } + } + + // Secondary section is not included in callouts + .section-secondary { + display: none; + } + + .section-main { + height: fit-content; + width: fit-content; + + .main-content { + position: relative; + overflow: hidden; + border: 1px solid var(--fc-border); + box-shadow: 0 2px 6px rgba(0, 0, 0, 15%); + border-radius: 4px; + padding-top: 0; + // Subtract 4px, the block margin of buttons + padding-bottom: 30px - 4px; + width: 25em; + background-color: var(--fc-background); + + .steps { + height: auto; + position: absolute; + // 30px is the margin of the CTAs from the bottom. The CTAs are 32px + // tall, and the steps are 8px tall. So we need to offset the steps + // by half the difference in order to center them. = 36px + bottom: 30px + math.div(32px, 2) - math.div(8px, 2); + margin: 0 30px; + padding-block: 0; + + .indicator { + // using border will show up in Windows High Contrast Mode to improve accessibility. + border: 4px solid var(--fc-step-color); + + &.current { + border-color: var(--fc-accent-color); + } + } + + & .indicator.current, + &.progress-bar .indicator.complete { + border-color: var(--fc-accent-color); + } + } + } + + .dismiss-button { + font-size: 1em; + top: 0; + margin-block: 15px 0; + margin-inline: 0 15px; + z-index: $max-z-index; + background-color: var(--fc-background); + } + } + + .action-buttons { + .primary, + .secondary-cta .secondary { + padding: 4px 16px; + font-size: 0.8em; + height: 2em; + background-color: var(--fc-button-background); + } + + .primary { + font-weight: bold; + float: inline-end; + margin-inline: 10px 30px; + padding: 4px 16px; + font-size: 0.8em; + line-height: 16px; + min-height: 32px; + } + + .secondary-cta { + float: inline-end; + } + } + + .action-buttons .primary, + .action-buttons .secondary-cta .secondary, + .dismiss-button { + border-radius: 4px; + border: 1px solid var(--fc-button-border); + cursor: pointer; + color: var(--fc-button-color); + + &:hover { + background-color: var(--fc-button-background-hover); + color: var(--fc-button-color-hover); + border: 1px solid var(--fc-button-border-hover); + + &:active { + background-color: var(--fc-button-background-active); + color: var(--fc-button-color-active); + border: 1px solid var(--fc-button-border-active); + } + } + + &:focus-visible { + box-shadow: none; + outline: 2px solid var(--fc-accent-color); + outline-offset: 2px; + } + } + } + } + + $arrow-size: 24px; + $arrow-inset-size: math.div($arrow-size, 2); + // the arrow already overlaps the callout by 12px (see above), but to account + // for different pixel scaling factors, we can overlap by 1px more, since the + // border is drawn underneath the callout (on ::after). this is either added + // to or subtracted from absolute coordinates, depending on the direction. + $arrow-overlap-inset: -$arrow-inset-size + 1px; + + // applied to all callout arrow foreground and background + &.callout-arrow::before, + &.callout-arrow::after { + content: ''; + position: absolute; + width: $arrow-size; + height: $arrow-size; + transform: rotate(45deg); + // keep the border crisp under transformation + transform-style: preserve-3d; + } + + // color for all arrow foreground + &.callout-arrow::before { + z-index: $max-z-index; + background-color: var(--fc-background); + } + + // styles for all arrow backgrounds + &.callout-arrow::after { + background: transparent; + outline: 1px solid var(--fc-border); + box-shadow: 0 2px 6px rgba(0, 0, 0, 15%); + z-index: -1; + } + + // up arrow positioning + &.arrow-top::before, + &.arrow-top::after { + top: $arrow-overlap-inset; + inset-inline-start: calc(50% - $arrow-inset-size); + } + + // down arrow positioning + &.arrow-bottom::before, + &.arrow-bottom::after { + bottom: $arrow-overlap-inset; + inset-inline-start: calc(50% - $arrow-inset-size); + } + + // end arrow positioning + &.arrow-inline-end::before, + &.arrow-inline-end::after { + top: calc(50% - $arrow-inset-size); + inset-inline-end: $arrow-overlap-inset; + } + + // start arrow positioning + &.arrow-inline-start::before, + &.arrow-inline-start::after { + top: calc(50% - $arrow-inset-size); + inset-inline-start: $arrow-overlap-inset; + } + + // top-end arrow positioning + &.arrow-top-end::before, + &.arrow-top-end::after { + top: $arrow-overlap-inset; + inset-inline-end: $arrow-inset-size; + } + + // top-start arrow positioning + &.arrow-top-start::before, + &.arrow-top-start::after { + top: $arrow-overlap-inset; + inset-inline-start: $arrow-inset-size; + } + + // hidden arrow display + &.hidden-arrow::before, + &.hidden-arrow::after { + display: none; + } + + &:focus-visible { + .screen { + &[pos='callout'] { + .section-main .main-content { + outline: 2px solid var(--fc-accent-color); + border-color: transparent; + + @media (prefers-contrast) { + border-color: var(--fc-background); + } + } + } + } + + &.callout-arrow::after { + outline: 2px solid var(--fc-accent-color); + } + } +} diff --git a/browser/components/newtab/content-src/styles/_icons.scss b/browser/components/newtab/content-src/styles/_icons.scss new file mode 100644 index 0000000000..4074f0a6a6 --- /dev/null +++ b/browser/components/newtab/content-src/styles/_icons.scss @@ -0,0 +1,211 @@ +.icon { + background-position: center center; + background-repeat: no-repeat; + background-size: $icon-size; + -moz-context-properties: fill; + display: inline-block; + color: var(--newtab-text-primary-color); + fill: currentColor; + height: $icon-size; + vertical-align: middle; + width: $icon-size; + + // helper classes + &.icon-spacer { + margin-inline-end: 8px; + } + + &.icon-small-spacer { + margin-inline-end: 6px; + } + + &.icon-button-style { + fill: var(--newtab-text-secondary-color); + border: 0; + + &:focus, + &:hover { + fill: var(--newtab-text-primary-color); + } + } + + // icon images + &.icon-bookmark-added { + background-image: url('chrome://browser/skin/bookmark.svg'); + } + + &.icon-bookmark-hollow { + background-image: url('chrome://browser/skin/bookmark-hollow.svg'); + } + + &.icon-clear-input { + background-image: url('chrome://global/skin/icons/close-fill.svg'); + } + + &.icon-delete { + background-image: url('chrome://global/skin/icons/delete.svg'); + } + + &.icon-search { + background-image: url('chrome://global/skin/icons/search-glass.svg'); + } + + &.icon-modal-delete { + flex-shrink: 0; + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-modal-delete-20.svg'); + background-size: $larger-icon-size; + height: $larger-icon-size; + width: $larger-icon-size; + } + + &.icon-mail { + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-mail-16.svg'); + } + + &.icon-dismiss { + background-image: url('chrome://global/skin/icons/close.svg'); + } + + &.icon-info { + background-image: url('chrome://global/skin/icons/info.svg'); + } + + &.icon-new-window { + @include flip-icon; + + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-newWindow-16.svg'); + } + + &.icon-new-window-private { + background-image: url('chrome://browser/skin/privateBrowsing.svg'); + } + + &.icon-settings { + background-image: url('chrome://global/skin/icons/settings.svg'); + } + + &.icon-pin { + @include flip-icon; + + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-pin-16.svg'); + } + + &.icon-unpin { + @include flip-icon; + + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-unpin-16.svg'); + } + + &.icon-edit { + background-image: url('chrome://global/skin/icons/edit.svg'); + } + + &.icon-pocket { + background-image: url('chrome://global/skin/icons/pocket.svg'); + } + + &.icon-pocket-save { + background-image: url('chrome://global/skin/icons/pocket.svg'); + fill: $white; + } + + &.icon-pocket-delete { + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-pocket-delete-16.svg'); + } + + &.icon-pocket-archive { + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-pocket-archive-16.svg'); + } + + &.icon-history-item { + background-image: url('chrome://browser/skin/history.svg'); + } + + &.icon-trending { + background-image: url('chrome://browser/skin/trending.svg'); + transform: translateY(2px); // trending bolt is visually top heavy + } + + &.icon-now { + background-image: url('chrome://browser/skin/history.svg'); + } + + &.icon-topsites { + background-image: url('chrome://browser/skin/topsites.svg'); + } + + &.icon-pin-small { + @include flip-icon; + + background-image: url('chrome://browser/skin/pin-12.svg'); + background-size: $smaller-icon-size; + height: $smaller-icon-size; + width: $smaller-icon-size; + } + + &.icon-check { + background-image: url('chrome://global/skin/icons/check.svg'); + } + + &.icon-download { + background-image: url('chrome://browser/skin/downloads/downloads.svg'); + } + + &.icon-copy { + background-image: url('chrome://global/skin/icons/edit-copy.svg'); + } + + &.icon-open-file { + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-open-file-16.svg'); + } + + &.icon-webextension { + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-webextension-16.svg'); + } + + &.icon-highlights { + background-image: url('chrome://global/skin/icons/highlights.svg'); + } + + &.icon-arrowhead-down { + background-image: url('chrome://global/skin/icons/arrow-down.svg'); + } + + &.icon-arrowhead-down-small { + background-image: url('chrome://global/skin/icons/arrow-down-12.svg'); + background-size: $smaller-icon-size; + height: $smaller-icon-size; + width: $smaller-icon-size; + } + + &.icon-arrowhead-forward-small { + background-image: url('chrome://global/skin/icons/arrow-right-12.svg'); + background-size: $smaller-icon-size; + height: $smaller-icon-size; + width: $smaller-icon-size; + + &:dir(rtl) { + background-image: url('chrome://global/skin/icons/arrow-left-12.svg'); + } + } + + &.icon-arrowhead-up { + background-image: url('chrome://global/skin/icons/arrow-up.svg'); + } + + &.icon-add { + background-image: url('chrome://global/skin/icons/plus.svg'); + } + + &.icon-minimize { + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-minimize-16.svg'); + } + + &.icon-maximize { + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-maximize-16.svg'); + } + + &.icon-arrow { + background-image: url('chrome://global/skin/icons/arrow-right-12.svg'); + } +} diff --git a/browser/components/newtab/content-src/styles/_mixins.scss b/browser/components/newtab/content-src/styles/_mixins.scss new file mode 100644 index 0000000000..d2b2748a60 --- /dev/null +++ b/browser/components/newtab/content-src/styles/_mixins.scss @@ -0,0 +1,50 @@ +// Shared styling of article images shown as background +@mixin image-as-background { + background-color: var(--newtab-element-secondary-color); + background-position: center; + background-repeat: no-repeat; + background-size: cover; + border-radius: 4px; + box-shadow: $shadow-image-inset; +} + +// Note: lineHeight and fontSize should be unitless but can be derived from pixel values +// Bug 1550624 to clean up / remove this mixin to avoid duplicate styles +@mixin limit-visible-lines($line-count, $line-height, $font-size) { + font-size: $font-size * 1px; + -webkit-line-clamp: $line-count; + line-height: $line-height * 1px; +} + +@mixin dark-theme-only { + [lwt-newtab-brighttext] & { + @content; + } +} + +@mixin ds-border-top { + @content; + + @include dark-theme-only { + border-top: 1px solid $grey-60; + } + + border-top: 1px solid $grey-30; +} + +@mixin ds-border-bottom { + @content; + + @include dark-theme-only { + border-bottom: 1px solid $grey-60; + } + + border-bottom: 1px solid $grey-30; +} + +@mixin ds-fade-in($halo-color: $blue-50-30) { + box-shadow: 0 0 0 5px $halo-color; + transition: box-shadow 150ms; + border-radius: 4px; + outline: none; +} diff --git a/browser/components/newtab/content-src/styles/_normalize.scss b/browser/components/newtab/content-src/styles/_normalize.scss new file mode 100644 index 0000000000..a47166ef7b --- /dev/null +++ b/browser/components/newtab/content-src/styles/_normalize.scss @@ -0,0 +1,29 @@ +html { + box-sizing: border-box; +} + +*, +*::before, +*::after { + box-sizing: inherit; +} + +*::-moz-focus-inner { + border: 0; +} + +body { + margin: 0; +} + +button, +input { + background-color: inherit; + color: inherit; + font-family: inherit; + font-size: inherit; +} + +[hidden] { + display: none !important; // stylelint-disable-line declaration-no-important +} diff --git a/browser/components/newtab/content-src/styles/_theme.scss b/browser/components/newtab/content-src/styles/_theme.scss new file mode 100644 index 0000000000..e5761b3d3b --- /dev/null +++ b/browser/components/newtab/content-src/styles/_theme.scss @@ -0,0 +1,99 @@ +@function textbox-shadow($color) { + @return 0 0 0 1px $color, 0 0 0 $textbox-shadow-size rgba($color, 0.3); +} + +@mixin textbox-focus($color) { + --newtab-textbox-focus-color: #{$color}; + --newtab-textbox-focus-boxshadow: #{textbox-shadow($color)}; +} + +// scss variables related to the theme. +$border-primary: 1px solid var(--newtab-border-color); +$border-secondary: 1px solid var(--newtab-border-color); +$inner-box-shadow: 0 0 0 1px var(--newtab-inner-box-shadow-color); +$input-border: 1px solid var(--newtab-border-color); +$input-border-active: 1px solid var(--newtab-textbox-focus-color); +$input-error-border: 1px solid var(--newtab-status-error); +$input-error-boxshadow: textbox-shadow(var(--newtab-status-error)); +$shadow-primary: 0 0 0 5px var(--newtab-element-secondary-color); +$shadow-secondary: 0 1px 4px 0 $grey-90-20; +$shadow-large: 0 2px 14px 0 $black-20; +$shadow-focus: 0 0 0 2px var(--newtab-primary-action-background-dimmed); +$shadow-card: 0 2px 6px rgba(0, 0, 0, 15%); +$shadow-image-inset: inset 0 0 0 0.5px $black-15; + +// Default theme +:root { + // General styles + --newtab-background-color: #{$in-content-page-background}; + --newtab-background-color-secondary: #{$newtab-background-secondary}; + --newtab-text-primary-color: #{$in-content-page-color}; + --newtab-primary-action-background: #{$primary-blue}; + // A button colour closer to the Pocket brand for usage in the Pocket section. + --newtab-primary-action-background-pocket: #{$primary-green}; + --newtab-text-secondary-color: color-mix(in srgb, var(--newtab-text-primary-color) 70%, transparent); + + // --newtab-element-*-color is used when an element needs to be set off from + // the primary background color. + --newtab-element-hover-color: color-mix(in srgb, var(--newtab-background-color) 90%, #{$black}); + --newtab-element-active-color: color-mix(in srgb, var(--newtab-background-color) 80%, #{$black}); + + // --newtab-element-secondary*-color is used when an element needs to be set + // off from the secondary background color. + --newtab-element-secondary-color: color-mix(in srgb, currentColor 5%, transparent); + --newtab-element-secondary-hover-color: color-mix(in srgb, currentColor 12%, transparent); + --newtab-element-secondary-active-color: color-mix(in srgb, currentColor 25%, transparent); + + --newtab-primary-element-hover-color: color-mix(in srgb, var(--newtab-primary-action-background) 90%, #{$black}); + --newtab-primary-element-hover-pocket-color: color-mix(in srgb, var(--newtab-primary-action-background-pocket) 90%, #{$black}); + --newtab-primary-element-active-color: color-mix(in srgb, var(--newtab-primary-action-background) 80%, #{$black}); + --newtab-primary-element-text-color: #{$white}; + // --newtab-primary-action-background-dimmed is used for soft focus borders. + --newtab-primary-action-background-dimmed: color-mix(in srgb, var(--newtab-primary-action-background) 25%, transparent); + --newtab-primary-action-background-pocket-dimmed: color-mix(in srgb, var(--newtab-primary-action-background-pocket) 25%, transparent); + --newtab-border-color: color-mix(in srgb, var(--newtab-background-color) 75%, #{$black}); + --newtab-wordmark-color: #{$newtab-wordmark-default-color}; + --newtab-status-success: #{$status-green}; + --newtab-status-error: #{$red-60}; + --newtab-inner-box-shadow-color: #{$black-10}; + --newtab-overlay-color: color-mix(in srgb, var(--newtab-background-color) 85%, transparent); + --newtab-text-emphasis-background: #{$yellow-50}; + --newtab-text-emphasis-text-color: #{$grey-90}; + + @include textbox-focus(var(--newtab-primary-action-background)); + + // Buttons + --newtab-button-secondary-color: inherit; + + &[lwt-newtab-brighttext] { + // General styles + --newtab-background-color: #{$in-content-page-background-dark}; + --newtab-background-color-secondary: #{$newtab-background-secondary-dark}; + --newtab-text-primary-color: #{$in-content-page-color-dark}; + + --newtab-primary-action-background: #{$primary-blue-dark}; + --newtab-primary-action-background-pocket: #{$primary-blue-dark}; + --newtab-primary-action-background-pocket-dimmed: color-mix(in srgb, var(--newtab-primary-action-background) 25%, transparent); + + --newtab-primary-element-hover-color: color-mix(in srgb, var(--newtab-primary-action-background) 55%, #{$white}); + --newtab-primary-element-hover-pocket-color: color-mix(in srgb, var(--newtab-primary-action-background-pocket) 55%, #{$white}); + + --newtab-element-hover-color: color-mix(in srgb, var(--newtab-background-color) 80%, #{$white}); + --newtab-element-active-color: color-mix(in srgb, var(--newtab-background-color) 60%, #{$white}); + + --newtab-element-secondary-color: color-mix(in srgb, currentColor 10%, transparent); + --newtab-element-secondary-hover-color: color-mix(in srgb, currentColor 17%, transparent); + --newtab-element-secondary-active-color: color-mix(in srgb, currentColor 30%, transparent); + + --newtab-border-color: color-mix(in srgb, var(--newtab-background-color) 75%, #{$white}); + --newtab-primary-element-text-color: #{$primary-text-color-dark}; + --newtab-wordmark-color: #{$newtab-wordmark-darktheme-color}; + --newtab-status-success: #{$status-dark-green}; + } +} + +@media (prefers-contrast) { + :root { + --newtab-text-secondary-color: var(--newtab-text-primary-color); + } +} diff --git a/browser/components/newtab/content-src/styles/_variables.scss b/browser/components/newtab/content-src/styles/_variables.scss new file mode 100644 index 0000000000..321b1ff7b8 --- /dev/null +++ b/browser/components/newtab/content-src/styles/_variables.scss @@ -0,0 +1,217 @@ +@use 'sass:math'; + +$primary-blue: rgb(0, 97, 224); +$primary-green: rgb(0, 128, 120); +$primary-blue-dark: rgb(0, 221, 255); +$primary-text-color-dark: rgb(43, 42, 51); + +// -------------------------------------------------------------------------- // +// Photon colors from http://design.firefox.com/photon/visuals/color.html + +$blue-40: #45A1FF; +$blue-50: #0A84FF; +$grey-30: #D7D7DB; +$grey-60: #4A4A4F; +$grey-90: #0C0C0D; +$red-60: #D70022; +$yellow-50: #FFE900; + +$grey-90-10: rgba($grey-90, 0.1); +$grey-90-20: rgba($grey-90, 0.2); + +$blue-40-40: rgba($blue-40, 0.4); +$blue-50-30: rgba($blue-50, 0.3); + +$black: #000; +$black-10: rgba($black, 0.1); +$black-15: rgba($black, 0.15); +$black-20: rgba($black, 0.2); +$black-30: rgba($black, 0.3); +$black-40: rgba($black, 0.4); + +// -------------------------------------------------------------------------- // +// Other colors + +$white: #FFF; +$status-green: #058B00; +$status-dark-green: #7C6; +$bookmark-icon-fill: #0A84FF; +$download-icon-fill: #12BC00; +$pocket-icon-fill: #EF4056; +$email-input-invalid: rgba($red-60, 0.3); + +$newtab-wordmark-default-color: #20123A; +$newtab-wordmark-darktheme-color: rgb(251, 251, 254); + +$in-content-page-color: rgb(21, 20, 26); +$in-content-page-color-dark: rgb(251, 251, 254); + +// -------------------------------------------------------------------------- // +// These colors should match the colors in the default themes +// (toolkit/mozapps/extensions/...). Note that they could get replaced by other +// themes. The color set in --tabpanel-background-color (tabs.inc.css) should +// match these colors here to avoid flashing. + +$in-content-page-background: #F9F9FB; +$in-content-page-background-dark: #2B2A33; + +$newtab-background-secondary: #FFF; +$newtab-background-secondary-dark: rgb(66, 65, 77); + +// Photon transitions from http://design.firefox.com/photon/motion/duration-and-easing.html +$photon-easing: cubic-bezier(0.07, 0.95, 0, 1); + +$border-radius: 3px; +$border-radius-new: 8px; + +// Grid related styles +$base-gutter: 32px; +$section-horizontal-padding: 25px; +$section-vertical-padding: 10px; +$section-spacing: 40px - $section-vertical-padding * 2; +$grid-unit: 96px; // 1 top site +// New Tab Experience grid unit needs to be smaller, but for now we are changing +// this UI with a pref, so requires duplication. +$grid-unit-small: 80px; // 1 top site + +$icon-size: 16px; +$smaller-icon-size: 12px; +$larger-icon-size: 32px; + +$searchbar-width-small: ($grid-unit * 2 + $base-gutter * 1) - 24px; +$searchbar-width-medium: ($grid-unit * 4 + $base-gutter * 3) - 120px; +$searchbar-width-large: ($grid-unit * 6 + $base-gutter * 5) - 136px; +$searchbar-width-largest: ($grid-unit * 6 + $base-gutter * 5) - 16px; + +$wrapper-default-width: $grid-unit * 2 + $base-gutter * 1 + $section-horizontal-padding * 2; // 2 top sites +$wrapper-max-width-medium: $grid-unit * 4 + $base-gutter * 3 + $section-horizontal-padding * 2; // 4 top sites +$wrapper-max-width-large: $grid-unit * 6 + $base-gutter * 5 + $section-horizontal-padding * 2; // 6 top sites +$wrapper-max-width-widest: $grid-unit * 8 + $base-gutter * 7 + $section-horizontal-padding * 2; // 8 top sites +// For the breakpoints, we need to add space for the scrollbar to avoid weird +// layout issues when the scrollbar is visible. 16px is wide enough to cover all +// OSes and keeps it simpler than a per-OS value. +$scrollbar-width: 16px; + +// Breakpoints +// If updating these breakpoints, don't forget to update uses of DSImage, which +// might choose the right image src to use depending on the viewport size. +$break-point-medium: $wrapper-max-width-medium + $base-gutter * 2 + $scrollbar-width; // 610px +$break-point-large: $wrapper-max-width-large + $base-gutter * 2 + $scrollbar-width; // 866px +$break-point-widest: $wrapper-max-width-widest + $base-gutter * 2 + $scrollbar-width; // 1122px + +$section-title-font-size: 17px; + +$card-width: $grid-unit-small * 2 + $base-gutter; + +$card-height: 266px; +$card-preview-image-height: 122px; +$card-title-margin: 2px; +$card-text-line-height: 19px; + +// Larger cards for wider screens: +$card-width-large: 309px; +$card-height-large: 370px; +$card-preview-image-height-large: 155px; + +// Compact cards for Highlights +$card-height-compact: 160px; +$card-preview-image-height-compact: 108px; + +$topic-margin-top: 12px; + +$context-menu-button-size: 27px; +$context-menu-button-boxshadow: 0 2px $grey-90-10; +$context-menu-shadow: 0 5px 10px $black-30, 0 0 0 1px $black-20; +$context-menu-font-size: 14px; +$context-menu-border-radius: 5px; +$context-menu-outer-padding: 5px; +$context-menu-item-padding: 3px 12px; + +$error-fallback-font-size: 12px; +$error-fallback-line-height: 1.5; + +$image-path: 'chrome://activity-stream/content/data/content/assets/'; + +$snippets-container-height: 120px; + +$textbox-shadow-size: 4px; + +$customize-menu-slide-bezier: cubic-bezier(0.46, 0.03, 0.52, 0.96); +$customize-menu-expand-bezier: cubic-bezier(0.82, 0.085, 0.395, 0.895); +$customize-menu-border-tint: 1px solid rgba(0, 0, 0, 15%); + +@mixin fade-in { + box-shadow: inset $inner-box-shadow, $shadow-primary; + transition: box-shadow 150ms; +} + +@mixin fade-in-card { + box-shadow: $shadow-primary; + transition: box-shadow 150ms; +} + +@mixin ds-focus { + border: 0; + outline: 0; + box-shadow: 0 0 0 3px var(--newtab-primary-action-background-dimmed), 0 0 0 1px var(--newtab-primary-action-background); +} + +@mixin context-menu-button { + .context-menu-button { + background-clip: padding-box; + background-color: var(--newtab-background-color-secondary); + background-image: url('chrome://global/skin/icons/more.svg'); + background-position: 55%; + border: $border-primary; + border-radius: 100%; + box-shadow: $context-menu-button-boxshadow; + cursor: pointer; + fill: var(--newtab-text-primary-color); + height: $context-menu-button-size; + inset-inline-end: math.div(-$context-menu-button-size, 2); + opacity: 0; + position: absolute; + top: math.div(-$context-menu-button-size, 2); + transform: scale(0.25); + transition-duration: 150ms; + transition-property: transform, opacity; + width: $context-menu-button-size; + + &:is(:active, :focus) { + opacity: 1; + transform: scale(1); + } + } +} + +@mixin context-menu-button-hover { + .context-menu-button { + opacity: 1; + transform: scale(1); + transition-delay: 333ms; + } +} + +@mixin context-menu-open-middle { + .context-menu { + margin-inline-end: auto; + margin-inline-start: auto; + inset-inline-end: auto; + inset-inline-start: -$base-gutter; + } +} + +@mixin context-menu-open-left { + .context-menu { + margin-inline-end: 5px; + margin-inline-start: auto; + inset-inline-end: 0; + inset-inline-start: auto; + } +} + +@mixin flip-icon { + &:dir(rtl) { + transform: scaleX(-1); + } +} diff --git a/browser/components/newtab/content-src/styles/activity-stream-linux.scss b/browser/components/newtab/content-src/styles/activity-stream-linux.scss new file mode 100644 index 0000000000..89128d9d04 --- /dev/null +++ b/browser/components/newtab/content-src/styles/activity-stream-linux.scss @@ -0,0 +1,11 @@ +/* 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/. */ + +/* This is the linux variant */ + +$os-infopanel-arrow-height: 10px; +$os-infopanel-arrow-offset-end: 6px; +$os-infopanel-arrow-width: 20px; + +@import './activity-stream'; diff --git a/browser/components/newtab/content-src/styles/activity-stream-mac.scss b/browser/components/newtab/content-src/styles/activity-stream-mac.scss new file mode 100644 index 0000000000..d95665f36f --- /dev/null +++ b/browser/components/newtab/content-src/styles/activity-stream-mac.scss @@ -0,0 +1,16 @@ +/* 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/. */ + +/* This is the mac variant */ + +$os-infopanel-arrow-height: 10px; +$os-infopanel-arrow-offset-end: 7px; +$os-infopanel-arrow-width: 18px; + +[lwt-newtab-brighttext] { + -moz-osx-font-smoothing: grayscale; +} + +// stylelint-disable-next-line no-invalid-position-at-import-rule +@import './activity-stream'; diff --git a/browser/components/newtab/content-src/styles/activity-stream-windows.scss b/browser/components/newtab/content-src/styles/activity-stream-windows.scss new file mode 100644 index 0000000000..9252bdd0f6 --- /dev/null +++ b/browser/components/newtab/content-src/styles/activity-stream-windows.scss @@ -0,0 +1,11 @@ +/* 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/. */ + +/* This is the windows variant */ + +$os-infopanel-arrow-height: 10px; +$os-infopanel-arrow-offset-end: 6px; +$os-infopanel-arrow-width: 20px; + +@import './activity-stream'; |