diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
commit | 2aa4a82499d4becd2284cdb482213d541b8804dd (patch) | |
tree | b80bf8bf13c3766139fbacc530efd0dd9d54394c /browser/components/newtab/content-src | |
parent | Initial commit. (diff) | |
download | firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.tar.xz firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.zip |
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/newtab/content-src')
178 files changed, 25582 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..d9503584b9 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.jsx @@ -0,0 +1,188 @@ +/* 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 { MultiStageAboutWelcome } from "./components/MultiStageAboutWelcome"; +import { SimpleAboutWelcome } from "./components/SimpleAboutWelcome"; +import { ReturnToAMO } from "./components/ReturnToAMO"; + +import { + AboutWelcomeUtils, + DEFAULT_WELCOME_CONTENT, +} from "../lib/aboutwelcome-utils"; + +class AboutWelcome extends React.PureComponent { + constructor(props) { + super(props); + this.state = { metricsFlowUri: null }; + this.fetchFxAFlowUri = this.fetchFxAFlowUri.bind(this); + this.handleStartBtnClick = this.handleStartBtnClick.bind(this); + } + + async fetchFxAFlowUri() { + this.setState({ metricsFlowUri: await window.AWGetFxAMetricsFlowURI() }); + } + + componentDidMount() { + this.fetchFxAFlowUri(); + + // Record impression with performance data after allowing the page to load + const recordImpression = domState => { + const { domComplete, domInteractive } = performance + .getEntriesByType("navigation") + .pop(); + window.AWSendEventTelemetry({ + event: "IMPRESSION", + event_context: { + domComplete, + domInteractive, + mountStart: performance.getEntriesByName("mount").pop().startTime, + domState, + source: this.props.UTMTerm, + page: "about:welcome", + }, + message_id: this.props.messageId, + }); + }; + 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); + } + + handleStartBtnClick() { + AboutWelcomeUtils.handleUserAction(this.props.startButton.action); + const ping = { + event: "CLICK_BUTTON", + event_context: { + source: this.props.startButton.message_id, + page: "about:welcome", + }, + message_id: this.props.messageId, + id: "ABOUT_WELCOME", + }; + window.AWSendEventTelemetry(ping); + } + + render() { + const { props } = this; + if (props.template === "simplified") { + return ( + <SimpleAboutWelcome + metricsFlowUri={this.state.metricsFlowUri} + message_id={props.messageId} + utm_term={props.UTMTerm} + title={props.title} + subtitle={props.subtitle} + cards={props.cards} + startButton={props.startButton} + handleStartBtnClick={this.handleStartBtnClick} + /> + ); + } else if (props.template === "return_to_amo") { + return ( + <ReturnToAMO + message_id={props.messageId} + name={props.name} + url={props.url} + iconURL={props.iconURL} + /> + ); + } + + return ( + <MultiStageAboutWelcome + screens={props.screens} + metricsFlowUri={this.state.metricsFlowUri} + message_id={props.messageId} + utm_term={props.UTMTerm} + /> + ); + } +} + +AboutWelcome.defaultProps = DEFAULT_WELCOME_CONTENT; + +// Computes messageId and UTMTerm info used in telemetry +function ComputeTelemetryInfo(welcomeContent, experimentId, branchId) { + let messageId = + welcomeContent.template === "return_to_amo" + ? "RTAMO_DEFAULT_WELCOME" + : "DEFAULT_ABOUTWELCOME"; + let UTMTerm = "default"; + + if (welcomeContent.id) { + messageId = welcomeContent.id.toUpperCase(); + } + + if (experimentId && branchId) { + UTMTerm = `${experimentId}-${branchId}`.toLowerCase(); + } + return { + messageId, + UTMTerm, + }; +} + +async function retrieveRenderContent() { + // Check for override content in pref browser.aboutwelcome.overrideContent + let aboutWelcomeProps = await window.AWGetWelcomeOverrideContent(); + if (aboutWelcomeProps?.template) { + let { messageId, UTMTerm } = ComputeTelemetryInfo(aboutWelcomeProps); + return { aboutWelcomeProps, messageId, UTMTerm }; + } + + // Check for experiment and retrieve content + const { slug, branch } = await window.AWGetExperimentData(); + aboutWelcomeProps = branch?.feature ? branch.feature.value : {}; + + // Check if there is any attribution data, this could take a while to await in series + // especially when there is an add-on that requires remote lookup + // Moving RTAMO as part of another screen of multistage is one option to fix the delay + // as it will allow the initial page to be fast while we fetch attribution data in parallel for a later screen. + const attribution = await window.AWGetAttributionData(); + if (attribution?.template) { + aboutWelcomeProps = { + ...aboutWelcomeProps, + // If part of an experiment, render experiment template + template: aboutWelcomeProps?.template + ? aboutWelcomeProps.template + : attribution.template, + ...attribution.extraProps, + }; + } + + let { messageId, UTMTerm } = ComputeTelemetryInfo( + aboutWelcomeProps, + slug, + branch && branch.slug + ); + return { aboutWelcomeProps, messageId, UTMTerm }; +} + +async function mount() { + let { aboutWelcomeProps, messageId, UTMTerm } = await retrieveRenderContent(); + ReactDOM.render( + <AboutWelcome + messageId={messageId} + UTMTerm={UTMTerm} + {...aboutWelcomeProps} + />, + document.getElementById("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..501b57952f --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.scss @@ -0,0 +1,673 @@ +// sass-lint:disable no-css-comments +/* 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 '../styles/normalize'; +@import '../styles/OnboardingImages'; + +$break-point-medium: 610px; +$break-point-large: 866px; +$break-point-widest: 1122px; +$logo-size: 112px; + +html { + height: 100%; +} + +body { + // sass-lint:disable no-color-literals + --grey-subtitle: #4A4A4F; + --grey-subtitle-1: #696977; + --newtab-background-color: #EDEDF0; + --newtab-background-color-1: #F9F9FA; + --newtab-text-primary-color: #0C0C0D; + --newtab-text-conditional-color: #4A4A4F; + --newtab-button-primary-color: #0060DF; + --newtab-button-secondary-color: #0060DF; + --newtab-card-background-color: #FFF; + --newtab-card-shadow: 0 1px 4px 0 rgba(12, 12, 13, 0.4); + --tiles-theme-section-border-width: 1px; + --welcome-header-text-color: #2B2156; + --welcome-header-text-color-1: #20133A; + --welcome-card-button-background-color: rgba(12, 12, 13, 0.1); + --welcome-card-button-background-hover-color: rgba(12, 12, 13, 0.2); + --welcome-card-button-background-active-color: rgba(12, 12, 13, 0.3); + --welcome-button-box-shadow-color: #0A84FF; + --welcome-button-box-shadow-inset-color: rgba(10, 132, 255, 0.3); + --welcome-button-text-color: #FFF; + --welcome-button-background-hover-color: #003EAA; + --welcome-button-background-active-color: #002275; + --about-welcome-media-fade: linear-gradient(transparent, transparent 35%, #F9F9FA, #F9F9FA); + + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Ubuntu', + 'Helvetica Neue', sans-serif; + font-size: 16px; + position: relative; + /* these two rules fix test failures in + "browser_ext_themes_ntp_colors" & "browser_ext_themes_ntp_colors_perwindow".*/ + color: var(--newtab-text-primary-color); + background-color: var(--newtab-background-color); + + &[lwt-newtab-brighttext] { + --newtab-background-color: #2A2A2E; + --newtab-background-color-1: #1D1133; + --newtab-text-primary-color: #F9F9FA; + --newtab-text-conditional-color: #F9F9FA; + --grey-subtitle-1: #FFF; + --newtab-button-primary-color: #0060DF; + --newtab-button-secondary-color: #FFF; + --newtab-card-background-color: #38383D; + --newtab-card-shadow: 0 1px 8px 0 rgba(12, 12, 13, 0.4); + --welcome-header-text-color: rgba(255, 255, 255, 0.6); + --welcome-header-text-color-1: #7542E5; + --welcome-card-button-background-color: rgba(12, 12, 13, 0.3); + --welcome-card-button-background-hover-color: rgba(12, 12, 13, 0.5); + --welcome-card-button-background-active-color: rgba(12, 12, 13, 0.7); + --welcome-button-box-shadow-color: #0A84FF; + --about-welcome-media-fade: linear-gradient(transparent, transparent 35%, #1D1133, #1D1133); + } +} + +.welcomeCardGrid { + margin: 0; + margin-top: 32px; + display: grid; + grid-gap: 32px; + transition: opacity 0.4s; + transition-delay: 0.1s; + grid-auto-rows: 1fr; + + @media (min-width: $break-point-medium) { + grid-template-columns: repeat(auto-fit, 224px); + } + + @media (min-width: $break-point-widest) { + grid-template-columns: repeat(auto-fit, 309px); + } +} + +.welcomeContainer { + text-align: center; + + @media (min-width: $break-point-medium) { + max-height: 1000px; + } + + h1 { + font-size: 36px; + font-weight: 200; + margin: 0 0 40px; + color: var(--welcome-header-text-color); + } + + .welcome-title { + margin-bottom: 5px; + line-height: 52px; + } + + .welcome-subtitle { + font-size: 28px; + font-weight: 200; + margin: 6px 0 0; + color: var(--grey-subtitle); + line-height: 42px; + } +} + +.welcomeContainerInner { + margin: auto; + padding: 40px 25px; + + @media (min-width: $break-point-medium) { + width: 530px; + } + + @media (min-width: $break-point-large) { + width: 786px; + } + + @media (min-width: $break-point-widest) { + width: 1042px; + } +} + +.welcomeCard { + position: relative; + background: var(--newtab-card-background-color); + border-radius: 4px; + box-shadow: var(--newtab-card-shadow); + font-size: 13px; + padding: 20px 20px 60px; + + @media (max-width: $break-point-large) { + padding: 20px; + } + + @media (min-width: $break-point-widest) { + font-size: 15px; + } +} + +.welcomeCard .onboardingTitle { + font-weight: normal; + color: var(--newtab-text-primary-color); + margin: 10px 0 4px; + font-size: 15px; + + @media (min-width: $break-point-widest) { + font-size: 18px; + } +} + +.welcomeCard .onboardingText { + margin: 0 0 60px; + color: var(--newtab-text-conditional-color); + line-height: 1.5; + font-weight: 200; +} + +.welcomeCard .onboardingButton { + color: var(--newtab-text-conditional-color); + background: var(--welcome-card-button-background-color); + border: 0; + border-radius: 4px; + margin: 14px; + min-width: 70%; + padding: 6px 14px; + white-space: pre-wrap; + cursor: pointer; + + &:focus, + &:hover { + box-shadow: none; + background: var(--welcome-card-button-background-hover-color); + } + + &:focus { + outline: dotted 1px; + } + + &:active { + background: var(--welcome-card-button-background-active-color); + } +} + +.welcomeCard .onboardingButtonContainer { + position: absolute; + bottom: 16px; + left: 0; + width: 100%; + text-align: center; +} + +.onboardingMessageImage { + height: 112px; + width: 180px; + background-size: auto 140px; + background-position: center center; + background-repeat: no-repeat; + display: inline-block; + + @media (max-width: $break-point-large) { + height: 75px; + min-width: 80px; + background-size: 140px; + } +} + +.start-button { + border: 0; + font-size: 15px; + font-family: inherit; + font-weight: 200; + margin-inline-start: 12px; + margin: 30px 0 25px; + padding: 8px 16px; + white-space: nowrap; + background-color: var(--newtab-button-primary-color); + color: var(--welcome-button-text-color); + cursor: pointer; + border-radius: 2px; + + &:focus { + background: var(--welcome-button-background-hover-color); + box-shadow: 0 0 0 1px var(--welcome-button-box-shadow-inset-color) inset, + 0 0 0 1px var(--welcome-button-box-shadow-inset-color), + 0 0 0 4px var(--welcome-button-box-shadow-color); + } + + &:hover { + background: var(--welcome-button-background-hover-color); + } + + &:active { + background: var(--welcome-button-background-active-color); + } +} + + +.onboardingContainer { + text-align: center; + overflow-x: auto; + height: 100vh; + background-color: var(--newtab-background-color-1); + + .screen { + display: flex; + flex-flow: column nowrap; + height: 100%; + } + + .brand-logo { + background: url('chrome://branding/content/about-logo.svg') top + center / $logo-size no-repeat; + padding: $logo-size 0 20px; + margin-top: 60px; + + &.cta-top { + margin-top: 25px; + } + } + + .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; + } + + &.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'); + } + } + + + } + + .welcome-text { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + margin-bottom: 20px; + + h1, + h2 { + width: 860px; + @media (max-width: $break-point-large) { + width: 530px; + } + + @media (max-width: $break-point-medium) { + width: 430px; + } + } + + h1 { + font-size: 48px; + line-height: 56px; + font-weight: bold; + margin: 0 6px; + color: var(--welcome-header-text-color-1); + } + + h2 { + font-size: 18px; + font-weight: normal; + margin: 10px 6px 0; + color: var(--grey-subtitle-1); + line-height: 28px; + max-width: 750px; + letter-spacing: -0.01em; + } + + img { + margin-inline: 2px; + width: 20px; + height: 20px; + } + } + + .tiles-theme-container { + margin: 10px auto; + border: 0; + } + + .sr-only { + opacity: 0; + overflow: hidden; + position: absolute; + + &.input { + height: 1px; + width: 1px; + } + } + + .tiles-theme-section { + display: grid; + grid-gap: 21px; + grid-template-columns: repeat(4, auto); + + /* --newtab-background-color-1 will be invisible, but it's necessary to + * keep the content from jumping around when it gets focus-within and + * does sprout a dotted border. This way it keeps a 1 pixel wide border + * either way so things don't change position. + */ + border: var(--tiles-theme-section-border-width) + solid + var(--newtab-background-color-1); + + @media (max-width: $break-point-medium) { + grid-template-columns: repeat(2, auto); + } + + &:focus-within { + border: var(--tiles-theme-section-border-width) dotted; + } + + .theme { + display: flex; + flex-direction: column; + padding: 0; + width: 180px; + height: 145px; + color: #000; + background-color: #FFF; + box-shadow: 0 1px 4px 0 rgba(12, 12, 13, 0.4); + border-radius: 4px; + cursor: pointer; + + .icon { + background-size: cover; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + height: 91px; + + &:dir(rtl) { + transform: scaleX(-1); + } + + &.light { + background-image: url('chrome://mozapps/content/extensions/firefox-compact-light.svg'); + } + + &.dark { + background-image: url('chrome://mozapps/content/extensions/firefox-compact-dark.svg'); + } + + &.automatic { + background-image: url('chrome://mozapps/content/extensions/default-theme.svg'); + } + + &.alpenglow { + background-image: url('chrome://mozapps/content/extensions/firefox-alpenglow.svg'); + } + } + + .text { + display: flex; + font-size: 14px; + font-weight: bold; + line-height: 22px; + margin-inline-start: 12px; + margin-top: 9px; + } + + &.selected { + outline: 4px solid #0090ED; + outline-offset: -4px; + } + + &:focus, + &:active { + outline: 4px solid #0090ED; + outline-offset: -4px; + } + } + } + + .tiles-container { + margin: 10px auto; + + &.info { + padding: 6px 12px 12px; + + &:hover, + &:focus { + background-color: rgba(217, 217, 227, 0.3); + border-radius: 4px; + } + } + } + + .tiles-topsites-section { + $host-size: 12px; + $tile-size: 96px; + + display: grid; + grid-gap: $tile-size / 4; + grid-template-columns: repeat(5, auto); + + @media (max-width: $break-point-medium) { + grid-template-columns: repeat(3, auto); + } + + .site { + width: $tile-size; + } + + .icon { + background-size: cover; + border-radius: 4px; + box-shadow: var(--newtab-card-shadow); + color: rgba(255, 255, 255, 0.5); + font-size: $host-size * 2; + font-weight: bold; + height: $tile-size; + line-height: $tile-size; + } + + .host { + font-size: $host-size; + line-height: $host-size * 3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .site:nth-child(1) .icon { + background-color: #7542E5; + } + + .site:nth-child(2) .icon { + background-color: #952BB9; + } + + .site:nth-child(3) .icon { + background-color: #E31587; + } + + .site:nth-child(4) .icon { + background-color: #E25920; + } + + .site:nth-child(5) .icon { + background-color: #0250BB; + } + } + + // "tiles-media-section" styles here will support tiles of type + // "video" when the screen JSON it set in the below format: + + // "tiles": { + // "type": "video", + // "source": { + // "default" : "<media-file-uri>", + // "dark" : "<media-file-dark-mode-uri>" + // } + // } + + .tiles-media-section { + align-self: center; + position: relative; + margin-top: -12px; + margin-bottom: -155px; + + .fade { + height: 390px; + width: 800px; + position: absolute; + background-image: var(--about-welcome-media-fade); + } + + .media { + height: 390px; + width: 800px; + } + + &.privacy { + background: top no-repeat url('chrome://activity-stream/content/data/content/assets/firefox-protections.svg'); + height: 200px; + width: 800px; + margin: 0; + + &.media { + opacity: 0; + } + } + } + + button { + font-family: inherit; + cursor: pointer; + border: 0; + border-radius: 4px; + + &.primary { + font-size: 16px; + margin-inline-start: 12px; + margin: 20px 0 0; + padding: 12px 20px; + white-space: nowrap; + background-color: var(--newtab-button-primary-color); + color: var(--welcome-button-text-color); + fill: currentColor; + position: relative; + z-index: 1; + // This transparent border will show up in Windows High Contrast Mode to improve accessibility. + border: 1px solid transparent; + + &:focus { + background: var(--welcome-button-background-hover-color); + box-shadow: 0 0 0 4px var(--welcome-button-box-shadow-color); + } + + &:hover { + background: var(--welcome-button-background-hover-color); + } + + &:active { + background: var(--welcome-button-background-active-color); + } + } + + &.secondary { + background-color: initial; + text-decoration: underline; + display: block; + padding: 0; + width: auto; + color: var(--newtab-button-secondary-color); + margin-top: 14px; + + &:hover, + &:active { + background-color: initial; + } + } + } + + .secondary-cta { + display: flex; + flex-direction: row; + justify-content: center; + font-size: 14px; + + &.top { + justify-content: end; + align-items: end; + padding-inline-end: 30px; + padding-top: 4px; + + @media (max-width: $break-point-medium) { + justify-content: center; + } + } + + span { + color: var(--grey-subtitle-1); + margin: 0 4px; + } + } + + .helptext { + padding: 1em; + text-align: center; + color: var(--grey-subtitle-1); + font-size: 12px; + line-height: 18px; + + &.default { + align-self: center; + max-width: 40%; + } + } + + .steps { + display: flex; + flex-direction: row; + justify-content: center; + margin-top: auto; + padding: 32px 0 66px; + z-index: 1; + + &.has-helptext { + padding-bottom: 0; + } + + .indicator { + width: 60px; + height: 4px; + margin-inline-end: 4px; + margin-inline-start: 4px; + background: var(--grey-subtitle-1); + border-radius: 5px; + // This transparent border will show up in Windows High Contrast Mode to improve accessibility. + border: 1px solid transparent; + opacity: 0.25; + + &.current { + opacity: 1; + } + } + } +} diff --git a/browser/components/newtab/content-src/aboutwelcome/components/FxCards.jsx b/browser/components/newtab/content-src/aboutwelcome/components/FxCards.jsx new file mode 100644 index 0000000000..4275c59c8f --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/FxCards.jsx @@ -0,0 +1,83 @@ +/* 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 { addUtmParams } from "../../asrouter/templates/FirstRun/addUtmParams"; +import { OnboardingCard } from "../../asrouter/templates/OnboardingMessage/OnboardingMessage"; +import { AboutWelcomeUtils } from "../../lib/aboutwelcome-utils"; + +export class FxCards extends React.PureComponent { + constructor(props) { + super(props); + + this.state = { flowParams: null }; + + this.fetchFxAFlowParams = this.fetchFxAFlowParams.bind(this); + this.onCardAction = this.onCardAction.bind(this); + } + + componentDidUpdate() { + this.fetchFxAFlowParams(); + } + + componentDidMount() { + this.fetchFxAFlowParams(); + } + + async fetchFxAFlowParams() { + if (this.state.flowParams || !this.props.metricsFlowUri) { + return; + } + + const flowParams = await AboutWelcomeUtils.fetchFlowParams( + this.props.metricsFlowUri + ); + + this.setState({ flowParams }); + } + + onCardAction(action) { + let { type, data } = action; + let UTMTerm = `aboutwelcome-${this.props.utm_term}-card`; + + if (action.type === "OPEN_URL") { + let url = new URL(action.data.args); + addUtmParams(url, UTMTerm); + + if (action.addFlowParams && this.state.flowParams) { + url.searchParams.append("device_id", this.state.flowParams.deviceId); + url.searchParams.append("flow_id", this.state.flowParams.flowId); + url.searchParams.append( + "flow_begin_time", + this.state.flowParams.flowBeginTime + ); + } + + data = { ...data, args: url.toString() }; + } + + AboutWelcomeUtils.handleUserAction({ type, data }); + } + + render() { + const { props } = this; + return ( + <React.Fragment> + <div className={`welcomeCardGrid show`}> + {props.cards.map(card => ( + <OnboardingCard + key={card.id} + message={card} + className="welcomeCard" + sendUserActionTelemetry={props.sendTelemetry} + onAction={this.onCardAction} + UISurface="ABOUT_WELCOME" + {...card} + /> + ))} + </div> + </React.Fragment> + ); + } +} diff --git a/browser/components/newtab/content-src/aboutwelcome/components/HeroText.jsx b/browser/components/newtab/content-src/aboutwelcome/components/HeroText.jsx new file mode 100644 index 0000000000..bb3a296d54 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/HeroText.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"; +import { Localized } from "./MSLocalized"; + +export const HeroText = props => { + return ( + <React.Fragment> + <Localized text={props.title}> + <h1 className="welcome-title" /> + </Localized> + <Localized text={props.subtitle}> + <h2 className="welcome-subtitle" /> + </Localized> + </React.Fragment> + ); +}; 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..694a294028 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/MSLocalized.jsx @@ -0,0 +1,50 @@ +/* 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 MS_STRING_PROP = "string_id"; + +/** + * 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. + * + * 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> + * output: + * <h1>Welcome</h1> + */ + +export const Localized = ({ text, children }) => { + if (!text) { + return null; + } + + let props = children ? children.props : {}; + let textNode; + + if (typeof text === "object" && text[MS_STRING_PROP]) { + props = { ...props }; + props["data-l10n-id"] = text[MS_STRING_PROP]; + } else if (typeof text === "string") { + textNode = text; + } + + if (!children) { + return React.createElement("span", props, textNode); + } else if (textNode) { + return React.cloneElement(children, props, textNode); + } + return React.cloneElement(children, props); +}; 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..fde65d3673 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/MultiStageAboutWelcome.jsx @@ -0,0 +1,445 @@ +/* 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 { Zap } from "./Zap"; +import { AboutWelcomeUtils } from "../../lib/aboutwelcome-utils"; +import { + BASE_PARAMS, + addUtmParams, +} from "../../asrouter/templates/FirstRun/addUtmParams"; + +export const MultiStageAboutWelcome = props => { + const [index, setScreenIndex] = useState(0); + useEffect(() => { + // Send impression ping when respective screen first renders + props.screens.forEach(screen => { + if (index === screen.order) { + AboutWelcomeUtils.sendImpressionTelemetry( + `${props.message_id}_${screen.id}` + ); + } + }); + + // Remember that a new screen has loaded for browser navigation + if (index > window.history.state) { + window.history.pushState(index, ""); + } + }, [index]); + + useEffect(() => { + // Switch to the screen tracked in state (null for initial state) + const handler = ({ state }) => setScreenIndex(Number(state)); + + // Handle page load, e.g., going back to about:welcome from about:home + handler(window.history); + + // Watch for browser back/forward button navigation events + window.addEventListener("popstate", handler); + return () => window.removeEventListener("popstate", handler); + }, []); + + const [flowParams, setFlowParams] = useState(null); + const { metricsFlowUri } = props; + useEffect(() => { + (async () => { + if (metricsFlowUri) { + setFlowParams(await AboutWelcomeUtils.fetchFlowParams(metricsFlowUri)); + } + })(); + }, [metricsFlowUri]); + + // Transition to next screen, opening about:home on last screen button CTA + const handleTransition = + index < props.screens.length - 1 + ? () => setScreenIndex(prevState => prevState + 1) + : () => + AboutWelcomeUtils.handleUserAction({ + type: "OPEN_ABOUT_PAGE", + data: { args: "home", where: "current" }, + }); + + // Update top sites with default sites by region when region is available + const [region, setRegion] = useState(null); + useEffect(() => { + (async () => { + setRegion(await window.AWGetRegion()); + })(); + }, []); + + // 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 useImportable = props.message_id.includes("IMPORTABLE"); + // Track whether we have already sent the importable sites impression telemetry + const importTelemetrySent = useRef(false); + const [topSites, setTopSites] = useState([]); + useEffect(() => { + (async () => { + let DEFAULT_SITES = await window.AWGetDefaultSites(); + const importable = JSON.parse(await window.AWGetImportableSites()); + const showImportable = useImportable && importable.length >= 5; + if (!importTelemetrySent.current) { + AboutWelcomeUtils.sendImpressionTelemetry(`${props.message_id}_SITES`, { + display: showImportable ? "importable" : "static", + importable: importable.length, + }); + importTelemetrySent.current = true; + } + setTopSites( + showImportable + ? { data: importable, showImportable } + : { data: DEFAULT_SITES, showImportable } + ); + })(); + }, [useImportable, region]); + + return ( + <React.Fragment> + <div className={`outer-wrapper onboardingContainer`}> + {props.screens.map(screen => { + return index === screen.order ? ( + <WelcomeScreen + key={screen.id} + id={screen.id} + totalNumberOfScreens={props.screens.length} + order={screen.order} + content={screen.content} + navigate={handleTransition} + topSites={topSites} + messageId={`${props.message_id}_${screen.id}`} + UTMTerm={props.utm_term} + flowParams={flowParams} + activeTheme={activeTheme} + initialTheme={initialTheme} + setActiveTheme={setActiveTheme} + /> + ) : null; + })} + </div> + </React.Fragment> + ); +}; + +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: `aboutwelcome-${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, `aboutwelcome-${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() }; + } + AboutWelcomeUtils.handleUserAction({ type, data }); + } + + async handleAction(event) { + let { props } = this; + + let targetContent = + props.content[event.currentTarget.value] || props.content.tiles; + if (!(targetContent && targetContent.action)) { + return; + } + + // Send telemetry before waiting on actions + AboutWelcomeUtils.sendActionTelemetry( + props.messageId, + event.currentTarget.value + ); + + let { action } = targetContent; + + if (["OPEN_URL", "SHOW_FIREFOX_ACCOUNTS"].includes(action.type)) { + this.handleOpenURL(action, props.flowParams, props.UTMTerm); + } else if (action.type) { + AboutWelcomeUtils.handleUserAction(action); + // Wait until migration closes to complete the action + if (action.type === "SHOW_MIGRATION_WIZARD") { + 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 (action.navigate) { + props.navigate(); + } + } + + renderSecondaryCTA(className) { + return ( + <div + className={className ? `secondary-cta ${className}` : `secondary-cta`} + > + <Localized text={this.props.content.secondary_button.text}> + <span /> + </Localized> + <Localized text={this.props.content.secondary_button.label}> + <button + className="secondary" + value="secondary_button" + onClick={this.handleAction} + /> + </Localized> + </div> + ); + } + + renderTiles() { + switch (this.props.content.tiles.type) { + case "topsites": + return this.props.topSites && this.props.topSites.data ? ( + <div + className={`tiles-container ${ + this.props.content.tiles.info ? "info" : "" + }`} + > + <div + className="tiles-topsites-section" + name="topsites-section" + id="topsites-section" + aria-labelledby="helptext" + role="region" + > + {this.props.topSites.data + .slice(0, 5) + .map(({ icon, label, title }) => ( + <div + className="site" + key={icon + label} + aria-label={title ? title : label} + role="img" + > + <div + className="icon" + style={ + icon + ? { + backgroundColor: "transparent", + backgroundImage: `url(${icon})`, + } + : {} + } + > + {icon ? "" : label && label[0].toUpperCase()} + </div> + {this.props.content.tiles.showTitles && ( + <div className="host">{title || label}</div> + )} + </div> + ))} + </div> + </div> + ) : null; + case "theme": + return this.props.content.tiles.data ? ( + <div className="tiles-theme-container"> + <div> + <fieldset className="tiles-theme-section"> + <Localized text={this.props.content.subtitle}> + <legend className="sr-only" /> + </Localized> + {this.props.content.tiles.data.map( + ({ theme, label, tooltip, description }) => ( + <Localized + key={theme + label} + text={typeof tooltip === "object" ? tooltip : {}} + > + <label + className={`theme${ + theme === this.props.activeTheme ? " selected" : "" + }`} + title={theme + label} + > + <Localized + text={ + typeof description === "object" ? description : {} + } + > + <input + type="radio" + value={theme} + name="theme" + checked={theme === this.props.activeTheme} + className="sr-only input" + onClick={this.handleAction} + data-l10n-attrs="aria-description" + /> + </Localized> + <div className={`icon ${theme}`} /> + {label && ( + <Localized text={label}> + <div className="text" /> + </Localized> + )} + </label> + </Localized> + ) + )} + </fieldset> + </div> + </div> + ) : null; + case "video": + return this.props.content.tiles.source ? ( + <div + className={`tiles-media-section ${this.props.content.tiles.media_type}`} + > + <div className="fade" /> + <video + className="media" + autoPlay="true" + loop="true" + muted="true" + src={ + AboutWelcomeUtils.hasDarkMode() + ? this.props.content.tiles.source.dark + : this.props.content.tiles.source.default + } + /> + </div> + ) : null; + case "image": + return this.props.content.tiles.source ? ( + <div className={`${this.props.content.tiles.media_type}`}> + <img + src={ + AboutWelcomeUtils.hasDarkMode() && + this.props.content.tiles.source.dark + ? this.props.content.tiles.source.dark + : this.props.content.tiles.source.default + } + role="presentation" + alt="" + /> + </div> + ) : null; + } + return null; + } + + renderStepsIndicator() { + let steps = []; + for (let i = 0; i < this.props.totalNumberOfScreens; i++) { + let className = i === this.props.order ? "current" : ""; + steps.push(<div key={i} className={`indicator ${className}`} />); + } + return steps; + } + + renderHelpText() { + return ( + <Localized text={this.props.content.help_text.text}> + <p + id="helptext" + className={`helptext ${this.props.content.help_text.position}`} + /> + </Localized> + ); + } + + render() { + const { content, topSites } = this.props; + const hasSecondaryTopCTA = + content.secondary_button && content.secondary_button.position === "top"; + const showImportableSitesDisclaimer = + content.tiles && + content.tiles.type === "topsites" && + topSites && + topSites.showImportable; + + return ( + <main className={`screen ${this.props.id}`}> + {hasSecondaryTopCTA ? this.renderSecondaryCTA("top") : null} + <div className={`brand-logo ${hasSecondaryTopCTA ? "cta-top" : ""}`} /> + <div className="welcome-text"> + <Zap hasZap={content.zap} text={content.title} /> + <Localized text={content.subtitle}> + <h2 /> + </Localized> + </div> + {content.tiles ? this.renderTiles() : null} + <div> + <Localized + text={content.primary_button ? content.primary_button.label : null} + > + <button + className="primary" + value="primary_button" + onClick={this.handleAction} + /> + </Localized> + </div> + {content.secondary_button && content.secondary_button.position !== "top" + ? this.renderSecondaryCTA() + : null} + {content.help_text && content.help_text.position === "default" + ? this.renderHelpText() + : null} + <nav + className={ + (content.help_text && content.help_text.position === "footer") || + showImportableSitesDisclaimer + ? "steps has-helptext" + : "steps" + } + data-l10n-id={"onboarding-welcome-steps-indicator"} + data-l10n-args={`{"current": ${parseInt(this.props.order, 10) + + 1}, "total": ${this.props.totalNumberOfScreens}}`} + > + {/* These empty elements are here to help trigger the nav for screen readers. */} + <br /> + <p /> + {this.renderStepsIndicator()} + </nav> + {(content.help_text && content.help_text.position === "footer") || + showImportableSitesDisclaimer + ? this.renderHelpText() + : null} + </main> + ); + } +} 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..2d5501dc46 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/ReturnToAMO.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 React from "react"; +import { + AboutWelcomeUtils, + DEFAULT_RTAMO_CONTENT, +} from "../../lib/aboutwelcome-utils"; +import { Localized } from "./MSLocalized"; + +export class ReturnToAMO extends React.PureComponent { + constructor(props) { + super(props); + this.onClickAddExtension = this.onClickAddExtension.bind(this); + this.handleStartBtnClick = this.handleStartBtnClick.bind(this); + } + + onClickAddExtension() { + const { content, message_id, url } = this.props; + if (!content?.primary_button?.action?.data) { + return; + } + + // Set add-on url in action.data.url property from JSON + content.primary_button.action.data.url = url; + AboutWelcomeUtils.handleUserAction(content.primary_button.action); + const ping = { + event: "INSTALL", + event_context: { + source: "ADD_EXTENSION_BUTTON", + page: "about:welcome", + }, + message_id, + }; + window.AWSendEventTelemetry(ping); + } + + handleStartBtnClick() { + const { content, message_id } = this.props; + AboutWelcomeUtils.handleUserAction(content.startButton.action); + const ping = { + event: "CLICK_BUTTON", + event_context: { + source: content.startButton.message_id, + page: "about:welcome", + }, + message_id, + }; + window.AWSendEventTelemetry(ping); + } + + render() { + const { content } = this.props; + if (!content) { + return null; + } + // 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"> + <main className="screen"> + <div className="brand-logo" /> + <div className="welcome-text"> + <Localized text={content.subtitle}> + <h1 /> + </Localized> + <Localized text={content.text}> + <h2 + data-l10n-args={ + this.props.name + ? JSON.stringify({ "addon-name": this.props.name }) + : null + } + > + <img + data-l10n-name="icon" + src={this.props.iconURL} + role="presentation" + alt="" + /> + </h2> + </Localized> + <Localized text={content.primary_button.label}> + <button onClick={this.onClickAddExtension} className="primary" /> + </Localized> + <Localized text={content.startButton.label}> + <button + onClick={this.handleStartBtnClick} + className="secondary" + /> + </Localized> + </div> + </main> + </div> + ); + } +} + +ReturnToAMO.defaultProps = DEFAULT_RTAMO_CONTENT; diff --git a/browser/components/newtab/content-src/aboutwelcome/components/SimpleAboutWelcome.jsx b/browser/components/newtab/content-src/aboutwelcome/components/SimpleAboutWelcome.jsx new file mode 100644 index 0000000000..4186cb2d69 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/SimpleAboutWelcome.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"; +import { HeroText } from "./HeroText"; +import { FxCards } from "./FxCards"; +import { Localized } from "./MSLocalized"; + +export class SimpleAboutWelcome extends React.PureComponent { + render() { + const { props } = this; + return ( + <div className="outer-wrapper welcomeContainer"> + <div className="welcomeContainerInner"> + <main> + <HeroText title={props.title} subtitle={props.subtitle} /> + <FxCards + cards={props.cards} + metricsFlowUri={this.props.metricsFlowUri} + sendTelemetry={window.AWSendEventTelemetry} + utm_term={this.props.UTMTerm} + /> + <Localized text={props.startButton.label}> + <button + className="start-button" + onClick={this.props.handleStartBtnClick} + /> + </Localized> + </main> + </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..a49bfa7dce --- /dev/null +++ b/browser/components/newtab/content-src/activity-stream.jsx @@ -0,0 +1,54 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { actionCreators as ac, actionTypes as at } from "common/Actions.jsm"; +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.jsm"; + +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..0ad8999ebc --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/asrouter-content.jsx @@ -0,0 +1,326 @@ +/* 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.jsm"; +import { actionTypes as at } from "common/Actions.jsm"; +import { ASRouterUtils } from "./asrouter-utils"; +import { generateBundles } from "./rich-text-strings"; +import { ImpressionsWrapper } from "./components/ImpressionsWrapper/ImpressionsWrapper"; +import { LocalizationProvider } from "fluent-react"; +import { NEWTAB_DARK_THEME } from "content-src/lib/constants"; +import 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); // eslint-disable-line no-console + } + + 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); // eslint-disable-line no-console + } + } catch (error) { + console.error(error); // eslint-disable-line no-console + } + 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); + } + + onBlockById(id, options) { + return ASRouterUtils.blockById(id, options).then(clearAll => { + if (clearAll) { + this.setState({ message: {} }); + } + }); + } + + onDismiss() { + this.clearMessage(this.state.message.id); + } + + clearMessage(id) { + if (id === this.state.message.id) { + 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) { + if ( + prevProps.adminContent && + JSON.stringify(prevProps.adminContent) !== + JSON.stringify(this.props.adminContent) + ) { + this.updateContent(); + } + } + + 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 } = this.state.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 bundles={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..fe7f0110f2 --- /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.jsm"; +import { actionCreators as ac } from "common/Actions.jsm"; + +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..330bfbb4fb --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/components/Button/_Button.scss @@ -0,0 +1,94 @@ +.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-button-primary-color); + background-color: var(--newtab-button-primary-color); + color: $grey-10; + + &:hover { + background-color: $blue-70; + } + + &:active { + background-color: $blue-80; + } + } + + &.secondary { + background-color: $grey-90-10; + + &:hover { + background-color: $grey-90-20; + } + + &:active { + background-color: $grey-90-30; + } + + &:focus { + box-shadow: 0 0 0 1px $blue-50 inset, 0 0 0 1px $blue-50, 0 0 0 4px $blue-50-30; + } + } + + &.slim { + background-color: $grey-90-10; + margin-inline-start: 0; + font-size: 12px; + padding: 6px 12px; + + &:hover { + background-color: $grey-90-20; + } + } +} + +[lwt-newtab-brighttext] { + .secondary { + background-color: $grey-10-10; + + &:hover { + background-color: $grey-10-20; + } + + &:active { + background-color: $grey-10-30; + } + } + + // Snippets scene 2 footer + .footer { + .secondary { + background-color: $grey-10-30; + + &:hover { + background-color: $grey-10-40; + } + + &:active { + background-color: $grey-10-50; + } + } + } +} 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..2cdbfcb1db --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/components/ModalOverlay/_ModalOverlay.scss @@ -0,0 +1,104 @@ +// 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-modal-color); + box-shadow: 0 1px 15px 0 $black-30; + 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: $grey-60; + 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: 1px solid $grey-30; + 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 { + box-shadow: 0 0 0 5px $grey-30; + transition: box-shadow 150ms; + } + } + } +} 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..45e35b83cc --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/components/RichText/RichText.jsx @@ -0,0 +1,83 @@ +/* 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} + 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} + {...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..cfa090f89b --- /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-snippets-background-color); + color: var(--newtab-text-primary-color); + font-size: 14px; + line-height: 20px; + border-top: 1px solid var(--newtab-snippets-hairline-color); + box-shadow: $shadow-secondary; + display: flex; + align-items: center; + + a { + cursor: pointer; + color: var(--newtab-link-primary-color); + + &: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; + @media (min-width: $break-point-large) { + padding-inline-end: $section-horizontal-padding; + } + + max-width: $wrapper-max-width-large + ($section-horizontal-padding * 2); + @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: 50%; + inset-inline-end: 12px; + height: 16px; + width: 16px; + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-dismiss-16.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: $grey-60-70; + background: $grey-30-60; + 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..035118b987 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/docs/debugging-docs.md @@ -0,0 +1,62 @@ +# 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 + +(Only available in Firefox 65+) + +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` +- 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/experiment-guide.md b/browser/components/newtab/content-src/asrouter/docs/experiment-guide.md new file mode 100644 index 0000000000..ac2784bb1f --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/docs/experiment-guide.md @@ -0,0 +1,52 @@ +# How to run experiments with ASRouter + +This guide will tell you how to run an experiment with ASRouter messages. +Note that the actual experiment process and infrastructure is handled by +the experiments team (#ask-experimenter). + +## Why run an experiment + +* To measure the effect of a message on a Firefox metric (e.g. retention) +* To test a potentially risky message on a smaller group of users +* To compare the performance of multiple variants of messages in a controlled way + +## Choose cohort IDs and request an experiment + +First you should decide on a cohort ID (this can be any arbitrary unique string) for each +individual group you need to segment for your experiment. + +For example, if I want to test two variants of an FXA Snippet, I might have two cohort IDs, +`FXA_SNIPPET_V1` and `FXA_SNIPPET_V2`. + +You will then [request](https://experimenter.services.mozilla.com/) a new "pref-flip" study with the Firefox Experiments team. +The preferences you will submit will be based on the cohort IDs you chose. + +For the FXA Snippet example, your preference name would be `browser.newtabpage.activity-stream.asrouter.providers.snippets` and values would be: + +Control (default value) +```json +{"id":"snippets","enabled":true,"type":"remote","url":"https://snippets.cdn.mozilla.net/%STARTPAGE_VERSION%/%NAME%/%VERSION%/%APPBUILDID%/%BUILD_TARGET%/%LOCALE%/release/%OS_VERSION%/%DISTRIBUTION%/%DISTRIBUTION_VERSION%/","updateCycleInMs":14400000} +``` + +Variant 1: +```json +{"id":"snippets", "cohort": "FXA_SNIPPET_V1", "enabled":true,"type":"remote","url":"https://snippets.cdn.mozilla.net/%STARTPAGE_VERSION%/%NAME%/%VERSION%/%APPBUILDID%/%BUILD_TARGET%/%LOCALE%/release/%OS_VERSION%/%DISTRIBUTION%/%DISTRIBUTION_VERSION%/","updateCycleInMs":14400000} +``` + +Variant 2: +```json +{"id":"snippets", "cohort": "FXA_SNIPPET_V1", "enabled":true,"type":"remote","url":"https://snippets.cdn.mozilla.net/%STARTPAGE_VERSION%/%NAME%/%VERSION%/%APPBUILDID%/%BUILD_TARGET%/%LOCALE%/release/%OS_VERSION%/%DISTRIBUTION%/%DISTRIBUTION_VERSION%/","updateCycleInMs":14400000} +``` + +## Add targeting to your messages + +You must now check for the cohort ID in the `targeting` expression of the messages you want to include in your experiments. + +For the previous example, you wold include the following to target the first cohort: + +```json +{ + "targeting": "providerCohorts.snippets == \"FXA_SNIPPET_V1\"" +} + +``` 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..82ccde3e39 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/docs/first-run.md @@ -0,0 +1,9 @@ +# First run on-boarding flow + +First Run flow describes the entire experience users have after Firefox has successfully been installed up until the first instance of new tab is shown. +First run help onboard new users by showing relevant messaging on about:welcome and about:newtab using triplets. + +### First Run Multistage +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 make first run looks like about:newtab and hides about:welcome 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..87476d32ac --- /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\: + +* What's New Panel - an icon in the toolbar and menu item that appears if a + message is available in the panel, usually after major Firefox releases +* 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 +* First Run - shown on startup in a content page as a set of onboarding cards + with calls to action that persist for several days +* Snippets - short messages that appear on New Tab Page to highlight products, + features and initiatives +* Badging - A colorful dot to highlight icons in the toolbar or menu items in + order to draw attention with minimal interruption + +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 + experiment-guide + 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..d553547420 --- /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..128ea2a0b3 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/docs/targeting-attributes.md @@ -0,0 +1,828 @@ +# 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 + +* [addonsInfo](#addonsinfo) +* [attributionData](#attributiondata) +* [browserSettings](#browsersettings) +* [currentDate](#currentdate) +* [devToolsOpenedCount](#devtoolsopenedcount) +* [isDefaultBrowser](#isdefaultbrowser) +* [firefoxVersion](#firefoxversion) +* [locale](#locale) +* [localeLanguageCode](#localelanguagecode) +* [needsUpdate](#needsupdate) +* [pinnedSites](#pinnedsites) +* [previousSessionEnd](#previoussessionend) +* [profileAgeCreated](#profileagecreated) +* [profileAgeReset](#profileagereset) +* [providerCohorts](#providercohorts) +* [region](#region) +* [searchEngines](#searchengines) +* [sync](#sync) +* [topFrecentSites](#topfrecentsites) +* [totalBookmarksCount](#totalbookmarkscount) +* [usesFirefoxSync](#usesfirefoxsync) +* [isFxAEnabled](#isFxAEnabled) +* [xpinstallEnabled](#xpinstallEnabled) +* [hasPinnedTabs](#haspinnedtabs) +* [hasAccessedFxAPanel](#hasaccessedfxapanel) +* [isWhatsNewPanelEnabled](#iswhatsnewpanelenabled) +* [totalBlockedCount](#totalblockedcount) +* [recentBookmarks](#recentbookmarks) +* [userPrefs](#userprefs) +* [attachedFxAOAuthClients](#attachedfxaoauthclients) +* [platformName](#platformname) +* [scores](#scores) +* [scoreThreshold](#scorethreshold) +* [messageImpressions](#messageimpressions) +* [blockedCountByType](#blockedcountbytype) +* [isChinaRepack](#ischinarepack) +* [userId](#userid) +* [profileRestartCount](#profilerestartcount) +* [homePageSettings](#homepagesettings) +* [newtabSettings](#newtabsettings) +* [isFissionExperimentEnabled](#isfissionexperimentenabled) +* [activeNotifications](#activenotifications) + +## 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` + +Includes two properties: +* `attribution`, which indicates how Firefox was downloaded - DEPRECATED - please use [attributionData](#attributiondata) +* `update`, which has information about how Firefox updates + +Note that attribution can be `undefined`, so you should check that it exists first. + +#### Examples +* Is updating enabled? +```java +browserSettings.update.enabled +``` + +#### 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; +``` + +### `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; +``` + +### `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/e0b0c38ee83f99d3cf868bad525ace4a395039f1/toolkit/modules/NewTabUtils.jsm#1087) + +#### 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/05a22d864814cb1e4352faa4004e1f975c7d2eb9/toolkit/modules/AppConstants.jsm#156). + +#### Definition + +``` +declare const platformName = "linux" | "win" | "macosx" | "android" | "other"; +``` + +### `scores` + +#### Definition + +See more in [CFR Machine Learning Experiment](https://bugzilla.mozilla.org/show_bug.cgi?id=1594422). + +``` +declare const scores = { [cfrId: string]: number (integer); } +``` + +### `scoreThreshold` + +#### Definition + +See more in [CFR Machine Learning Experiment](https://bugzilla.mozilla.org/show_bug.cgi?id=1594422). + +``` +declare const scoreThreshold = integer; +``` + +### `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: "", +} +``` + +### `isFissionExperimentEnabled` + +A boolean. `true` if we're running Fission experiment, `false` otherwise. + +### `activeNotifications` + +True when an infobar style message is displayed or when the awesomebar is +expanded to show a message (for example onboarding tips). 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..901756bca5 --- /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..6a52732ad1 --- /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 } from "fluent"; + +/** + * 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.addMessages(`${key} = ${string}`); + }); + return [bundle]; +} 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..64f30e7c49 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/schemas/message-group.schema.json @@ -0,0 +1,63 @@ +{ + "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": { + "oneOf": [ + { + "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"] + } + } + } + } + }, + "required": ["id", "enabled"], + "additionalProperties": false +} diff --git a/browser/components/newtab/content-src/asrouter/schemas/panel/cfr-fxa-bookmark.schema.json b/browser/components/newtab/content-src/asrouter/schemas/panel/cfr-fxa-bookmark.schema.json new file mode 100644 index 0000000000..2ea50d482d --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/schemas/panel/cfr-fxa-bookmark.schema.json @@ -0,0 +1,163 @@ +{ + "title": "CFRFxABookmark", + "description": "A message shown in the bookmark panel when user adds or edits a bookmark", + "version": "1.0.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": { + "description": "Shown at the top of the message in the largest font size.", + "oneOf": [ + { + "allOf": [ + {"$ref": "#/definitions/richText"}, + {"description": "Message to be shown"} + ] + }, + { + "type": "object", + "properties": { + "string_id": { + "type": "string", + "description": "Fluent id of localized string" + } + }, + "required": ["string_id"] + } + ] + }, + "text": { + "description": "Longest part of the message, below the title, provides explanation.", + "oneOf": [ + { + "allOf": [ + {"$ref": "#/definitions/richText"}, + {"description": "Message to be shown"} + ] + }, + { + "type": "object", + "properties": { + "string_id": { + "type": "string", + "description": "Fluent id of localized string" + } + }, + "required": ["string_id"] + } + ] + }, + "cta": { + "description": "Link shown at the bottom of the message, call to action", + "oneOf": [ + { + "allOf": [ + {"$ref": "#/definitions/richText"}, + {"description": "Message to be shown"} + ] + }, + { + "type": "object", + "properties": { + "string_id": { + "type": "string", + "description": "Fluent id of localized string" + } + }, + "required": ["string_id"] + } + ] + }, + "info_icon": { + "type": "object", + "description": "The small icon displayed in the top right corner of the panel. Not configurable, only the tooltip text." , + "properties": { + "tooltiptext": { + "oneOf": [ + { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Message to be shown"} + ] + }, + { + "type": "object", + "properties": { + "string_id": { + "type": "string", + "description": "Fluent id of localized string" + } + }, + "required": ["string_id"] + } + ] + } + }, + "required": ["tooltiptext"] + }, + "close_button": { + "type": "object", + "description": "The small dissmiss icon displayed in the top right corner of the message. Not configurable, only the tooltip text." , + "properties": { + "tooltiptext": { + "oneOf": [ + { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Message to be shown"} + ] + }, + { + "type": "object", + "properties": { + "string_id": { + "type": "string", + "description": "Fluent id of localized string" + } + }, + "required": ["string_id"] + } + ] + } + }, + "required": ["tooltiptext"] + }, + "color": { + "description": "Message text color", + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Valid CSS color"} + ] + }, + "background_color_1": { + "description": "Configurable background color through CSS gradient", + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Valid CSS color"} + ] + }, + "background_color_2": { + "description": "Configurable background color through CSS gradient", + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Valid CSS color"} + ] + } + }, + "additionalProperties": false, + "required": ["title", "text", "cta", "info_icon"] +} 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..76e2249d31 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/schemas/provider-response.schema.json @@ -0,0 +1,75 @@ +{ + "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" + }, + "personalized": { + "type": "boolean", + "description": "Is a personalized score applied to the provider's messages?" + }, + "personalizedModelVersion": { + "type": "string", + "description": "The version of the model use for personalization" + }, + "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..8d6109a968 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/template-utils.js @@ -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/. */ + +export function safeURI(url) { + if (!url) { + return ""; + } + const { protocol } = new URL(url); + const isAllowed = [ + "http:", + "https:", + "data:", + "resource:", + "chrome:", + ].includes(protocol); + if (!isAllowed) { + console.warn(`The protocol ${protocol} is not allowed for template URLs.`); // eslint-disable-line no-console + } + 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..5758efd686 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/CFR/templates/CFRUrlbarChiclet.schema.json @@ -0,0 +1,75 @@ +{ + "title": "CFRUrlbarChiclet", + "description": "A template with a chiclet button with text.", + "version": "1.0.0", + "type": "object", + "definitions": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "linkUrl": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + }, + "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": { + "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.", + "oneOf": [ + { + "type": "string", + "description": "Message shown in the location bar notification." + }, + { + "type": "object", + "properties": { + "string_id": { + "type": "string", + "description": "Id of localized string for the location bar notification." + } + }, + "required": ["string_id"] + } + ] + }, + "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.", + "allOf": [ + {"$ref": "#/definitions/linkUrl"}, + {"description": "Icon associated with the message"} + ] + }, + "where": { + "description": "Should it open in a new tab or the current tab", + "enum": ["current", "tabshifted"] + } + }, + "additionalProperties": "false", + "required": ["url", "where"] + } + }, + "additionalProperties": false, + "required": ["layout", "category", "bucket_id", "notification_text", "action"] +} 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..fd9e3acc0e --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/CFR/templates/ExtensionDoorhanger.schema.json @@ -0,0 +1,365 @@ +{ + "title": "ExtensionDoorhanger", + "description": "A template with a heading, addon icon, title and description. No markup allowed.", + "version": "1.0.0", + "type": "object", + "definitions": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "linkUrl": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + }, + "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", "message_and_animation", "icon_and_message", "addon_recommendation"] + }, + "anchor_id": { + "type": "string", + "description": "A 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." + }, + "notification_text": { + "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.", + "oneOf": [ + { + "type": "string", + "description": "Message shown in the location bar notification." + }, + { + "type": "object", + "properties": { + "string_id": { + "type": "string", + "description": "Id of localized string for the location bar notification." + } + }, + "required": ["string_id"] + } + ] + }, + "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": { + "type": "string", + "description": "Text for button tooltip used to provide information about the doorhanger." + } + }, + "required": ["tooltiptext"] + } + }, + "required": ["attributes"] + }, + { + "type": "object", + "properties": { + "string_id": { + "type": "string", + "description": "Id of localized string used to provide information about the doorhanger." + } + }, + "required": ["string_id"] + } + ] + }, + "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": { + "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.", + "oneOf": [ + { + "type": "string", + "description": "The message displayed in the title of the extension doorhanger" + }, + { + "type": "object", + "properties": { + "string_id": { + "type": "string" + } + }, + "required": ["string_id"], + "description": "Id of localized string for extension doorhanger title" + } + ] + }, + "icon": { + "description": "The icon displayed in the pop-over. Should be 32x32px or 64x64px and png/svg.", + "allOf": [ + {"$ref": "#/definitions/linkUrl"}, + {"description": "Icon associated with the message"} + ] + }, + "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": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Unique addon ID"} + ] + }, + "title": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Addon name"} + ] + }, + "author": { + "allOf": [ + {"$ref": "#/definitions/plainText"}, + {"description": "Addon author"} + ] + }, + "icon": { + "description": "The icon displayed in the pop-over. Should be 64x64px and png/svg.", + "allOf": [ + {"$ref": "#/definitions/linkUrl"}, + {"description": "Addon icon"} + ] + }, + "rating": { + "type": "number", + "minimum": 0, + "maximum": 5, + "description": "Star rating" + }, + "users": { + "type": "integer", + "minimum": 0, + "description": "Installed users" + }, + "amo_url": { + "allOf": [ + {"$ref": "#/definitions/linkUrl"}, + {"description": "Link that offers more information related to the addon."} + ] + } + }, + "required": ["title", "author", "icon", "amo_url"] + }, + "text": { + "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.", + "oneOf": [ + { + "type": "string", + "description": "Description message of the addon." + }, + { + "type": "object", + "properties": { + "string_id": { + "type": "string", + "description": "Id of string to localized addon description" + } + }, + "required": ["string_id"] + } + ] + }, + "descriptionDetails": { + "description": "Additional information and steps on how to use", + "type": "object", + "properties": { + "steps": { + "description": "Array of messages or string_ids", + "type": "array", + "items": { + "type": "object", + "properties": { + "string_id": { + "type": "string", + "description": "Id of string to localized addon description" + } + }, + "required": ["string_id"] + } + } + }, + "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": { + "allOf": [ + {"$ref": "#/definitions/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": "#/definitions/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": { + "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": "object", + "properties": { + "label": { + "type": "object", + "oneOf": [ + { + "properties": { + "value": { + "allOf": [ + {"$ref": "#/definitions/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": "#/definitions/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": "#/definitions/linkUrl"}, + {"description": "URL used in combination with the primary action dispatched."} + ] + } + } + } + } + } + } + } + } + } + } + }, + "additionalProperties": false, + "required": ["layout", "category", "bucket_id", "notification_text", "heading_text", "text", "buttons"] +} 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..3bbaa1ca4f --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/CFR/templates/InfoBar.schema.json @@ -0,0 +1,96 @@ +{ + "title": "InfoBar", + "description": "A template with an image, test and buttons.", + "version": "1.0.0", + "type": "object", + "definitions": { + "plainText": { + "description": "Plain text (no HTML allowed)", + "type": "string" + }, + "linkUrl": { + "description": "Target for links or buttons", + "type": "string", + "format": "uri" + } + }, + "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": { + "description": "The text show in the notification box.", + "oneOf": [ + { + "type": "string", + "description": "Message shown in the location bar notification." + }, + { + "type": "object", + "properties": { + "string_id": { + "type": "string", + "description": "Id of localized string for the location bar notification." + } + }, + "required": ["string_id"] + } + ] + }, + "buttons": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "description": "The text label of the button.", + "oneOf": [ + { + "type": "string", + "description": "Message content for the button." + }, + { + "type": "object", + "properties": { + "string_id": { + "type": "string", + "description": "Id of localized string for the button." + } + }, + "required": ["string_id"] + } + ] + }, + "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": false + } + }, + "required": ["label", "action", "accessKey"], + "additionalProperties": false + } + } + }, + "additionalProperties": false, + "required": ["text", "buttons"] +} 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..a82de98e09 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json @@ -0,0 +1,159 @@ +{ + "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..ef17606e80 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/_EOYSnippet.scss @@ -0,0 +1,54 @@ +.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: $grey-90; + margin-inline-end: 18px; + border: 1px solid $grey-40; + padding: 5px 14px; + background: $grey-10; + cursor: pointer; + } + + input { + &[type='radio'] { + opacity: 0; + margin-inline-end: -18px; + + &:checked + .donation-amount { + background: $grey-50; + color: $white; + border: 1px solid $grey-60; + } + + // accessibility + &:checked:focus + .donation-amount, + &:not(:checked):focus + .donation-amount { + border: 1px dotted var(--newtab-link-primary-color); + } + } + } + + .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..d7d3e37bbc --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.schema.json @@ -0,0 +1,187 @@ +{ + "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..cb29f66d6e --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/FirstRun/addUtmParams.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/. */ + +/** + * 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); + } + Object.keys(BASE_PARAMS).forEach(key => { + returnUrl.searchParams.append(key, BASE_PARAMS[key]); + }); + returnUrl.searchParams.append("utm_term", utmTerm); + return returnUrl; +} diff --git a/browser/components/newtab/content-src/asrouter/templates/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..eeb63554ed --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.schema.json @@ -0,0 +1,177 @@ +{ + "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/OnboardingMessage.jsx b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.jsx new file mode 100644 index 0000000000..ce1a840247 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.jsx @@ -0,0 +1,52 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +import { Localized } from "../../../aboutwelcome/components/MSLocalized"; + +export class OnboardingCard extends React.PureComponent { + constructor(props) { + super(props); + this.onClick = this.onClick.bind(this); + } + + onClick() { + const { props } = this; + const ping = { + event: "CLICK_BUTTON", + message_id: props.id, + id: props.UISurface, + }; + props.sendUserActionTelemetry(ping); + props.onAction(props.content.primary_button.action, props.message); + } + + render() { + const { content } = this.props; + const className = this.props.className || "onboardingMessage"; + return ( + <div className={className}> + <div className={`onboardingMessageImage ${content.icon}`} /> + <div className="onboardingContent"> + <span> + <Localized text={content.title}> + <h2 className="onboardingTitle" /> + </Localized> + <Localized text={content.text}> + <p className="onboardingText" /> + </Localized> + </span> + <span className="onboardingButtonContainer"> + <Localized text={content.primary_button.label}> + <button + className="button onboardingButton" + onClick={this.onClick} + /> + </Localized> + </span> + </div> + </div> + ); + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.schema.json b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.schema.json new file mode 100644 index 0000000000..f355d89da7 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.schema.json @@ -0,0 +1,142 @@ +{ + "title": "OnboardingMessage", + "description": "A template with a title, icon, button and description. No markup allowed.", + "version": "1.0.0", + "type": "object", + "properties": { + "title": { + "oneOf": [ + { + "type": "string", + "description": "The message displayed in the title of the onboarding card" + }, + { + "type": "object", + "properties": { + "string_id": { + "type": "string" + } + }, + "required": ["string_id"], + "description": "Id of localized string for onboarding card title" + } + ], + "description": "Id of localized string or message override." + }, + "text": { + "oneOf": [ + { + "type": "string", + "description": "The message displayed in the description of the onboarding card" + }, + { + "type": "object", + "properties": { + "string_id": { + "type": "string" + }, + "args": { + "type": "object", + "description": "An optional argument to pass to the localization module" + } + }, + "required": ["string_id"], + "description": "Id of localized string for onboarding card description" + } + ], + "description": "Id of localized string or message override." + }, + "icon": { + "allOf": [ + { + "type": "string", + "description": "Image associated with the onboarding card" + } + ] + }, + "primary_button": { + "type": "object", + "properties": { + "label": { + "oneOf": [ + { + "type": "string", + "description": "The label of the onboarding messages' action button" + }, + { + "type": "object", + "properties": { + "string_id": { + "type": "string" + } + }, + "required": ["string_id"], + "description": "Id of localized string for onboarding messages' button" + } + ], + "description": "Id of localized string or message override." + }, + "action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Action dispatched by the button." + }, + "data": { + "properties": { + "args": { + "type": "string", + "description": "Additional parameters for button action, for example which link the button should open." + } + } + } + } + } + } + }, + "secondary_buttons": { + "type": "object", + "properties": { + "label": { + "oneOf": [ + { + "type": "string", + "description": "The label of the onboarding messages' (optional) secondary action button" + }, + { + "type": "object", + "properties": { + "string_id": { + "type": "string" + } + }, + "required": ["string_id"], + "description": "Id of localized string for onboarding messages' button" + } + ], + "description": "Id of localized string or message override." + }, + "action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Action dispatched by the button." + }, + "data": { + "properties": { + "args": { + "type": "string", + "description": "Additional parameters for button action, for example which link the button should open." + } + } + } + } + } + } + } + }, + "additionalProperties": true, + "required": ["title", "text", "icon", "primary_button"] +} 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..b873e62b83 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json @@ -0,0 +1,39 @@ +{ + "title": "ToolbarBadgeMessage", + "description": "A template that specifies to which element in the browser toolbar to add a notification.", + "version": "1.1.0", + "type": "object", + "properties": { + "target": { + "type": "string" + }, + "action": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "additionalProperties": false, + "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": { + "type": "object", + "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'", + "properties": { + "string_id": { + "type": "string", + "description": "Fluent string id" + } + }, + "required": ["string_id"] + } + }, + "additionalProperties": false, + "required": ["target"] +} 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..7624c67d4c --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json @@ -0,0 +1,36 @@ +{ + "title": "UpdateActionMessage", + "description": "A template for messages that execute predetermined actions.", + "version": "1.0.0", + "type": "object", + "properties": { + "action": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "data": { + "type": "object", + "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" + } + } + }, + "description": "Additional data provided as argument when executing the action" + }, + "additionalProperties": false, + "description": "Optional action to take in addition to showing the notification" + }, + "additionalProperties": false, + "required": ["id", "action"] + }, + "additionalProperties": false, + "required": ["action"] +} 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..998f2cfc8d --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json @@ -0,0 +1,97 @@ +{ + "title": "WhatsNewMessage", + "description": "A template for the messages that appear in the What's New panel.", + "version": "1.2.0", + "type": "object", + "definitions": { + "localizableText": { + "oneOf": [ + { + "type": "string", + "description": "The string to be rendered." + }, + { + "type": "object", + "properties": { + "string_id": { + "type": "string" + } + }, + "required": ["string_id"], + "description": "Id of localized string to be rendered." + } + ] + } + }, + "properties": { + "layout": { + "description": "Different message layouts", + "enum": ["tracking-protections"] + }, + "layout_title_content_variable": { + "description": "Select what profile specific value to show for the current layout.", + "type": "string" + }, + "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": { + "allOf": [ + {"$ref": "#/definitions/localizableText"}, + {"description": "Id of localized string or message override of What's New message title"} + ] + }, + "subtitle": { + "allOf": [ + {"$ref": "#/definitions/localizableText"}, + {"description": "Id of localized string or message override of What's New message subtitle"} + ] + }, + "body": { + "allOf": [ + {"$ref": "#/definitions/localizableText"}, + {"description": "Id of localized string or message override of What's New message body"} + ] + }, + "link_text": { + "allOf": [ + {"$ref": "#/definitions/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": "uri" + }, + "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": { + "allOf": [ + {"$ref": "#/definitions/localizableText"}, + {"description": "Alt text for image."} + ] + } + }, + "additionalProperties": false, + "required": ["published_date", "title", "body", "cta_url", "bucket_id"], + "dependencies": { + "layout": ["layout_title_content_variable"] + } +} diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/_OnboardingMessage.scss b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/_OnboardingMessage.scss new file mode 100644 index 0000000000..d4109317a1 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/_OnboardingMessage.scss @@ -0,0 +1,131 @@ +@import '../../../styles/OnboardingImages'; + +.onboardingMessage { + height: 340px; + text-align: center; + padding: 13px; + font-weight: 200; + + // at 850px, img floats left, content floats right next to it + @media(max-width: 850px) { + height: 170px; + text-align: left; + padding: 10px; + border-bottom: 1px solid $grey-30; + display: flex; + margin-bottom: 11px; + + &:last-child { + border: 0; + } + + .onboardingContent { + padding-left: 10px; + height: 100%; + + > span > h3 { + margin-top: 0; + margin-bottom: 4px; + font-weight: 400; + } + + > span > p { + margin-top: 0; + line-height: 22px; + font-size: 15px; + } + } + } + + @media(max-width: 650px) { + height: 250px; + } + + .onboardingContent { + height: 175px; + + > span > h3 { + color: $grey-90; + margin-bottom: 8px; + font-weight: 400; + } + + > span > p { + color: $grey-60; + margin-top: 0; + height: 180px; + margin-bottom: 12px; + font-size: 15px; + line-height: 22px; + + @media(max-width: 650px) { + margin-bottom: 0; + height: 160px; + } + } + } + + .onboardingButton { + background-color: $grey-90-10; + border: 0; + width: 150px; + height: 30px; + margin-bottom: 23px; + padding: 4px 0 6px; + font-size: 15px; + + // at 850px, the button shimmies down and to the right + @media(max-width: 850px) { + float: right; + margin-top: -105px; + margin-inline-end: -10px; + } + + @media(max-width: 650px) { + float: none; + } + + &:focus, + &.active, + &:hover { + box-shadow: 0 0 0 5px $grey-30; + transition: box-shadow 150ms; + } + } + + + &::before { + content: ''; + height: 230px; + width: 1px; + position: absolute; + background-color: $grey-30; + margin-top: 40px; + margin-inline-start: 215px; + + // at 850px, the line goes from vertical to horizontal + @media(max-width: 850px) { + content: none; + } + } + + &:last-child::before { + content: none; + } +} + +.onboardingMessageImage { + height: 112px; + width: 180px; + background-size: auto 140px; + background-position: center center; + background-repeat: no-repeat; + display: inline-block; + + // Cards will wrap into the next line after this breakpoint + @media(max-width: 865px) { + height: 75px; + min-width: 80px; + background-size: 140px; + } +} 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..238840234a --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.schema.json @@ -0,0 +1,234 @@ +{ + "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..29addb688d --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/isEmailOrPhoneNumber.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/. */ + +/** + * 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..049f66ef6b --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json @@ -0,0 +1,110 @@ +{ + "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..dd9e637529 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/_SimpleBelowSearchSnippet.scss @@ -0,0 +1,198 @@ + +.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: var(--newtab-card-shadow); + flex-direction: column; + padding: 16px; + text-align: center; + width: 100%; + + @mixin full-width-styles { + align-items: flex-start; + background-color: transparent; + border-radius: 4px; + box-shadow: none; + flex-direction: row; + padding: 0; + text-align: inherit; + width: 696px; + } + + @media (min-width: $break-point-medium) { + @include full-width-styles; + } + + @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: 0 0 0 1px $blue-50 inset, 0 0 0 1px $blue-50, 0 0 0 4px $blue-50-30; + 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; + + @mixin full-width-styles { + height: 24px; + width: 24px; + } + + @media (min-width: $break-point-medium) { + @include full-width-styles; + } + + @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..8d7b8c1f7b --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx @@ -0,0 +1,225 @@ +/* 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..1229700d67 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json @@ -0,0 +1,155 @@ +{ + "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..b16f78dc93 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/_SimpleSnippet.scss @@ -0,0 +1,135 @@ +$section-header-height: 30px; +$icon-width: 54px; // width of primary icon + margin + +.SimpleSnippet { + &.tall { + padding: 27px 0; + } + + p em { + color: $grey-90; + font-style: normal; + background: $yellow-50; + } + + &.bold, + &.takeover { + .donation-form-url, + .donation-amount { + padding-top: 8px; + padding-bottom: 8px; + } + } + + &.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; + } + + &.takeover, + &.bold { + .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-section-header-text-color); + display: inline-block; + font-size: 13px; + font-weight: bold; + margin: 0; + + a { + color: var(--newtab-section-header-text-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..f3dcde11af --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormScene2Snippet.schema.json @@ -0,0 +1,163 @@ +{ + "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..d1f267f2fa --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.jsx @@ -0,0 +1,409 @@ +/* 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.log(err); // eslint-disable-line no-console + } + + 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 { + // eslint-disable-next-line no-console + 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..0fc3128d1c --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json @@ -0,0 +1,225 @@ +{ + "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..286366c12b --- /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-textbox-background-color); + 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-section-header-text-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/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..622e5a2b7b --- /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-link-primary-color); + + &: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..e0d8e45cc0 --- /dev/null +++ b/browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx @@ -0,0 +1,1936 @@ +/* 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.jsm"; +import { ASRouterUtils } from "../../asrouter/asrouter-utils"; +import { connect } from "react-redux"; +import React from "react"; +import { SimpleHashRouter } from "./SimpleHashRouter"; + +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.togglePersonalizationVersion = this.togglePersonalizationVersion.bind( + this + ); + } + + togglePersonalizationVersion() { + this.props.dispatch( + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_PERSONALIZATION_VERSION_TOGGLE, + }) + ); + } + + render() { + const { + lastUpdated, + version, + initialized, + } = this.props.state.Personalization; + return ( + <React.Fragment> + <button className="button" onClick={this.togglePersonalizationVersion}> + {version === 1 + ? "Enable V2 Personalization" + : "Enable V1 Personalization"} + </button> + <table> + <tbody> + <Row> + <td className="min">Personalization version</td> + <td>{version}</td> + </Row> + <Row> + <td className="min">Personalization Last Updated</td> + <td>{relativeTime(lastUpdated) || "(no data)"}</td> + </Row> + {version === 2 ? ( + <Row> + <td className="min">Personalization V2 Initialized</td> + <td>{initialized ? "true" : "false"}</td> + </Row> + ) : null} + </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 personalized collapsible".split( + " " + ); + const { config, lastUpdated, layout } = this.props.state.DiscoveryStream; + 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 + 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.handleUpdateWNMessages = this.handleUpdateWNMessages.bind(this); + this.handleForceWNP = this.handleForceWNP.bind(this); + this.handleCloseWNP = this.handleCloseWNP.bind(this); + this.resetPanel = this.resetPanel.bind(this); + this.restoreWNMessageState = this.restoreWNMessageState.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", + WNMessages: [], + collapsedMessages: [], + modifiedMessages: [], + evaluationStatus: {}, + stringTargetingParameters: null, + newStringTargetingParameters: null, + copiedToClipboard: false, + attributionParameters: { + source: "addons.mozilla.org", + medium: "referral", + campaign: "non-fx-button", + content: "iridium@particlecore.github.io", + 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), + ], + })); + } + + resetAllJSON() { + let messageCheckboxes = document.querySelectorAll('input[type="checkbox"]'); + + for (const checkbox of messageCheckboxes) { + let trimmedId = checkbox.id.replace(" checkbox", ""); + + let message = this.state.messages.filter(msg => msg.id === trimmedId); + let msgId = message[0].id; + + document.getElementById(`${msgId}-textarea`).value = JSON.stringify( + message[0], + null, + 2 + ); + } + this.setState({ + WNMessages: [], + }); + } + + resetPanel() { + this.resetAllJSON(); + this.handleCloseWNP(); + } + + handleOverride(id) { + return () => + ASRouterUtils.overrideMessage(id).then(state => { + this.setStateFromParent(state); + this.props.notifyContent({ + message: state.message, + }); + }); + } + + async handleUpdateWNMessages() { + await this.restoreWNMessageState(); + let messages = this.state.WNMessages; + + for (const msg of messages) { + ASRouterUtils.modifyMessageJson(JSON.parse(msg)); + } + } + + handleForceWNP() { + ASRouterUtils.sendMessage({ type: "FORCE_WHATSNEW_PANEL" }); + } + + handleCloseWNP() { + ASRouterUtils.sendMessage({ type: "CLOSE_WHATSNEW_PANEL" }); + } + + 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.log(`Error parsing value of parameter ${name}`); // eslint-disable-line no-console + 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); + + 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> + )} + <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> + ); + } + + restoreWNMessageState() { + // check the page for checked boxes, and reset the state of WNMessages based on that. + let tempState = []; + let messageCheckboxes = document.querySelectorAll('input[type="checkbox"]'); + // put the JSON of all the checked checkboxes in the array + for (const checkbox of messageCheckboxes) { + let trimmedId = checkbox.id.replace(" checkbox", ""); + let msg = document.getElementById(`${trimmedId}-textarea`).value; + + if (checkbox.checked) { + tempState.push(msg); + } + } + + this.setState({ + WNMessages: tempState, + }); + } + + 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, + }); + }); + } + + renderWNMessageItem(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="checkbox" + id={`${msg.id} checkbox`} + name={`${msg.id} checkbox`} + /> + </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 + ); + + 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. + </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> + ); + } + + renderWNMessages() { + if (!this.state.messages) { + return null; + } + const messagesToShow = this.state.messages.filter( + message => message.provider === "whats-new-panel" && message.content.body + ); + return ( + <table> + <tbody> + {messagesToShow.map(msg => this.renderWNMessageItem(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> + {/* eslint-disable-next-line prettier/prettier */} + Show messages from {/* eslint-disable-next-line jsx-a11y/no-onchange */} + <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.bucket})`; + } 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 'Iridium for Youtube'. 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 the addon GUID, 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="iridium@particlecore.github.io" + 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>; + } + + renderWNPTests() { + if (!this.state.messages) { + return null; + } + let messagesToShow = this.state.messages.filter( + message => message.provider === "whats-new-panel" + ); + + return ( + <div> + <p className="helpLink"> + <span className="icon icon-small-spacer icon-info" />{" "} + <span> + To correctly render selected messages, click 'Open What's New + Panel', select the messages you want to see, and click 'Render + Selected Messages'. + <br /> + <br /> + To modify a message, select it, modify the JSON and click 'Render + Selected Messages' again to see your changes. + <br /> + Click 'Reset Panel' to close the panel and reset all JSON to its + original state. + </span> + </p> + <div> + <button + className="ASRouterButton primary button" + onClick={this.handleForceWNP} + > + Open What's New Panel + </button> + <button + className="ASRouterButton secondary button" + onClick={this.handleUpdateWNMessages} + > + Render Selected Messages + </button> + <button + className="ASRouterButton secondary button" + onClick={this.resetPanel} + > + Reset Panel + </button> + <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.renderWNMessages()} + </div> + </div> + ); + } + + getSection() { + const [section] = this.props.location.routes; + switch (section) { + case "wnpanel": + return ( + <React.Fragment> + <h2>What's New Panel</h2> + {this.renderWNPTests()} + </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-wnpanel">What's New Panel</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://github.com/mozilla/activity-stream/blob/master/content-src/asrouter/docs/debugging-docs.md" + > + 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..0e12ec97e7 --- /dev/null +++ b/browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.scss @@ -0,0 +1,273 @@ +.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://browser/skin/developer.svg'); + padding: 15px; + } + + &:dir(rtl) { + transform: scaleX(-1); + } + + &:hover { + background: var(--newtab-element-hover-color); + } + + &.expanded { + background: $black-20; + } +} + +.asrouter-admin { + position: fixed; + top: 0; + inset-inline-start: 0; + width: 100%; + background: var(--newtab-background-color); + height: 100%; + overflow-y: scroll; + $border-color: var(--newtab-border-secondary-color); + $monospace: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Droid Sans Mono', + 'Source Code Pro', monospace; + $sidebar-width: 240px; + 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-textbox-background-color); + } + } + } + + 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: $grey-20-60; + 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-textbox-background-color); + } + + td { + padding: 4px; + + &[rowspan] { + border: 1px solid var(--newtab-textbox-background-color); + } + } + } + } + + .sourceLabel { + background: var(--newtab-textbox-background-color); + padding: 2px 5px; + border-radius: 3px; + + &.isDisabled { + background: $email-input-invalid; + color: $red-60; + } + } + + .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-textbox-background-color); + margin: 0; + padding: 8px; + font-size: 12px; + max-width: 750px; + overflow: auto; + font-family: $monospace; + } + + .errorState { + border: 1px solid $red-60; + } + + .helpLink { + padding: 10px; + display: flex; + background: $black-10; + border-radius: 3px; + + a { + text-decoration: underline; + } + } + + .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/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..9bb1193159 --- /dev/null +++ b/browser/components/newtab/content-src/components/Base/Base.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 { actionCreators as ac, actionTypes as at } from "common/Actions.jsm"; +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"; +import { CSSTransition } from "react-transition-group"; + +export const PrefsButton = ({ onClick, icon }) => ( + <div className="prefs-button"> + <button + className={`icon ${icon || "icon-settings"}`} + onClick={onClick} + data-l10n-id="newtab-settings-button" + /> + </div> +); + +export const PersonalizeButton = ({ onClick }) => ( + <button + className="personalize-button" + onClick={onClick} + data-l10n-id="newtab-personalize-button-label" + /> +); + +// 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, customizeMenuVisible: 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.setState({ customizeMenuVisible: true }); + this.props.dispatch(ac.UserEvent({ event: "SHOW_PERSONALIZE" })); + } + + closeCustomizationMenu() { + if (this.state.customizeMenuVisible) { + this.setState({ customizeMenuVisible: false }); + 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 } = App; + const prefs = props.Prefs.values; + + // Values from experiment data + const { prefsButtonIcon } = prefs.featureConfig || {}; + + 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 customizationMenuEnabled = prefs["customizationMenu.enabled"]; + const newNewtabExperienceEnabled = prefs["newNewtabExperience.enabled"]; + const canShowCustomizationMenu = + customizationMenuEnabled || newNewtabExperienceEnabled; + const showCustomizationMenu = + canShowCustomizationMenu && this.state.customizeMenuVisible; + const enabledSections = { + topSitesEnabled: prefs["feeds.topsites"], + pocketEnabled: prefs["feeds.section.topstories"], + snippetsEnabled: prefs["feeds.snippets"], + highlightsEnabled: prefs["feeds.section.highlights"], + showSponsoredTopSitesEnabled: prefs.showSponsoredTopSites, + showSponsoredPocketEnabled: prefs.showSponsored, + 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", + newNewtabExperienceEnabled && "newtab-experience", + ] + .filter(v => v) + .join(" "); + + return ( + <div> + {canShowCustomizationMenu ? ( + <span> + <PersonalizeButton onClick={this.openCustomizationMenu} /> + <CSSTransition + timeout={0} + classNames="customize-animate" + in={showCustomizationMenu} + appear={true} + > + <CustomizeMenu + onClose={this.closeCustomizationMenu} + openPreferences={this.openPreferences} + setPref={this.setPref} + enabledSections={enabledSections} + pocketRegion={pocketRegion} + mayHaveSponsoredTopSites={mayHaveSponsoredTopSites} + /> + </CSSTransition> + </span> + ) : ( + <PrefsButton onClick={this.openPreferences} icon={prefsButtonIcon} /> + )} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions*/} + <div className={outerClassName} onClick={this.closeCustomizationMenu}> + <main> + {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..a12e14cb95 --- /dev/null +++ b/browser/components/newtab/content-src/components/Base/_Base.scss @@ -0,0 +1,179 @@ +.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-link-primary-color); + } +} + +main { + margin: auto; + width: $wrapper-default-width; + // 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; + + 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; + } + +} + +.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: 68px; + + @media (min-width: $break-point-medium) { + width: 510px; + } + + @media (min-width: $break-point-large) { + width: 746px; + } + + @media (min-width: $break-point-widest) { + width: 986px; + } + } +} + +.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); + } + } +} + +@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 { + font-size: 12px; + font-weight: 600; + border: 0; + border-radius: 4px; + background-color: var(--newtab-background-button-color); + color: var(--newtab-background-button-text-color); + padding: 3px 10px; + min-height: 32px; + max-width: 117px; + + &:hover { + background-color: var(--newtab-background-button-hover-color); + } + + &:active { + background-color: var(--newtab-background-button-active-color); + } + + &:focus-visible { + @include ds-focus-nte; + } +} 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..3e2c5ace83 --- /dev/null +++ b/browser/components/newtab/content-src/components/Card/Card.jsx @@ -0,0 +1,354 @@ +/* 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.jsm"; +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 + await gImageLoading.get(imageUrl); + + // 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..64dd5c0cf6 --- /dev/null +++ b/browser/components/newtab/content-src/components/Card/_Card.scss @@ -0,0 +1,369 @@ +// Special styling for the New Tab Experience styles, +// This is to be incorporated once the styles are made permanent +.outer-wrapper.newtab-experience { + .card-outer { + border-radius: $border-radius-new; + + &:is(:focus):not(.placeholder) { + @include ds-focus-nte; + + transition: none; + } + + &:hover { + box-shadow: none; + transition: none; + } + + .card { + box-shadow: 0 3px 8px var(--newtab-card-first-shadow), 0 0 2px var(--newtab-card-second-shadow); + border-radius: $border-radius-new; + } + + .card-preview-image-outer { + border-radius: $border-radius-new $border-radius-new 0 0; + } + + // Temporary fix to have the context button focus blend in with other New Tab Experience context menu focus + .context-menu-button { + &:is(:active, :focus) { + outline: 0; + fill: var(--newtab-primary-action-background); + border: 1px solid var(--newtab-primary-action-background); + } + } + + > a { + &:is(:focus) { + .card { + @include ds-focus-nte; + } + } + } + } +} + +.outer-wrapper:not(.newtab-experience) { + .card-outer { + &:is(:hover, :focus, .active):not(.placeholder) { + @include fade-in-card; + } + } +} + +.card-outer { + @include context-menu-button; + background: var(--newtab-card-background-color); + border-radius: $border-radius; + display: inline-block; + height: $card-height; + margin-inline-end: $base-gutter; + position: relative; + width: 100%; + + &.placeholder { + background: transparent; + + .card { + box-shadow: inset $inner-box-shadow; + } + + .card-preview-image-outer, + .card-context { + display: none; + } + } + + .card { + border-radius: $border-radius; + box-shadow: var(--newtab-card-shadow); + height: 100%; + } + + > a { + color: inherit; + display: block; + height: 100%; + outline: none; + position: absolute; + width: 100%; + + &:is(.active, :focus) { + .card { + @include fade-in-card; + } + + .card-title { + color: var(--newtab-link-primary-color); + } + } + } + + &:is(:hover, :focus, .active):not(.placeholder) { + @include context-menu-button-hover; + outline: none; + + .card-title { + color: var(--newtab-link-primary-color); + } + + .alternate ~ .card-host-name { + display: none; + } + + .card-host-name.alternate { + display: block; + } + } + + .card-preview-image-outer { + background-color: $grey-30; + border-radius: $border-radius $border-radius 0 0; + height: $card-preview-image-height; + overflow: hidden; + position: relative; + + [lwt-newtab-brighttext] & { + background-color: $grey-60; + } + + &::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; // sass-lint:disable-line no-disallowed-properties + 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-card-background-color); + border-radius: $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: ($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 - $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: ($container-size / 2) + 1 ($container-size / 2) + 1 0 0; + content: ''; + position: absolute; + height: ($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..e52c2a8da9 --- /dev/null +++ b/browser/components/newtab/content-src/components/CollapsibleSection/CollapsibleSection.jsx @@ -0,0 +1,342 @@ +/* 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.jsm"; +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"; +import { SectionMenu } from "content-src/components/SectionMenu/SectionMenu"; +import { SectionMenuOptions } from "content-src/lib/section-menu-options"; +import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton"; + +const VISIBLE = "visible"; +const VISIBILITY_CHANGE_EVENT = "visibilitychange"; + +export class _CollapsibleSection extends React.PureComponent { + constructor(props) { + super(props); + this.onBodyMount = this.onBodyMount.bind(this); + this.collapseOrExpandSection = this.collapseOrExpandSection.bind(this); + this.onHeaderClick = this.onHeaderClick.bind(this); + this.onKeyPress = this.onKeyPress.bind(this); + this.onTransitionEnd = this.onTransitionEnd.bind(this); + this.enableOrDisableAnimation = this.enableOrDisableAnimation.bind(this); + this.onMenuButtonMouseEnter = this.onMenuButtonMouseEnter.bind(this); + this.onMenuButtonMouseLeave = this.onMenuButtonMouseLeave.bind(this); + this.onMenuUpdate = this.onMenuUpdate.bind(this); + this.state = { + enableAnimation: true, + isAnimating: false, + menuButtonHover: false, + showContextMenu: false, + }; + this.setContextMenuButtonRef = this.setContextMenuButtonRef.bind(this); + } + + componentWillMount() { + this.props.document.addEventListener( + VISIBILITY_CHANGE_EVENT, + this.enableOrDisableAnimation + ); + } + + componentWillUpdate(nextProps) { + // Check if we're about to go from expanded to collapsed + if (!this.props.collapsed && nextProps.collapsed) { + // This next line forces a layout flush of the section body, which has a + // max-height style set, so that the upcoming collapse animation can + // animate from that height to the collapsed height. Without this, the + // update is coalesced and there's no animation from no-max-height to 0. + this.sectionBody.scrollHeight; // eslint-disable-line no-unused-expressions + } + } + + setContextMenuButtonRef(element) { + this.contextMenuButtonRef = element; + } + + componentDidMount() { + if (!this.props.Prefs.values["newNewtabExperience.enabled"]) { + this.contextMenuButtonRef.addEventListener( + "mouseenter", + this.onMenuButtonMouseEnter + ); + this.contextMenuButtonRef.addEventListener( + "mouseleave", + this.onMenuButtonMouseLeave + ); + } + } + + componentWillUnmount() { + this.props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this.enableOrDisableAnimation + ); + + if (!this.props.Prefs.values["newNewtabExperience.enabled"]) { + this.contextMenuButtonRef.removeEventListener( + "mouseenter", + this.onMenuButtonMouseEnter + ); + this.contextMenuButtonRef.removeEventListener( + "mouseleave", + this.onMenuButtonMouseLeave + ); + } + } + + enableOrDisableAnimation() { + // Only animate the collapse/expand for visible tabs. + const visible = this.props.document.visibilityState === VISIBLE; + if (this.state.enableAnimation !== visible) { + this.setState({ enableAnimation: visible }); + } + } + + onBodyMount(node) { + this.sectionBody = node; + } + + collapseOrExpandSection() { + // If this.sectionBody is unset, it means that we're in some sort of error + // state, probably displaying the error fallback, so we won't be able to + // compute the height, and we don't want to persist the preference. + if (!this.sectionBody) { + return; + } + + // Get the current height of the body so max-height transitions can work + this.setState({ + isAnimating: true, + maxHeight: `${this._getSectionBodyHeight()}px`, + }); + const { action } = SectionMenuOptions.CheckCollapsed(this.props); + this.props.dispatch(action); + } + + onHeaderClick() { + // If the new new tab experience pref is turned on, + // sections should not be collapsible. + // If this.sectionBody is unset, it means that we're in some sort of error + // state, probably displaying the error fallback, so we won't be able to + // compute the height, and we don't want to persist the preference. + // If props.collapsed is undefined handler shouldn't do anything. + if ( + this.props.Prefs.values["newNewtabExperience.enabled"] || + !this.sectionBody || + this.props.collapsed === undefined + ) { + return; + } + + this.collapseOrExpandSection(); + const { userEvent } = SectionMenuOptions.CheckCollapsed(this.props); + this.props.dispatch( + ac.UserEvent({ + event: userEvent, + source: this.props.eventSource, + }) + ); + } + + onKeyPress(event) { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + this.onHeaderClick(); + } + } + + _getSectionBodyHeight() { + const div = this.sectionBody; + if (div.style.display === "none") { + // If the div isn't displayed, we can't get it's height. So we display it + // to get the height (it doesn't show up because max-height is set to 0px + // in CSS). We don't undo this because we are about to expand the section. + div.style.display = "block"; + } + return div.scrollHeight; + } + + onTransitionEnd(event) { + // Only update the animating state for our own transition (not a child's) + if (event.target === event.currentTarget) { + this.setState({ isAnimating: false }); + } + } + + renderIcon() { + const { icon } = this.props; + if (icon && icon.startsWith("moz-extension://")) { + return ( + <span + className="icon icon-small-spacer" + style={{ backgroundImage: `url('${icon}')` }} + /> + ); + } + return ( + <span + className={`icon icon-small-spacer icon-${icon || "webextension"}`} + /> + ); + } + + onMenuButtonMouseEnter() { + this.setState({ menuButtonHover: true }); + } + + onMenuButtonMouseLeave() { + this.setState({ menuButtonHover: false }); + } + + onMenuUpdate(showContextMenu) { + this.setState({ showContextMenu }); + } + + render() { + const isCollapsible = this.props.collapsed !== undefined; + const isNewNewtabExperienceEnabled = this.props.Prefs.values[ + "newNewtabExperience.enabled" + ]; + + // If new new tab prefs are set to true, sections should not be + // collapsible. Expand and make the section visible, if it has been + // previously collapsed. + if (isNewNewtabExperienceEnabled && this.props.collapsed) { + this.collapseOrExpandSection(); + } + + const { + enableAnimation, + isAnimating, + maxHeight, + menuButtonHover, + showContextMenu, + } = this.state; + const { + id, + eventSource, + collapsed, + learnMore, + title, + extraMenuOptions, + showPrefName, + privacyNoticeURL, + dispatch, + isFixed, + isFirst, + isLast, + isWebExtension, + } = 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" }; + } + return ( + <section + className={`collapsible-section ${this.props.className}${ + enableAnimation ? " animation-enabled" : "" + }${collapsed ? " collapsed" : ""}${active ? " active" : ""}`} + aria-expanded={!collapsed} + // 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" style={titleStyle}> + <span className="click-target-container"> + {/* Click-targets that toggle a collapsible section should have an aria-expanded attribute; see bug 1553234 */} + <span + className="click-target" + role="button" + tabIndex="0" + onKeyPress={this.onKeyPress} + onClick={this.onHeaderClick} + > + {!isNewNewtabExperienceEnabled && this.renderIcon()} + <FluentOrText message={title} /> + {!isNewNewtabExperienceEnabled && isCollapsible && ( + <span + data-l10n-id={ + collapsed + ? "newtab-section-expand-section-label" + : "newtab-section-collapse-section-label" + } + className={`collapsible-arrow icon ${ + collapsed + ? "icon-arrowhead-forward-small" + : "icon-arrowhead-down-small" + }`} + /> + )} + </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> + </span> + </h3> + {!isNewNewtabExperienceEnabled && ( + <div> + <ContextMenuButton + tooltip="newtab-menu-section-tooltip" + onUpdate={this.onMenuUpdate} + refFunction={this.setContextMenuButtonRef} + > + <SectionMenu + id={id} + extraOptions={extraMenuOptions} + source={eventSource} + showPrefName={showPrefName} + privacyNoticeURL={privacyNoticeURL} + collapsed={collapsed} + isFixed={isFixed} + isFirst={isFirst} + isLast={isLast} + dispatch={dispatch} + isWebExtension={isWebExtension} + /> + </ContextMenuButton> + </div> + )} + </div> + <ErrorBoundary className="section-body-fallback"> + <div + className={`section-body${isAnimating ? " animating" : ""}`} + onTransitionEnd={this.onTransitionEnd} + 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..89aacaccfb --- /dev/null +++ b/browser/components/newtab/content-src/components/CollapsibleSection/_CollapsibleSection.scss @@ -0,0 +1,188 @@ +.outer-wrapper.newtab-experience { + .collapsible-section { + &[data-section-id='topsites'] { + .section-top-bar { + display: none; + } + } + + .click-target-container { + .click-target { + span { + cursor: default; + font-weight: 600; + font-size: 17px; + color: var(--newtab-background-primary-text-color); + } + } + } + } +} + +.outer-wrapper:not(.newtab-experience) { + .collapsible-section { + .section-title { + span { + vertical-align: middle; + } + } + } +} + +.collapsible-section { + padding: $section-vertical-padding $section-horizontal-padding; + transition-delay: 100ms; + transition-duration: 100ms; + transition-property: background-color; + + .section-title { + font-size: $section-title-font-size; + font-weight: bold; + margin: 0; + + &.grey-title, + span { + color: var(--newtab-section-header-text-color); + display: inline-block; + fill: var(--newtab-section-header-text-color); + } + + &.grey-title { + vertical-align: middle; + } + + .click-target-container { + // 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; + + .click-target { + white-space: nowrap; + cursor: pointer; + } + } + + .collapsible-arrow { + margin-inline-start: 8px; + margin-top: -1px; + } + } + + .section-top-bar { + min-height: 19px; + margin-bottom: 13px; + position: relative; + + .context-menu-button { + background: url('chrome://global/skin/icons/more.svg') no-repeat right center; + border: 0; + cursor: pointer; + fill: var(--newtab-section-header-text-color); + height: 100%; + inset-inline-end: 0; + opacity: 0; + position: absolute; + top: 0; + transition-duration: 200ms; + transition-property: opacity; + width: $context-menu-button-size; + + &:is(:active, :focus, :hover) { + fill: var(--newtab-section-header-text-color); + opacity: 1; + } + } + + .context-menu { + top: 16px; + } + + @media (max-width: $break-point-widest + $card-width * 1.5) { + @include context-menu-open-left; + } + } + + &:hover, + &.active { + .section-top-bar { + .context-menu-button { + opacity: 1; + } + } + } + + &.active { + background: var(--newtab-element-hover-color); + border-radius: 4px; + + .section-top-bar { + .context-menu-button { + fill: var(--newtab-section-active-contextmenu-color); + } + } + } + + .learn-more-link { + font-size: 11px; + margin-inline-start: 12px; + + a { + color: var(--newtab-link-secondary-color); + } + } + + .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; + } + } + + &.animation-enabled { + .section-title { + .collapsible-arrow { + transition: transform 0.5s $photon-easing; + } + } + + .section-body { + transition: max-height 0.5s $photon-easing; + } + } + + &.collapsed { + .section-body { + max-height: 0; + overflow: hidden; + } + } + + // 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..169632c2c5 --- /dev/null +++ b/browser/components/newtab/content-src/components/ComponentPerfTimer/ComponentPerfTimer.jsx @@ -0,0 +1,174 @@ +/* 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.jsm"; +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..b2e80b8645 --- /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.jsm"; +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..1bdb41dbb7 --- /dev/null +++ b/browser/components/newtab/content-src/components/ConfirmDialog/_ConfirmDialog.scss @@ -0,0 +1,68 @@ +.confirmation-dialog { + .modal { + box-shadow: 0 2px 2px 0 $black-10; + 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-modal-color); + 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..f2f3fde03f --- /dev/null +++ b/browser/components/newtab/content-src/components/ContextMenu/ContextMenu.jsx @@ -0,0 +1,179 @@ +/* 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; + const isNewNewtabExperienceEnabled = this.props.Prefs.values[ + "newNewtabExperience.enabled" + ]; + 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} + > + {!isNewNewtabExperienceEnabled && option.icon && ( + <span className={`icon icon-spacer icon-${option.icon}`} /> + )} + <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..bf6e602de7 --- /dev/null +++ b/browser/components/newtab/content-src/components/ContextMenu/_ContextMenu.scss @@ -0,0 +1,55 @@ +.context-menu { + background: var(--newtab-contextmenu-background-color); + 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: ($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-hover-color); + } + + &:active { + background: var(--newtab-element-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..b6c8b43f51 --- /dev/null +++ b/browser/components/newtab/content-src/components/CustomizeMenu/ContentSection/ContentSection.jsx @@ -0,0 +1,277 @@ +/* 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.jsm"; + +export class ContentSection extends React.PureComponent { + constructor(props) { + super(props); + this.onPreferenceSelect = this.onPreferenceSelect.bind(this); + } + + 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"); + 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); + } + + render() { + const { + topSitesEnabled, + pocketEnabled, + highlightsEnabled, + snippetsEnabled, + showSponsoredTopSitesEnabled, + showSponsoredPocketEnabled, + topSitesRowsCount, + } = this.props.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 ${ + topSitesEnabled ? "" : "shrink" + }`} + > + <div + className={`more-information ${ + topSitesEnabled ? "expand" : "shrink" + }`} + > + <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> + {this.props.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> + + {this.props.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" + /> + {this.props.mayHaveSponsoredStories && ( + <div + className={`more-info-pocket-wrapper ${ + pocketEnabled ? "" : "shrink" + }`} + > + <div + className={`more-information ${ + pocketEnabled ? "expand" : "shrink" + }`} + > + <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> + </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> + + <div id="snippets-section" className="section"> + <label className="switch"> + <input + id="snippets-toggle" + checked={snippetsEnabled} + type="checkbox" + onChange={this.onPreferenceSelect} + preference="feeds.snippets" + aria-labelledby="custom-snippets-title" + aria-describedby="custom-snippets-subtitle" + eventSource="SNIPPETS" + /> + <span className="slider" role="presentation"></span> + </label> + <div> + <h2 id="custom-snippets-title" className="title"> + <label + htmlFor="snippets-toggle" + data-l10n-id="newtab-custom-snippets-title" + ></label> + </h2> + <p + id="custom-snippets-subtitle" + className="subtitle" + data-l10n-id="newtab-custom-snippets-subtitle" + /> + </div> + </div> + + <span className="divider" role="separator"></span> + + <div> + <button + id="settings-link" + className="external-link" + onClick={this.props.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..8e08efef2b --- /dev/null +++ b/browser/components/newtab/content-src/components/CustomizeMenu/CustomizeMenu.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 { ThemesSection } from "content-src/components/CustomizeMenu/ThemesSection/ThemesSection"; +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"; + +export class _CustomizeMenu extends React.PureComponent { + render() { + return ( + <div className="customize-menu"> + <button + onClick={this.props.onClose} + className="close-button" + data-l10n-id="newtab-custom-close-button" + /> + <ThemesSection /> + <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} + dispatch={this.props.dispatch} + /> + </div> + ); + } +} + +export const CustomizeMenu = connect(state => ({ + DiscoveryStream: state.DiscoveryStream, +}))(_CustomizeMenu); diff --git a/browser/components/newtab/content-src/components/CustomizeMenu/ThemesSection/ThemesSection.jsx b/browser/components/newtab/content-src/components/CustomizeMenu/ThemesSection/ThemesSection.jsx new file mode 100644 index 0000000000..e220b93b00 --- /dev/null +++ b/browser/components/newtab/content-src/components/CustomizeMenu/ThemesSection/ThemesSection.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 ThemesSection extends React.PureComponent { + render() { + return <div />; + } +} 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..d4dfd37a9c --- /dev/null +++ b/browser/components/newtab/content-src/components/CustomizeMenu/_CustomizeMenu.scss @@ -0,0 +1,297 @@ + +.customize-menu { + color: var(--customize-menu-primary-text-color); + background-color: var(--customize-menu-background); + width: 432px; + height: 100%; + position: fixed; + inset-block: 0; + inset-inline-end: 0; + z-index: 1001; + padding: 16px; + transition: transform 250ms $customize-menu-slide-bezier; + overflow: auto; + transform: translateX(435px); + visibility: hidden; + cursor: default; + + &:dir(rtl) { + transform: translateX(-435px); + } + + &.customize-animate-enter-done { + transform: translateX(0); + } + + &.customize-animate-enter-done, + &.customize-animate-enter-active, + &.customize-animate-enter { + box-shadow: 0 0 64px var(--customize-menu-first-shadow), 0 0 24px var(--customize-menu-second-shadow); + transition: transform 250ms $customize-menu-slide-bezier, visibility 1ms; + visibility: visible; + } + + &.customize-animate-exit-active, + &.customize-animate-exit { + box-shadow: 0 0 64px var(--customize-menu-first-shadow), 0 0 24px var(--customize-menu-second-shadow); + transition: transform 250ms $customize-menu-slide-bezier, visibility 1ms 250ms; + visibility: visible; + } + + &.customize-animate-exit-done { + transform: translateX(435px); + transition: transform 250ms $customize-menu-slide-bezier, visibility 1ms 250ms; + + &:dir(rtl) { + transform: translateX(-435px); + } + } + + .close-button { + margin-inline-start: auto; + margin-bottom: 28px; + white-space: nowrap; + display: block; + background-color: var(--customize-menu-secondary-action-background); + padding: 8px 10px; + border: $customize-menu-border-tint; + border-radius: 4px; + color: var(--customize-menu-primary-text-color); + font-size: 13px; + font-weight: 600; + } + + .close-button:hover { + background-color: var(--customize-menu-secondary-action-background-hover); + } + + .close-button:hover:active { + background-color: var(--customize-menu-secondary-action-background-active); + } +} + +.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-primary-action-background-off); + } + + .sponsored-checkbox:checked { + -moz-context-properties: fill; + fill: var(--customize-menu-check-fill); + background: url('chrome://global/skin/icons/check.svg') center no-repeat; + background-color: var(--newtab-primary-action-background); + background-size: 10px; + } + + .sponsored-checkbox:active + .checkmark { + fill: $newtab-card-tint; + } + + .selector { + color: var(--customize-menu-primary-text-color); + width: 118px; + display: block; + border: 1px solid var(--customize-menu-line-color); + border-radius: 4px; + appearance: none; + padding-block: 7px; + padding-inline: 10px 13px; + margin-inline-start: 2px; + -moz-context-properties: fill; + fill: var(--customize-menu-primary-text-color); + background: url('#{$image-path}glyph-arrowhead-down-12.svg') right no-repeat; + background-size: 8px; + background-origin: content-box; + background-color: var(--customize-menu-background); + + &: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-primary-action-background-off); + + &::before { + position: absolute; + content: ''; + height: 8px; + width: 8px; + inset-inline-start: 3px; + bottom: 3px; + background-color: var(--customize-menu-primary-action-text); + transition: transform 250ms; + border-radius: 50%; + outline: $customize-menu-border-tint; + -moz-outline-radius: 11px; + } + } + + .switch input:focus-visible + .slider { + border: 1px solid var(--newtab-focus-border-selected); + outline: 0; + box-shadow: 0 0 0 2px var(--newtab-focus-outline); + } + + .switch input:not(:checked):focus-visible + .slider { + border: 1px solid var(--newtab-focus-border); + } + + 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; + transition: max-height 250ms $customize-menu-expand-bezier; + + &.shrink { + max-height: 0; + } + + .more-information { + padding-top: 12px; + position: relative; + transition: top 250ms $customize-menu-expand-bezier; + } + + .more-information.expand { + top: 0; + } + } + + .more-info-top-wrapper { + max-height: 78px; + + .more-information { + top: -77px; + } + + .check-wrapper { + margin-top: 10px; + } + } + + .more-info-pocket-wrapper { + max-height: 35px; + + .more-information { + top: -35px; + } + } + } + + .divider { + border-top: 1px var(--customize-menu-seperator-line-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(--customize-menu-primary-text-color); + background: url('chrome://global/skin/icons/settings.svg') left no-repeat; + background-size: 16px; + padding-inline-start: 21px; + margin-bottom: 20px; + + &:dir(rtl) { + background-position-x: right; + } + } + + .external-link:hover { + text-decoration: underline; + } +} + +.home-section .section .sponsored-checkbox:focus-visible, +.selector:focus-visible, +.external-link:focus-visible, +.close-button:focus-visible { + border: 1px solid var(--newtab-focus-border); + outline: 0; + box-shadow: 0 0 0 2px var(--newtab-focus-outline); +} 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..590223a981 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx @@ -0,0 +1,393 @@ +/* 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 { Hero } from "content-src/components/DiscoveryStreamComponents/Hero/Hero"; +import { Highlights } from "content-src/components/DiscoveryStreamComponents/Highlights/Highlights"; +import { HorizontalRule } from "content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule"; +import { List } from "content-src/components/DiscoveryStreamComponents/List/List"; +import { Navigation } from "content-src/components/DiscoveryStreamComponents/Navigation/Navigation"; +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/DiscoveryStreamComponents/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}`); // eslint-disable-line no-console + 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}`); // eslint-disable-line no-console + } + }); + }); + }); + } + + renderComponent(component, embedWidth) { + const ENGAGEMENT_LABEL_ENABLED = this.props.Prefs.values[ + `discoverystream.engagementLabelEnabled` + ]; + + switch (component.type) { + case "Highlights": + return <Highlights />; + case "TopSites": + let promoAlignment; + if ( + component.spocs && + component.spocs.positions && + component.spocs.positions.length + ) { + promoAlignment = + component.spocs.positions[0].index === 0 ? "left" : "right"; + } + return ( + <TopSites + header={component.header} + data={component.data} + promoAlignment={promoAlignment} + /> + ); + 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} + /> + ); + case "SectionTitle": + return <SectionTitle header={component.header} />; + case "Navigation": + return ( + <Navigation + dispatch={this.props.dispatch} + links={component.properties.links} + alignment={component.properties.alignment} + display_variant={component.properties.display_variant} + explore_topics={component.properties.explore_topics} + header={component.header} + /> + ); + case "CollectionCardGrid": + const { DiscoveryStream } = this.props; + return ( + <CollectionCardGrid + data={component.data} + feed={component.feed} + spocs={DiscoveryStream.spocs} + placement={component.placement} + border={component.properties.border} + type={component.type} + items={component.properties.items} + cta_variant={component.cta_variant} + display_engagement_labels={ENGAGEMENT_LABEL_ENABLED} + dismissible={this.props.DiscoveryStream.isCollectionDismissible} + dispatch={this.props.dispatch} + /> + ); + case "CardGrid": + return ( + <CardGrid + enable_video_playheads={ + !!component.properties.enable_video_playheads + } + title={component.header && component.header.title} + display_variant={component.properties.display_variant} + data={component.data} + feed={component.feed} + border={component.properties.border} + type={component.type} + dispatch={this.props.dispatch} + items={component.properties.items} + cta_variant={component.cta_variant} + display_engagement_labels={ENGAGEMENT_LABEL_ENABLED} + /> + ); + case "Hero": + return ( + <Hero + subComponentType={embedWidth >= 9 ? `cards` : `list`} + feed={component.feed} + title={component.header && component.header.title} + data={component.data} + border={component.properties.border} + type={component.type} + dispatch={this.props.dispatch} + items={component.properties.items} + /> + ); + case "HorizontalRule": + return <HorizontalRule />; + case "List": + return ( + <List + data={component.data} + feed={component.feed} + fullWidth={component.properties.full_width} + hasBorders={component.properties.border === "border"} + hasImages={component.properties.has_images} + hasNumbers={component.properties.has_numbers} + items={component.properties.items} + type={component.type} + header={component.header} + /> + ); + 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() { + // Select layout render data by adding spocs and position to recommendations + const { layoutRender } = selectLayoutRender({ + state: this.props.DiscoveryStream, + prefs: this.props.Prefs.values, + locale: this.props.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, + }, + }; + + // 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} + icon={topStories.icon} + id={topStories.id} + isFixed={true} + learnMore={{ + link: { + href: message.header.link_url, + message: message.header.link_text, + }, + }} + privacyNoticeURL={topStories.privacyNoticeURL} + showPrefName={topStories.pref.feed} + title={message.header.title} + eventSource="CARDGRID" + > + {this.renderLayout(layoutRender)} + </CollapsibleSection> + )} + {this.renderLayout([ + { + width: 12, + components: [{ type: "Highlights" }], + }, + ])} + </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, +}))(_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..7626ebbd45 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamBase/_DiscoveryStreamBase.scss @@ -0,0 +1,70 @@ +$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 { + @include dark-theme-only { + color: $grey-30; + } + + color: $grey-50; + font-size: 13px; + 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-link-primary-color); + font-weight: normal; + + &:is(:focus, :hover) { + text-decoration: underline; + } + } + } +} 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..6734d8d00c --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx @@ -0,0 +1,109 @@ +/* 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 { FluentOrText } from "../../FluentOrText/FluentOrText.jsx"; +import React from "react"; + +export class CardGrid extends React.PureComponent { + renderCards() { + const recs = this.props.data.recommendations.slice(0, this.props.items); + const cards = []; + + for (let index = 0; index < this.props.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} + 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} + engagement={rec.engagement} + display_engagement_labels={this.props.display_engagement_labels} + cta={rec.cta} + cta_variant={this.props.cta_variant} + is_video={this.props.enable_video_playheads && rec.is_video} + is_collection={this.props.is_collection} + /> + ) + ); + } + + // Used for CSS overrides to default styling (eg: "hero") + const variantClass = this.props.display_variant + ? `ds-card-grid-${this.props.display_variant}` + : ``; + + return ( + <div + className={`ds-card-grid ds-card-grid-${this.props.border} ${variantClass}`} + > + {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 = { + border: `border`, + items: 4, // Number of stories to display + enable_video_playheads: false, +}; 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..3eed3a70b5 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/_CardGrid.scss @@ -0,0 +1,164 @@ +$col4-header-line-height: 20; +$col4-header-font-size: 14; + +// Special styling for the New Tab Experience styles, +// This is to be incorporated once the styles are made permanent +.outer-wrapper.newtab-experience { + .ds-card-grid { + &.ds-card-grid-border { + .ds-card:not(.placeholder) { + border-radius: $border-radius-new; + box-shadow: 0 3px 8px var(--newtab-card-first-shadow), 0 0 2px var(--newtab-card-second-shadow); + + .img-wrapper .img img { + border-radius: $border-radius-new $border-radius-new 0 0; + } + } + } + + .ds-card-link:focus { + @include ds-focus-nte; + transition: none; + border-radius: $border-radius-new; + } + } +} + +.ds-card-grid { + display: grid; + grid-gap: 24px; + + .ds-card { + @include dark-theme-only { + background: none; + } + + background: $white; + border-radius: 4px; + } + + .ds-column-12 &.ds-card-grid-hero { + @media (min-width: $break-point-large) { + grid-template-columns: repeat(12, 1fr); + + // "hero" + .ds-card:nth-child(1) { + grid-column-start: 1; + grid-column-end: span 6; + grid-row-start: 1; + grid-row-end: span 2; + + .excerpt { + -webkit-line-clamp: 4; + } + } + + .ds-card:nth-child(2), + .ds-card:nth-child(4) { + grid-column-start: 7; + grid-column-end: span 3; + } + + .ds-card:nth-child(3), + .ds-card:nth-child(5) { + grid-column-start: 10; + grid-column-end: span 3; + } + + // "small" cards + .ds-card:nth-child(n+2):nth-child(-n+5) { + .excerpt { + display: none; + } + + .meta { + padding: 8px; + + .title { + font-size: 13px; + line-height: 19px; + } + + .story-sponsored-label { + -webkit-line-clamp: none; + line-height: 19px; + } + } + } + + .ds-card:nth-child(n+6) { + grid-column-start: auto; + grid-column-end: span 4; + } + } + + @media (min-width: $break-point-widest) { + // "small" cards + .ds-card:nth-child(n+2):nth-child(-n+5) { + min-height: 222px; + } + } + } + + &.ds-card-grid-border { + .ds-card:not(.placeholder) { + @include dark-theme-only { + box-shadow: 0 1px 4px $shadow-10; + background: $grey-70; + } + + box-shadow: 0 1px 4px 0 $grey-90-10; + + .img-wrapper .img img { + border-radius: 4px 4px 0 0; + } + } + } + + &.ds-card-grid-no-border { + .ds-card { + background: none; + + .meta { + padding: 12px 0; + } + } + } + + // "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; + } +} 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..fa7b2b7b4f --- /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.jsm"; +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.UserEvent({ + event: userEvent, + source, + action_position: pos, + }) + ); + if (impression) { + this.props.dispatch(impression); + } + } + } + + render() { + const { data, dismissible } = 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 + title={title} + context={sponsoredByMessage} + data={recsData} + feed={feed} + border={this.props.border} + 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..14667be1bd --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/_CollectionCardGrid.scss @@ -0,0 +1,46 @@ +.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 { + @include dark-theme-only { + color: $grey-30; + } + + color: $grey-90; + font-weight: 600; + font-size: 17px; + line-height: 24px; + } + + .ds-context { + @include dark-theme-only { + color: $grey-40; + } + + color: $grey-50; + 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..b94d29bb6d --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.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 } from "common/Actions.jsm"; +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 } from "../DSContextFooter/DSContextFooter.jsx"; +import { FluentOrText } from "../../FluentOrText/FluentOrText.jsx"; +import { connect } from "react-redux"; + +// Default Meta that displays CTA as link if cta_variant in layout is set as "link" +export const DefaultMeta = ({ + display_engagement_labels, + source, + title, + excerpt, + context, + context_type, + cta, + engagement, + cta_variant, + sponsor, + sponsored_by_override, +}) => ( + <div className="meta"> + <div className="info-wrap"> + <p className="source clamp">{source}</p> + <header className="title clamp">{title}</header> + {excerpt && <p className="excerpt clamp">{excerpt}</p>} + {cta_variant === "link" && cta && ( + <div role="link" className="cta-link icon icon-arrow" tabIndex="0"> + {cta} + </div> + )} + </div> + <DSContextFooter + context_type={context_type} + context={context} + sponsor={sponsor} + sponsored_by_override={sponsored_by_override} + display_engagement_labels={display_engagement_labels} + engagement={engagement} + /> + </div> +); + +export const CTAButtonMeta = ({ + display_engagement_labels, + source, + title, + excerpt, + context, + context_type, + cta, + engagement, + sponsor, + sponsored_by_override, +}) => ( + <div className="meta"> + <div className="info-wrap"> + <p className="source clamp"> + {context && ( + <FluentOrText + message={{ + id: `newtab-label-sponsored`, + values: { sponsorOrSource: sponsor ? sponsor : source }, + }} + /> + )} + + {!context && (sponsor ? sponsor : source)} + </p> + <header className="title clamp">{title}</header> + {excerpt && <p className="excerpt clamp">{excerpt}</p>} + </div> + {context && cta && <button className="button cta-button">{cta}</button>} + {!context && ( + <DSContextFooter + context_type={context_type} + context={context} + sponsor={sponsor} + sponsored_by_override={sponsored_by_override} + display_engagement_labels={display_engagement_labels} + engagement={engagement} + /> + )} + </div> +); + +export class _DSCard extends React.PureComponent { + constructor(props) { + super(props); + + this.onLinkClick = this.onLinkClick.bind(this); + 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.UserEvent({ + event: "CLICK", + source: this.props.is_video + ? "CARDGRID_VIDEO" + : 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.is_video + ? "CARDGRID_VIDEO" + : this.props.type.toUpperCase(), + click: 0, + tiles: [ + { + id: this.props.id, + pos: this.props.pos, + ...(this.props.shim && this.props.shim.click + ? { shim: this.props.shim.click } + : {}), + }, + ], + }) + ); + } + } + + 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 isButtonCTA = this.props.cta_variant === "button"; + const baseClass = `ds-card ${this.props.is_video ? `video-card` : ``}`; + + return ( + <div className={baseClass}> + <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} + /> + {this.props.is_video && ( + <div className="playhead"> + <span>Video Content</span> + </div> + )} + </div> + {isButtonCTA ? ( + <CTAButtonMeta + display_engagement_labels={this.props.display_engagement_labels} + source={this.props.source} + title={this.props.title} + excerpt={this.props.excerpt} + context={this.props.context} + context_type={this.props.context_type} + engagement={this.props.engagement} + cta={this.props.cta} + sponsor={this.props.sponsor} + sponsored_by_override={this.props.sponsored_by_override} + /> + ) : ( + <DefaultMeta + display_engagement_labels={this.props.display_engagement_labels} + source={this.props.source} + title={this.props.title} + excerpt={this.props.excerpt} + context={this.props.context} + engagement={this.props.engagement} + context_type={this.props.context_type} + cta={this.props.cta} + cta_variant={this.props.cta_variant} + sponsor={this.props.sponsor} + sponsored_by_override={this.props.sponsored_by_override} + /> + )} + <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.is_video ? "CARDGRID_VIDEO" : this.props.type} + /> + </SafeAnchor> + <DSLinkMenu + id={this.props.id} + index={this.props.pos} + dispatch={this.props.dispatch} + url={this.props.url} + title={this.props.title} + source={this.props.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} + /> + </div> + ); + } +} + +_DSCard.defaultProps = { + windowObj: window, // Added to support unit tests +}; + +export const DSCard = connect(state => ({ + App: state.App, +}))(_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..8564fa4f67 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss @@ -0,0 +1,313 @@ +// Type sizes +$header-font-size: 17; +$header-line-height: 24; +$excerpt-font-size: 14; +$excerpt-line-height: 20; + +.outer-wrapper:not(.newtab-experience) { + .ds-card { + .ds-card-link { + &:hover { + @include ds-fade-in($grey-30); + + @include dark-theme-only { + @include ds-fade-in($grey-60); + } + } + + &:focus { + @include ds-fade-in; + + @include dark-theme-only { + @include ds-fade-in($blue-40-40); + } + } + + &:active { + @include ds-fade-in($grey-30); + + @include dark-theme-only { + @include ds-fade-in($grey-60); + } + } + } + } +} + +.ds-card { + display: flex; + flex-direction: column; + position: relative; + + .playhead { + background: $blue-60 url('chrome://activity-stream/content/data/content/assets/glyph-playhead.svg') no-repeat 12px center; + border-radius: 20px; + bottom: -16px; + color: $white-0; + display: flex; + flex-direction: column; + height: 40px; + justify-content: center; + left: 16px; + min-width: 40px; + padding: 0 0 0 40px; + position: absolute; + transition: padding 100ms ease-in-out 0ms, color 100ms linear 100ms; + + &:hover { + color: $white-100; + padding: 0 20px 0 40px; + } + + span { + display: none; + } + + &:hover span { + display: inline; + font-style: normal; + font-weight: 600; + font-size: 13px; + } + } + + &.placeholder { + background: transparent; + box-shadow: inset $inner-box-shadow; + border-radius: 4px; + min-height: 300px; + } + + .img-wrapper { + width: 100%; + position: relative; + } + + .img { + height: 0; + padding-top: 50%; // 2:1 aspect ratio + + img { + border-radius: 4px; + box-shadow: inset 0 0 0 0.5px $black-15; + } + } + + .ds-card-link { + height: 100%; + display: flex; + flex-direction: column; + + &:hover { + header { + @include dark-theme-only { + color: $blue-40; + } + + color: $blue-60; + } + } + + &:focus { + header { + @include dark-theme-only { + color: $blue-40; + } + + color: $blue-60; + } + } + + &:active { + header { + @include dark-theme-only { + color: $blue-50; + } + + color: $blue-70; + } + } + } + + &.video-card .meta { + margin-top: 4px; + } + + .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 { + @include dark-theme-only { + color: $grey-40; + } + + -webkit-line-clamp: 1; + margin-bottom: 2px; + font-size: 13px; + color: $grey-50; + } + + .cta-button { + @include dark-theme-only { + color: $grey-10; + background: $grey-90-70; + } + + width: 100%; + margin: 12px 0 4px; + box-shadow: none; + border-radius: 4px; + height: 32px; + font-size: 14px; + font-weight: 600; + padding: 5px 8px 7px; + border: 0; + color: $grey-90; + background: $grey-90-10; + + &:focus { + @include dark-theme-only { + background: $grey-90-70; + box-shadow: 0 0 0 2px $grey-80, 0 0 0 5px $blue-50-50; + } + + background: $grey-90-10; + box-shadow: 0 0 0 2px $white, 0 0 0 5px $blue-50-50; + } + + &:hover { + @include dark-theme-only { + background: $grey-90-50; + } + + background: $grey-90-20; + } + + &:active { + @include dark-theme-only { + background: $grey-90-70; + } + + background: $grey-90-30; + } + } + + .cta-link { + @include dark-theme-only { + color: $blue-40; + fill: $blue-40; + } + + font-size: 15px; + font-weight: 600; + line-height: 24px; + height: 24px; + width: auto; + background-size: auto; + background-position: right 1.5px; + padding-right: 9px; + color: $blue-60; + fill: $blue-60; + + &:focus { + @include dark-theme-only { + box-shadow: 0 0 0 1px $grey-80, 0 0 0 4px $blue-50-50; + } + + box-shadow: 0 0 0 1px $white, 0 0 0 4px $blue-50-50; + border-radius: 4px; + outline: 0; + } + + &:active { + @include dark-theme-only { + color: $blue-50; + fill: $blue-50; + box-shadow: none; + } + + color: $blue-70; + fill: $blue-70; + box-shadow: none; + } + + &:hover { + text-decoration: underline; + } + } + } + + header { + @include dark-theme-only { + color: $grey-10; + } + + line-height: $header-line-height * 1px; + font-size: $header-font-size * 1px; + color: $grey-90; + } + + p { + @include dark-theme-only { + color: $grey-10; + } + + font-size: $excerpt-font-size * 1px; + line-height: $excerpt-line-height * 1px; + color: $grey-90; + margin: 0; + } +} + +// Special styling for the New Tab Experience styles, +// This is to be incorporated once the styles are made permanent +.outer-wrapper.newtab-experience { + .ds-card { + // Temporary fix to have the context button focus blend in with other New Tab Experience context menu focus + .context-menu-button { + &:is(:active, :focus) { + outline: 0; + fill: var(--newtab-primary-action-background); + border: 1px solid var(--newtab-primary-action-background); + } + } + + .ds-card-link { + &:focus { + @include ds-focus-nte; + + transition: none; + + header { + @include dark-theme-only { + color: $blue-40; + } + + color: $blue-60; + } + } + } + } +} 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..0da08d3664 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter.jsx @@ -0,0 +1,85 @@ +/* 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 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 }) => { + const classList = "story-sponsored-label 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() { + // display_engagement_labels is based on pref `browser.newtabpage.activity-stream.discoverystream.engagementLabelEnabled` + const { + context, + context_type, + engagement, + display_engagement_labels, + sponsor, + sponsored_by_override, + } = this.props; + const { icon, fluentID } = cardContextTypes[context_type] || {}; + + return ( + <div className="story-footer"> + {SponsorLabel({ sponsored_by_override, sponsor, context })} + <TransitionGroup component={null}> + {!context && + (context_type || (display_engagement_labels && engagement)) && ( + <CSSTransition + key={fluentID} + timeout={ANIMATION_DURATION} + classNames="story-animate" + > + {engagement && !context_type ? ( + <div className="story-view-count">{engagement}</div> + ) : ( + <StatusMessage icon={icon} fluentID={fluentID} /> + )} + </CSSTransition> + )} + </TransitionGroup> + </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..4c4aa7b93e --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/_DSContextFooter.scss @@ -0,0 +1,109 @@ +$status-green: #058B00; +$status-dark-green: #7C6; + +.story-footer { + color: var(--newtab-text-secondary-color); + inset-inline-start: 0; + margin-top: 12px; + position: relative; + + .story-sponsored-label, + .story-view-count, + .status-message { + @include dark-theme-only { + color: $grey-40; + } + + -webkit-line-clamp: 1; + font-size: 13px; + line-height: 24px; + color: $grey-50; + } + + .status-message { + display: flex; + align-items: center; + height: 24px; + + .story-badge-icon { + @include dark-theme-only { + fill: $grey-40; + } + + fill: $grey-50; + height: 16px; + margin-inline-end: 6px; + + &.icon-bookmark-removed { + background-image: url('#{$image-path}icon-removed-bookmark.svg'); + } + } + + .story-context-label { + @include dark-theme-only { + color: $grey-40; + } + + color: $grey-50; + 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 { + @include dark-theme-only { + animation: dark-color 3s ease-out 0.3s; + } + + animation: color 3s ease-out 0.3s; + + @keyframes color { + 0% { + color: $status-green; + fill: $status-green; + } + + 100% { + color: $grey-50; + fill: $grey-50; + } + } + + @keyframes dark-color { + 0% { + color: $status-dark-green; + fill: $status-dark-green; + } + + 100% { + color: $grey-40; + fill: $grey-40; + } + } + } +} + +.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..1d28be53a9 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/_DSDismiss.scss @@ -0,0 +1,68 @@ +.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 { + @include dark-theme-only { + fill: $grey-20; + } + + fill: $grey-50; + } + + &:hover { + @include dark-theme-only { + background: $grey-90-50; + + .icon { + fill: $grey-10; + } + } + + background: $grey-90-20; + + .icon { + fill: $grey-80; + } + } + + &:active { + @include dark-theme-only { + background: $grey-90-70; + } + + background: $grey-90-30; + } + + &:focus { + @include dark-theme-only { + box-shadow: 0 0 0 2px $grey-80, 0 0 0 5px $blue-50-50; + } + + box-shadow: 0 0 0 2px $white, 0 0 0 5px $blue-50-50; + } + } +} 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..2f3822b825 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx @@ -0,0 +1,97 @@ +/* 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.jsm"; +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..bc66424a5a --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/_DSEmptyState.scss @@ -0,0 +1,87 @@ +.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-feed-button-background); + color: var(--newtab-feed-button-text); + 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($grey-30); + + @include dark-theme-only { + @include ds-fade-in($grey-60); + } + } + } + + &::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-feed-button-spinner); + } + + &.waiting { + cursor: initial; + background: var(--newtab-feed-button-background-faded); + color: var(--newtab-feed-button-text-faded); + 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..919a110fbc --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/DSImage.jsx @@ -0,0 +1,157 @@ +/* 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 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.state.nonOptimizedImageFailed) { + img = ( + <img + loading="lazy" + alt={this.props.alt_text} + crossOrigin="anonymous" + onLoad={this.onLoad} + onError={this.onNonOptimizedImageError} + src={this.props.source} + /> + ); + } else { + // Remove the img element if both sources fail. Render a placeholder instead. + 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..03063c0cf8 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/_DSImage.scss @@ -0,0 +1,23 @@ +.ds-image { + display: block; + position: relative; + opacity: 0; + + &.use-transition { + transition: opacity 0.8s; + } + + &.loaded { + opacity: 1; + } + + img, + .broken-image { + background-color: var(--newtab-card-placeholder-color); + position: absolute; + top: 0; + width: 100%; + height: 100%; + object-fit: cover; + } +} 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..469857f6e3 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx @@ -0,0 +1,90 @@ +/* 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 React from "react"; + +export class DSLinkMenu extends React.PureComponent { + constructor(props) { + super(props); + this.onMenuUpdate = this.onMenuUpdate.bind(this); + this.onMenuShow = this.onMenuShow.bind(this); + this.contextMenuButtonRef = React.createRef(); + } + + onMenuUpdate(showContextMenu) { + if (!showContextMenu) { + const dsLinkMenuHostDiv = this.contextMenuButtonRef.current.parentElement; + dsLinkMenuHostDiv.parentElement.classList.remove("active", "last-item"); + } + } + + nextAnimationFrame() { + return new Promise(resolve => + this.props.windowObj.requestAnimationFrame(resolve) + ); + } + + async onMenuShow() { + const dsLinkMenuHostDiv = this.contextMenuButtonRef.current.parentElement; + // Wait for next frame before computing scrollMaxX to allow fluent menu strings to be visible + await this.nextAnimationFrame(); + if (this.props.windowObj.scrollMaxX > 0) { + dsLinkMenuHostDiv.parentElement.classList.add("last-item"); + } + dsLinkMenuHostDiv.parentElement.classList.add("active"); + } + + render() { + const { index, dispatch } = this.props; + const TOP_STORIES_CONTEXT_MENU_OPTIONS = [ + "CheckBookmarkOrArchive", + "CheckSavedToPocket", + "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> + <ContextMenuButton + refFunction={this.contextMenuButtonRef} + tooltip={"newtab-menu-content-tooltip"} + tooltipArgs={{ title }} + onUpdate={this.onMenuUpdate} + > + <LinkMenu + dispatch={dispatch} + index={index} + source={type.toUpperCase()} + onShow={this.onMenuShow} + options={TOP_STORIES_CONTEXT_MENU_OPTIONS} + shouldSendImpressionStats={true} + 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> + ); + } +} + +DSLinkMenu.defaultProps = { + windowObj: window, // Added to support unit tests +}; 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..6e05e4ef9f --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/_DSLinkMenu.scss @@ -0,0 +1,33 @@ +.ds-hero-item, +.ds-list-item, +.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; + + &.ds-card-grid-border { + @include fade-in-card; + } + } +} 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..41b4c3863b --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/_DSMessage.scss @@ -0,0 +1,45 @@ +.ds-message { + margin: 8px 0 0; + + .title { + display: flex; + align-items: center; + + .glyph { + @include dark-theme-only { + fill: $grey-30; + } + + width: 16px; + height: 16px; + margin: 0 6px 0 0; + -moz-context-properties: fill; + fill: $grey-50; + background-position: center center; + background-size: 16px; + background-repeat: no-repeat; + } + + .title-text { + @include dark-theme-only { + color: $grey-30; + } + + line-height: 20px; + font-size: 13px; + color: $grey-50; + 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..56bb4cc580 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx @@ -0,0 +1,69 @@ +/* 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.jsm"; +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.UserEvent({ + 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..d7dceac6e5 --- /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: $blue-60; + width: max-content; + + &:hover { + text-decoration: underline; + cursor: pointer; + } + + &::before { + -moz-context-properties: fill; + fill: $blue-60; + 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://activity-stream/content/data/content/assets/glyph-info-16.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..5ae2df57c2 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.jsx @@ -0,0 +1,167 @@ +/* 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.jsm"; +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.UserEvent({ + 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} + 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..758076f168 --- /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: #{$grey-40}; + 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..a0040ac650 --- /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.jsm"; +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.UserEvent({ + 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.UserEvent({ + 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..6dea98d802 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/_DSTextPromo.scss @@ -0,0 +1,119 @@ +.ds-dismiss-ds-text-promo { + max-width: 744px; + margin: auto; + overflow: hidden; + + &.hovering { + @include dark-theme-only { + background: $grey-90-30; + } + + background: $grey-90-10; + } + + .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 { + @include dark-theme-only { + color: $grey-10; + } + + margin: 0; + font-weight: 600; + font-size: 15px; + } + + .subtitle { + @include dark-theme-only { + color: $grey-40; + } + + font-size: 13px; + margin: 0; + color: $grey-50; + } +} + +.ds-chevron-link { + color: $blue-60; + display: inline-block; + outline: 0; + + &:hover { + text-decoration: underline; + } + + &:active { + @include dark-theme-only { + color: $blue-50; + } + + color: $blue-70; + + &::after { + @include dark-theme-only { + background-color: $blue-50; + } + + background-color: $blue-70; + } + } + + &:focus { + @include dark-theme-only { + box-shadow: 0 0 0 2px $grey-80, 0 0 0 5px $blue-50-50; + } + + box-shadow: 0 0 0 2px $white, 0 0 0 5px $blue-50-50; + border-radius: 2px; + } + + &::after { + @include dark-theme-only { + background-color: $blue-40; + } + + content: ' '; + mask: url('chrome://activity-stream/content/data/content/assets/glyph-caret-right.svg') 0 -8px no-repeat; + background-color: $blue-60; + 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/Hero/Hero.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/Hero.jsx new file mode 100644 index 0000000000..eaa9ed20c5 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/Hero.jsx @@ -0,0 +1,207 @@ +/* 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 { actionCreators as ac } from "common/Actions.jsm"; +import { DSEmptyState } from "../DSEmptyState/DSEmptyState.jsx"; +import { DSImage } from "../DSImage/DSImage.jsx"; +import { DSLinkMenu } from "../DSLinkMenu/DSLinkMenu"; +import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats"; +import { List } from "../List/List.jsx"; +import React from "react"; +import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; +import { DSContextFooter } from "../DSContextFooter/DSContextFooter.jsx"; + +export class Hero extends React.PureComponent { + constructor(props) { + super(props); + this.onLinkClick = this.onLinkClick.bind(this); + } + + onLinkClick(event) { + if (this.props.dispatch) { + this.props.dispatch( + ac.UserEvent({ + event: "CLICK", + source: this.props.type.toUpperCase(), + action_position: this.heroRec.pos, + }) + ); + + this.props.dispatch( + ac.ImpressionStats({ + source: this.props.type.toUpperCase(), + click: 0, + tiles: [ + { + id: this.heroRec.id, + pos: this.heroRec.pos, + ...(this.heroRec.shim && this.heroRec.shim.click + ? { shim: this.heroRec.shim.click } + : {}), + }, + ], + }) + ); + } + } + + renderHero() { + let [heroRec, ...otherRecs] = this.props.data.recommendations.slice( + 0, + this.props.items + ); + this.heroRec = heroRec; + + const cards = []; + for (let index = 0; index < this.props.items - 1; index++) { + const rec = otherRecs[index]; + cards.push( + !rec || rec.placeholder ? ( + <PlaceholderDSCard key={`dscard-${index}`} /> + ) : ( + <DSCard + flightId={rec.flight_id} + key={`dscard-${rec.id}`} + image_src={rec.image_src} + raw_image_src={rec.raw_image_src} + title={rec.title} + url={rec.url} + id={rec.id} + shim={rec.shim} + pos={rec.pos} + type={this.props.type} + dispatch={this.props.dispatch} + context={rec.context} + context_type={rec.context_type} + source={rec.domain} + pocket_id={rec.pocket_id} + bookmarkGuid={rec.bookmarkGuid} + engagement={rec.engagement} + /> + ) + ); + } + + let heroCard = null; + + if (!heroRec || heroRec.placeholder) { + heroCard = <PlaceholderDSCard />; + } else { + heroCard = ( + <div className="ds-hero-item" key={`dscard-${heroRec.id}`}> + <SafeAnchor + className="wrapper" + dispatch={this.props.dispatch} + onLinkClick={this.onLinkClick} + url={heroRec.url} + > + <div className="img-wrapper"> + <DSImage + extraClassNames="img" + source={heroRec.image_src} + rawSource={heroRec.raw_image_src} + /> + </div> + <div className="meta"> + <div className="header-and-excerpt"> + <p className="source clamp">{heroRec.domain}</p> + <header className="clamp">{heroRec.title}</header> + <p className="excerpt clamp">{heroRec.excerpt}</p> + </div> + <DSContextFooter + context={heroRec.context} + context_type={heroRec.context_type} + engagement={heroRec.engagement} + /> + </div> + <ImpressionStats + flightId={heroRec.flight_id} + rows={[ + { + id: heroRec.id, + pos: heroRec.pos, + ...(heroRec.shim && heroRec.shim.impression + ? { shim: heroRec.shim.impression } + : {}), + }, + ]} + dispatch={this.props.dispatch} + source={this.props.type} + /> + </SafeAnchor> + <DSLinkMenu + id={heroRec.id} + index={heroRec.pos} + dispatch={this.props.dispatch} + url={heroRec.url} + title={heroRec.title} + source={heroRec.domain} + type={this.props.type} + pocket_id={heroRec.pocket_id} + shim={heroRec.shim} + bookmarkGuid={heroRec.bookmarkGuid} + flightId={heroRec.flight_id} + /> + </div> + ); + } + + let list = ( + <List + recStartingPoint={1} + data={this.props.data} + feed={this.props.feed} + hasImages={true} + hasBorders={this.props.border === `border`} + items={this.props.items - 1} + type={`Hero`} + /> + ); + + return ( + <div className={`ds-hero ds-hero-${this.props.border}`}> + {heroCard} + <div className={`${this.props.subComponentType}`}> + {this.props.subComponentType === `cards` ? cards : list} + </div> + </div> + ); + } + + render() { + const { data } = this.props; + + // Handle a render before feed has been fetched by displaying nothing + if (!data || !data.recommendations) { + return <div />; + } + + // Handle the case where a user has dismissed all recommendations + const isEmpty = data.recommendations.length === 0; + + return ( + <div> + <div className="ds-header">{this.props.title}</div> + {isEmpty ? ( + <div className="ds-hero empty"> + <DSEmptyState + status={data.status} + dispatch={this.props.dispatch} + feed={this.props.feed} + /> + </div> + ) : ( + this.renderHero() + )} + </div> + ); + } +} + +Hero.defaultProps = { + data: {}, + border: `border`, + items: 1, // Number of stories to display +}; diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/_Hero.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/_Hero.scss new file mode 100644 index 0000000000..ed81e42946 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/_Hero.scss @@ -0,0 +1,282 @@ +$card-header-in-hero-font-size: 14; +$card-header-in-hero-line-height: 20; + +.ds-hero { + position: relative; + + header { + font-weight: 600; + } + + p { + line-height: 1.538; + margin: 8px 0; + } + + .excerpt { + @include limit-visible-lines(3, 24, 15); + @include dark-theme-only { + color: $grey-10; + } + + color: $grey-90; + margin: 0 0 10px; + } + + .ds-card:not(.placeholder) { + border: 0; + padding-bottom: 20px; + + &:hover { + border: 0; + box-shadow: none; + border-radius: 0; + } + + .meta { + padding: 0; + } + + .img-wrapper { + margin: 0 0 12px; + } + } + + .ds-card.placeholder { + margin-bottom: 20px; + padding-bottom: 20px; + min-height: 180px; + } + + .img-wrapper { + margin: 0 0 12px; + } + + .ds-hero-item { + position: relative; + } + + // "1/3 width layout" (aka "Mobile First") + .wrapper { + @include ds-border-top; + @include dark-theme-only { + color: $grey-30; + } + + color: $grey-50; + display: block; + margin: 12px 0 16px; + padding-top: 16px; + height: 100%; + + &:focus { + @include ds-fade-in; + } + + @at-root .ds-hero-no-border .ds-hero-item .wrapper { + border-top: 0; + border-bottom: 0; + padding: 0 0 8px; + } + + &:hover .meta header { + @include dark-theme-only { + color: $blue-40; + } + + color: $blue-60; + } + + &:active .meta header { + @include dark-theme-only { + color: $blue-40; + } + + color: $blue-70; + } + + .img-wrapper { + width: 100%; + } + + .img { + height: 0; + padding-top: 50%; // 2:1 aspect ratio + + img { + border-radius: 4px; + box-shadow: inset 0 0 0 0.5px $black-15; + } + } + + .meta { + display: block; + flex-direction: column; + justify-content: space-between; + + .header-and-excerpt { + flex: 1; + } + + header { + @include dark-theme-only { + color: $white; + } + + @include limit-visible-lines(4, 28, 22); + color: $grey-90; + margin-bottom: 0; + } + + .context, + .source { + margin: 0 0 4px; + } + + .context { + @include dark-theme-only { + color: $teal-10; + } + + color: $teal-70; + } + + .source { + @include dark-theme-only { + color: $grey-40; + } + + font-size: 13px; + color: $grey-50; + -webkit-line-clamp: 1; + margin-bottom: 0; + } + } + } + + // "2/3 width layout" + .ds-column-5 &, + .ds-column-6 &, + .ds-column-7 &, + .ds-column-8 & { + .wrapper { + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-column-gap: 24px; + + .img-wrapper { + margin: 0; + grid-column: 2; + grid-row: 1; + } + + .meta { + grid-column: 1; + grid-row: 1; + display: flex; + } + + .img { + height: 0; + padding-top: 100%; // 1:1 aspect ratio + } + } + + .cards { + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-column-gap: 24px; + grid-auto-rows: min-content; + } + } + + // "Full width layout" + .ds-column-9 &, + .ds-column-10 &, + .ds-column-11 &, + .ds-column-12 & { + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-column-gap: 24px; + + &.ds-hero-border { + @include ds-border-top; + padding: 20px 0; + + .ds-card:not(.placeholder):nth-child(-n+2) { + @include ds-border-bottom; + margin-bottom: 20px; + } + } + + .wrapper { + border-top: 0; + border-bottom: 0; + margin: 0; + padding: 0 0 20px; + display: flex; + flex-direction: column; + + .img-wrapper { + margin: 0; + } + + .img { + margin-bottom: 12px; + height: 0; + padding-top: 50%; // 2:1 aspect ratio + } + + .meta { + flex-grow: 1; + display: flex; + padding: 0 24px 0 0; + + header { + @include limit-visible-lines(3, 28, 22); + } + + .source { + margin-bottom: 0; + } + } + } + + .cards { + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-column-gap: 24px; + grid-auto-rows: min-content; + + .ds-card { + &:hover { + @include dark-theme-only { + background: none; + + .title { + color: $blue-40; + } + } + } + + &:active .title { + @include dark-theme-only { + color: $blue-50; + } + } + + .title { + @include dark-theme-only { + color: $white; + } + + @include limit-visible-lines(3, 20, 14); + } + } + } + } + + &.empty { + grid-template-columns: auto; + } +} 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..bf0a0557da --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/_Highlights.scss @@ -0,0 +1,40 @@ +.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; // sass-lint:disable-line no-disallowed-properties + } + + .card-title { + font-size: 14px; + font-weight: 600; + line-height: $line-height; + max-height: $line-height; + } + } + } + } + + .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/List/List.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/List.jsx new file mode 100644 index 0000000000..057d507099 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/List.jsx @@ -0,0 +1,221 @@ +/* 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.jsm"; +import { connect } from "react-redux"; +import { DSEmptyState } from "../DSEmptyState/DSEmptyState.jsx"; +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 } from "../DSContextFooter/DSContextFooter.jsx"; + +/** + * @note exported for testing only + */ +export class ListItem extends React.PureComponent { + // TODO performance: get feeds to send appropriately sized images rather + // than waiting longer and scaling down on client? + constructor(props) { + super(props); + this.onLinkClick = this.onLinkClick.bind(this); + } + + onLinkClick(event) { + if (this.props.dispatch) { + this.props.dispatch( + ac.UserEvent({ + 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, + tiles: [ + { + id: this.props.id, + pos: this.props.pos, + ...(this.props.shim && this.props.shim.click + ? { shim: this.props.shim.click } + : {}), + }, + ], + }) + ); + } + } + + render() { + return ( + <li + className={`ds-list-item${ + this.props.placeholder ? " placeholder" : "" + }`} + > + <SafeAnchor + className="ds-list-item-link" + dispatch={this.props.dispatch} + onLinkClick={!this.props.placeholder ? this.onLinkClick : undefined} + url={this.props.url} + > + <div className="ds-list-item-text"> + <p> + <span className="ds-list-item-info clamp"> + {this.props.domain} + </span> + </p> + <div className="ds-list-item-body"> + <div className="ds-list-item-title clamp">{this.props.title}</div> + {this.props.excerpt && ( + <div className="ds-list-item-excerpt clamp"> + {this.props.excerpt} + </div> + )} + </div> + <DSContextFooter + context={this.props.context} + context_type={this.props.context_type} + engagement={this.props.engagement} + /> + </div> + <DSImage + extraClassNames="ds-list-image" + source={this.props.image_src} + rawSource={this.props.raw_image_src} + /> + <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> + {!this.props.placeholder && ( + <DSLinkMenu + id={this.props.id} + index={this.props.pos} + dispatch={this.props.dispatch} + url={this.props.url} + title={this.props.title} + source={this.props.source} + type={this.props.type} + pocket_id={this.props.pocket_id} + shim={this.props.shim} + bookmarkGuid={this.props.bookmarkGuid} + flightId={this.props.flightId} + /> + )} + </li> + ); + } +} + +export const PlaceholderListItem = props => <ListItem placeholder={true} />; + +/** + * @note exported for testing only + */ +export function _List(props) { + const renderList = () => { + const recs = props.data.recommendations.slice( + props.recStartingPoint, + props.recStartingPoint + props.items + ); + const recMarkup = []; + + for (let index = 0; index < props.items; index++) { + const rec = recs[index]; + recMarkup.push( + !rec || rec.placeholder ? ( + <PlaceholderListItem key={`ds-list-item-${index}`} /> + ) : ( + <ListItem + key={`ds-list-item-${rec.id}`} + dispatch={props.dispatch} + flightId={rec.flight_id} + domain={rec.domain} + excerpt={rec.excerpt} + id={rec.id} + shim={rec.shim} + image_src={rec.image_src} + raw_image_src={rec.raw_image_src} + pos={rec.pos} + title={rec.title} + context={rec.context} + context_type={rec.context_type} + type={props.type} + url={rec.url} + pocket_id={rec.pocket_id} + bookmarkGuid={rec.bookmarkGuid} + engagement={rec.engagement} + /> + ) + ); + } + + const listStyles = [ + "ds-list", + props.fullWidth ? "ds-list-full-width" : "", + props.hasBorders ? "ds-list-borders" : "", + props.hasImages ? "ds-list-images" : "", + props.hasNumbers ? "ds-list-numbers" : "", + ]; + + return <ul className={listStyles.join(" ")}>{recMarkup}</ul>; + }; + + const { data } = props; + if (!data || !data.recommendations) { + return null; + } + + // Handle the case where a user has dismissed all recommendations + const isEmpty = data.recommendations.length === 0; + + return ( + <div> + {props.header && props.header.title ? ( + <div className="ds-header">{props.header.title}</div> + ) : null} + {isEmpty ? ( + <div className="ds-list empty"> + <DSEmptyState + status={data.status} + dispatch={props.dispatch} + feed={props.feed} + /> + </div> + ) : ( + renderList() + )} + </div> + ); +} + +_List.defaultProps = { + recStartingPoint: 0, // Index of recommendations to start displaying from + fullWidth: false, // Display items taking up the whole column + hasBorders: false, // Display lines separating each item + hasImages: false, // Display images for each item + hasNumbers: false, // Display numbers for each item + items: 6, // Number of stories to display. TODO: get from endpoint +}; + +export const List = connect(state => ({ + DiscoveryStream: state.DiscoveryStream, +}))(_List); diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/_List.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/_List.scss new file mode 100644 index 0000000000..6841ed4a46 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/_List.scss @@ -0,0 +1,269 @@ +// Type sizes +$bordered-spacing: 16px; +$item-font-size: 14; +$item-image-size: 80px; +$item-line-height: 20; + +// XXX this is gross, and attaches the bottom-border to the item above. +// Ideally, we'd attach the top-border to the item that needs it. +// Unfortunately the border needs to go _above_ the row gap as currently +// set up, which means that some refactoring will be required to do this. +@mixin bottom-border-except-last-grid-row($columns) { + .ds-list-item:not(.placeholder):not(:nth-last-child(-n+#{$columns})) { + @include ds-border-bottom; + margin-bottom: -1px; // cancel out the pixel we used for the border + padding-bottom: $bordered-spacing; + } +} + +@mixin set-item-sizes($font-size, $line-height, $image-size) { + .ds-list-item { + // XXX see if we really want absolute units, maybe hoist somewhere central? + font-size: $font-size * 1px; + line-height: $line-height * 1px; + position: relative; + } + + .ds-list-item-title { + @include limit-visible-lines(3, $line-height, $font-size); + } + + .ds-list-image { + min-width: $image-size; + width: $image-size; + } +} + +.ds-list { + display: grid; + grid-row-gap: 24px; + grid-column-gap: 24px; + + // reset some stuff from <ul>. Should maybe be hoisted when we have better + // regression detection? + padding-inline-start: 0; + + &:not(.ds-list-full-width) { + @include set-item-sizes($item-font-size, $item-line-height, $item-image-size); + + // "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(3, 1fr); + } + + &.empty { + grid-template-columns: auto; + } + + .ds-list-item-excerpt { + display: none; + } + } + + &:not(.ds-list-images) { + .ds-list-image { + display: none; + } + } + + a { + @include dark-theme-only { + color: $grey-10; + } + + color: $grey-90; + } +} + +.ds-list-item-link:focus { + @include ds-fade-in; +} + +.ds-list-numbers { + $counter-whitespace: ($item-line-height - $item-font-size) * 1px; + $counter-size: 32px; + $counter-padded-size: $counter-size + $counter-whitespace * 1.5; + + .ds-list-item { + counter-increment: list; + } + + .ds-list-item:not(.placeholder) > .ds-list-item-link { + padding-inline-start: $counter-padded-size; + + &::before { + @include dark-theme-only { + background-color: $teal-70; + } + + background-color: $pocket-teal; + border-radius: $counter-size; + color: $white; + content: counter(list); + font-size: 17px; + height: $counter-size; + line-height: $counter-size; + margin-inline-start: -$counter-padded-size; + margin-top: $counter-whitespace / 2; + position: absolute; + text-align: center; + width: $counter-size; + } + + &:hover::before { + @include dark-theme-only { + background-color: $blue-40; + } + + background-color: $blue-40; + } + + &:active::before { + @include dark-theme-only { + background-color: $blue-60; + } + + background-color: $blue-70; + } + } +} + +.ds-list-borders { + @include ds-border-top; + grid-row-gap: $bordered-spacing; + padding-top: $bordered-spacing; + + &.ds-list-full-width, + .ds-column-1 &, + .ds-column-2 &, + .ds-column-3 &, + .ds-column-4 & { + @include bottom-border-except-last-grid-row(1); + } + + &:not(.ds-list-full-width) { + // "2/3 width layout" + .ds-column-5 &, + .ds-column-6 &, + .ds-column-7 &, + .ds-column-8 & { + @include bottom-border-except-last-grid-row(2); + } + + // "Full width layout" + .ds-column-9 &, + .ds-column-10 &, + .ds-column-11 &, + .ds-column-12 & { + @include bottom-border-except-last-grid-row(3); + } + } +} + +.ds-list-full-width { + @include set-item-sizes(17, 24, $item-image-size * 2); +} + +.ds-list-item { + // reset some stuff from <li>. Should maybe be hoisted when we have better + // regression detection? + display: block; + text-align: start; + + &.placeholder { + background: transparent; + min-height: $item-image-size; + box-shadow: inset $inner-box-shadow; + border-radius: 4px; + + .ds-list-item-link { + cursor: default; + } + + .ds-list-image { + opacity: 0; + } + } + + .ds-list-item-link { + mix-blend-mode: normal; + + display: flex; + justify-content: space-between; + height: 100%; + } + + .ds-list-item-excerpt { + @include limit-visible-lines(2, $item-line-height, $item-font-size); + @include dark-theme-only { + color: $grey-10-80; + } + color: $grey-50; + margin: 4px 0 8px; + } + + p { + font-size: $item-font-size * 1px; + line-height: $item-line-height * 1px; + margin: 0; + } + + .ds-list-item-info { + @include limit-visible-lines(1, $item-line-height, $item-font-size); + @include dark-theme-only { + color: $grey-40; + } + + color: $grey-50; + font-size: 13px; + } + + .ds-list-item-title { + font-weight: 600; + margin-bottom: 4px; + } + + .ds-list-item-body { + flex: 1; + } + + .ds-list-item-text { + display: flex; + flex-direction: column; + justify-content: space-between; + } + + .ds-list-image { + height: $item-image-size; + margin-inline-start: $item-font-size * 1px; + min-height: $item-image-size; + + img { + border-radius: 4px; + box-shadow: inset 0 0 0 0.5px $black-15; + } + } + + &:hover { + .ds-list-item-title { + color: $blue-40; + } + } + + &:active { + .ds-list-item-title { + color: $blue-70; + } + } +} 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..45445bf889 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/Navigation.jsx @@ -0,0 +1,73 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { actionCreators as ac } from "common/Actions.jsm"; +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.UserEvent({ + 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() { + const links = this.props.links || []; + const alignment = this.props.alignment || "centered"; + const header = this.props.header || {}; + return ( + <div className={`ds-navigation ds-navigation-${alignment}`}> + {header.title ? ( + <FluentOrText message={header.title}> + <span className="ds-navigation-header" /> + </FluentOrText> + ) : null} + <ul> + {links && + links.map(t => ( + <li key={t.name}> + <Topic + url={t.url} + name={t.name} + dispatch={this.props.dispatch} + /> + </li> + ))} + </ul> + </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..c48b36281b --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/_Navigation.scss @@ -0,0 +1,64 @@ +.outer-wrapper.newtab-experience { + .ds-navigation { + color: var(--newtab-background-primary-text-color); + } +} + +.ds-navigation { + @include dark-theme-only { + color: $grey-30; + } + + color: $grey-50; + padding: 4px 0; + font-weight: 600; + line-height: 22px; + font-size: 11.5px; + + @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: underline; + } + + &:active { + color: $blue-70; + } + } + } + + .ds-navigation-header { + padding-inline-end: 6px; + } +} 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..5a65a50bb8 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.jsx @@ -0,0 +1,62 @@ +/* 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.jsm"; +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..317bb29466 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/_SectionTitle.scss @@ -0,0 +1,26 @@ +.ds-section-title { + text-align: center; + margin-top: 24px; + + .title { + @include dark-theme-only { + color: $white; + } + + line-height: 48px; + font-size: 36px; + font-weight: 300; + color: $grey-90; + } + + .subtitle { + @include dark-theme-only { + color: $grey-30; + } + + line-height: 24px; + font-size: 14px; + color: $grey-50; + margin-top: 4px; + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/TopSites.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/TopSites.jsx new file mode 100644 index 0000000000..bcc388698f --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/TopSites.jsx @@ -0,0 +1,157 @@ +/* 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 { TopSites as OldTopSites } from "content-src/components/TopSites/TopSites"; +import { TOP_SITES_MAX_SITES_PER_ROW } from "common/Reducers.jsm"; +import React from "react"; + +export class _TopSites extends React.PureComponent { + // Find a SPOC that doesn't already exist in User's TopSites + getFirstAvailableSpoc(topSites, data) { + const { spocs } = data; + if (!spocs || spocs.length === 0) { + return null; + } + + const userTopSites = new Set( + topSites.map(topSite => topSite && topSite.url) + ); + + // We "clean urls" with http in TopSiteForm.jsx + // Spoc domains are in the format 'sponsorname.com' + return spocs.find( + spoc => + !userTopSites.has(spoc.url) && + !userTopSites.has(`http://${spoc.domain}`) && + !userTopSites.has(`https://${spoc.domain}`) && + !userTopSites.has(`http://www.${spoc.domain}`) && + !userTopSites.has(`https://www.${spoc.domain}`) + ); + } + + // Find the first empty or unpinned index we can place the SPOC in. + // Return -1 if no available index and we should push it at the end. + getFirstAvailableIndex(topSites, promoAlignment) { + if (promoAlignment === "left") { + return topSites.findIndex(topSite => !topSite || !topSite.isPinned); + } + + // The row isn't full so we can push it to the end of the row. + if (topSites.length < TOP_SITES_MAX_SITES_PER_ROW) { + return -1; + } + + // If the row is full, we can check the row first for unpinned topsites to replace. + // Else we can check after the row. This behavior is how unpinned topsites move while drag and drop. + let endOfRow = TOP_SITES_MAX_SITES_PER_ROW - 1; + for (let i = endOfRow; i >= 0; i--) { + if (!topSites[i] || !topSites[i].isPinned) { + return i; + } + } + + for (let i = endOfRow + 1; i < topSites.length; i++) { + if (!topSites[i] || !topSites[i].isPinned) { + return i; + } + } + + return -1; + } + + insertSpocContent(TopSites, data, promoAlignment) { + if ( + !TopSites.rows || + TopSites.rows.length === 0 || + !data.spocs || + data.spocs.length === 0 + ) { + return null; + } + + let topSites = [...TopSites.rows]; + const topSiteSpoc = this.getFirstAvailableSpoc(topSites, data); + + if (!topSiteSpoc) { + return null; + } + + const link = { + customScreenshotURL: topSiteSpoc.image_src, + type: "SPOC", + label: topSiteSpoc.sponsor, + title: topSiteSpoc.sponsor, + url: topSiteSpoc.url, + flightId: topSiteSpoc.flight_id, + id: topSiteSpoc.id, + guid: topSiteSpoc.id, + shim: topSiteSpoc.shim, + // For now we are assuming position based on intended position. + // Actual position can shift based on other content. + // We also hard code left and right to be 0 and 7. + // We send the intended postion in the ping. + pos: promoAlignment === "left" ? 0 : 7, + }; + + const firstAvailableIndex = this.getFirstAvailableIndex( + topSites, + promoAlignment + ); + + if (firstAvailableIndex === -1) { + topSites.push(link); + } else { + // Normal insertion will not work since pinned topsites are in their correct index already + // Similar logic is done to handle drag and drop with pinned topsites in TopSite.jsx + + let shiftedTopSite = topSites[firstAvailableIndex]; + let index = firstAvailableIndex + 1; + + // Shift unpinned topsites to the right by finding the next unpinned topsite to replace + while (shiftedTopSite) { + if (index === topSites.length) { + topSites.push(shiftedTopSite); + shiftedTopSite = null; + } else if (topSites[index] && topSites[index].isPinned) { + index += 1; + } else { + const nextTopSite = topSites[index]; + topSites[index] = shiftedTopSite; + shiftedTopSite = nextTopSite; + index += 1; + } + } + + topSites[firstAvailableIndex] = link; + } + + return { ...TopSites, rows: topSites }; + } + + render() { + const { header = {}, data, promoAlignment, TopSites } = this.props; + + const TopSitesWithSpoc = + TopSites && data && promoAlignment + ? this.insertSpocContent(TopSites, data, promoAlignment) + : null; + + return ( + <div + className={`ds-top-sites ${TopSitesWithSpoc ? "top-sites-spoc" : ""}`} + > + <OldTopSites + isFixed={true} + title={header.title} + TopSitesWithSpoc={TopSitesWithSpoc} + /> + </div> + ); + } +} + +export const TopSites = connect(state => ({ TopSites: state.TopSites }))( + _TopSites +); 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..b40ce5bb7b --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/_TopSites.scss @@ -0,0 +1,119 @@ +// ds topsites wraps the original topsites, with a few css changes. +.outer-wrapper:not(.newtab-experience) { + .ds-top-sites { + // This is the override layer. + .top-sites { + .top-site-outer { + padding: 0 12px; + + .top-site-inner > a:is(.active, :focus) .tile { + @include ds-fade-in; + + @include dark-theme-only { + @include ds-fade-in($blue-40-40); + } + } + + .top-site-inner > a:is(:hover) .tile { + + @include ds-fade-in($grey-30); + + @include dark-theme-only { + @include ds-fade-in($grey-60); + } + } + } + + .top-sites-list { + margin: 0 -12px; + } + } + } +} + +.outer-wrapper.newtab-experience { + .ds-top-sites { + // This is the override layer. + .top-sites { + .top-site-outer { + .top-site-inner > a:is(.active, :focus) .tile { + @include ds-focus-nte; + } + + .top-site-inner > a:is(:hover) .top-site-inner { + + @include ds-fade-in($grey-30); + + @include dark-theme-only { + @include ds-fade-in($grey-60); + } + } + } + + .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/DiscoveryStreamImpressionStats/ImpressionStats.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx new file mode 100644 index 0000000000..cc0082bdfe --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.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/. */ + +import { actionCreators as ac, actionTypes as at } from "common/Actions.jsm"; +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 }, + }) + ); + } + + if (this._needsImpressionStats(cards)) { + props.dispatch( + ac.DiscoveryStreamImpressionStats({ + source: props.source.toUpperCase(), + tiles: cards.map(link => ({ + id: link.id, + pos: link.pos, + ...(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..8607be7de4 --- /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-conditional-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-conditional-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..f8ba0786e9 --- /dev/null +++ b/browser/components/newtab/content-src/components/LinkMenu/LinkMenu.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 { actionCreators as ac } from "common/Actions.jsm"; +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, + } = 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 } = 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 (userEvent) { + const userEventData = Object.assign( + { + event: userEvent, + source, + action_position: index, + }, + siteInfo + ); + props.dispatch(ac.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..bfc441afee --- /dev/null +++ b/browser/components/newtab/content-src/components/MoreRecommendations/_MoreRecommendations.scss @@ -0,0 +1,22 @@ +.more-recommendations { + display: flex; + align-items: center; + white-space: nowrap; + line-height: 1.230769231; // (16 / 13) -> 16px computed + + &::after { + background: url('#{$image-path}topic-show-more-12.svg') no-repeat center center; + content: ''; + -moz-context-properties: fill; + display: inline-block; + fill: var(--newtab-link-secondary-color); + height: 16px; + margin-inline-start: 5px; + vertical-align: top; + width: 12px; + } + + &:dir(rtl)::after { + transform: scaleX(-1); + } +} 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..3a55da9112 --- /dev/null +++ b/browser/components/newtab/content-src/components/PocketLoggedInCta/_PocketLoggedInCta.scss @@ -0,0 +1,39 @@ +.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: $blue-60; + 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: 1.230769231; // (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..62a0dcb787 --- /dev/null +++ b/browser/components/newtab/content-src/components/Search/Search.jsx @@ -0,0 +1,208 @@ +/* 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.jsm"; +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.HIDE_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 usset the search icon and reacts to + // changes to default engine to keep everything in sync. + this._handoffSearchController = new ContentSearchHandoffUIController(); + } + } + + 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.hide && "search-hidden", + this.props.fakeFocus && "fake-focus", + ] + .filter(v => v) + .join(" "); + + const isNewNewtabExperienceEnabled = this.props.Prefs.values[ + "newNewtabExperience.enabled" + ]; + + 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={ + isNewNewtabExperienceEnabled + ? "newtab-search-box-input" + : "newtab-search-box-search-the-web-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" + data-l10n-id={ + isNewNewtabExperienceEnabled + ? "newtab-search-box-input" + : "newtab-search-box-search-the-web-input" + } + ref={this.onSearchHandoffButtonMount} + onClick={this.onSearchHandoffClick} + tabIndex="-1" + > + <div + className="fake-textbox" + data-l10n-id={ + isNewNewtabExperienceEnabled + ? "newtab-search-box-text" + : "newtab-search-box-search-the-web-text" + } + /> + <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..87bfdb0427 --- /dev/null +++ b/browser/components/newtab/content-src/components/Search/_Search.scss @@ -0,0 +1,516 @@ +$search-height: 48px; +$search-height-new: 52px; +$search-icon-size: 24px; +$search-icon-padding: 12px; +$search-icon-width: 2 * $search-icon-padding + $search-icon-size -2; +$search-button-width: 48px; +$glyph-forward: url('chrome://browser/skin/forward.svg'); + +.outer-wrapper.newtab-experience { + $search-icon-padding: 16px; + $search-icon-width: 2 * $search-icon-padding + $search-icon-size -4; + + &.visible-logo { + .logo-and-wordmark { + .wordmark { + fill: var(--newtab-wordmark-color); + } + } + } + + .search-wrapper { + padding-bottom: 38px; + + .search-inner-wrapper { + min-height: $search-height-new; + width: $searchbar-width-small-new; + + @media (min-width: $break-point-medium) { + width: $searchbar-width-medium-new; + } + + @media (min-width: $break-point-large) { + width: $searchbar-width-large-new; + } + + @media (min-width: $break-point-widest) { + width: $searchbar-width-largest-new; + } + } + + .search-button { + &:focus { + outline: 0; + box-shadow: 0 0 0 2px var(--newtab-focus-outline); + border: 1px solid var(--newtab-focus-border); + border-radius: 0 $border-radius-new $border-radius-new 0; + } + } + + input:focus { + outline: 0; + border: 1px solid var(--newtab-focus-border); + box-shadow: 0 0 0 2px var(--newtab-focus-outline); + } + + &.fake-focus { + .search-handoff-button { + border: 1px solid var(--newtab-focus-border); + box-shadow: 0 0 0 2px var(--newtab-focus-outline); + } + } + + .search-handoff-button, + input { + background: var(--newtab-textbox-background-color) 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; + box-shadow: 0 3px 8px var(--newtab-search-first-shadow), 0 0 2px var(--newtab-search-second-shadow); + border: 1px solid transparent; + border-radius: 8px; + color: var(--newtab-search-text-color); + font-weight: 500; + font-size: 15px; + } + + .search-handoff-button { + padding-inline-end: 15px; + + .fake-caret { + top: 18px; + inset-inline-start: $search-icon-width; + + &:dir(rtl) { + background-position-x: right $search-icon-padding; + } + } + } + } +} + +.search-wrapper { + padding: 34px 0 64px; + + .only-search & { + padding: 0 0 64px; + } + + .logo-and-wordmark { + $logo-size: 82px; + $wordmark-size: 134px; + + align-items: center; + display: flex; + justify-content: center; + margin-bottom: 48px; + + .logo { + 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'); + } + display: inline-block; + height: $logo-size; + width: $logo-size; + } + + .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-search-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; + margin: 0 auto; + position: relative; + width: $searchbar-width-small; + + .ds-outer-wrapper-breakpoint-override & { + width: 216px; + } + + @media (min-width: $break-point-medium) { + width: $searchbar-width-medium; + + .ds-outer-wrapper-breakpoint-override & { + width: 460px; + } + } + + @media (min-width: $break-point-large) { + width: $searchbar-width-large; + + .ds-outer-wrapper-breakpoint-override & { + width: 696px; + } + } + } + + input { + background: var(--newtab-textbox-background-color) var(--newtab-search-icon) $search-icon-padding center no-repeat; + background-size: $search-icon-size; + border: solid 1px var(--newtab-search-border-color); + box-shadow: $shadow-secondary, 0 0 0 1px $black-15; + font-size: 15px; + -moz-context-properties: fill; + fill: var(--newtab-search-icon-color); + padding: 0; + padding-inline-end: $search-button-width; + padding-inline-start: $search-icon-width; + width: 100%; + + &:dir(rtl) { + background-position-x: right $search-icon-padding; + } + } + + &:hover input { + box-shadow: $shadow-secondary, 0 0 0 1px $black-25; + } + + .search-inner-wrapper:active input, + input:focus { + border: $input-border-active; + box-shadow: var(--newtab-textbox-focus-boxshadow); + } + + .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-search-icon-color); + height: 100%; + inset-inline-end: 0; + position: absolute; + width: $search-button-width; + + &:focus, + &:hover { + background-color: $grey-90-10; + cursor: pointer; + } + + &:active { + background-color: $grey-90-20; + } + + &:dir(rtl) { + transform: scaleX(-1); + } + } +} + +.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-textbox-background-color) var(--newtab-search-icon) $search-icon-padding center no-repeat; + background-size: $search-icon-size; + border: solid 1px var(--newtab-search-border-color); + 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; + transition: opacity 500ms; + width: 100%; + + &:dir(rtl) { + background-position-x: right $search-icon-padding; + } + + &:hover { + box-shadow: $shadow-secondary, 0 0 0 1px $black-25; + } + + .fake-focus & { + border: $input-border-active; + box-shadow: var(--newtab-textbox-focus-boxshadow); + + .fake-caret { + display: block; + } + } + + .search-hidden & { + opacity: 0; + visibility: hidden; + } + + .fake-editable:focus { + outline: none; + caret-color: transparent; + } + + .fake-editable { + color: transparent; + height: 100%; + opacity: 0; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 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: 146px; + } + + &.visible-logo { + main { + padding-top: 276px; + } + } + + .search-wrapper { + $search-height: 35px; + $search-icon-size: 16px; + $search-icon-padding: 16px; + + border-bottom: solid 1px var(--newtab-border-secondary-color); + padding: 30px 0; + $search-header-bar-height: 95px; + background-color: var(--newtab-search-header-background-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; + } + } + + &.newtab-experience { + main { + padding-top: 124px; + } + + &.visible-logo { + main { + padding-top: 254px; + } + } + + .search-wrapper { + $search-height: 45px; + $search-icon-size: 24px; + $search-icon-padding: 16px; + + border-bottom: solid 1px var(--newtab-seperator-line-color); + padding: 27px 0; + + .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 { + 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 { + background-color: var(--newtab-search-dropdown-color); + border: 0; + box-shadow: $context-menu-shadow; + transform: translateY($textbox-shadow-size); + + .contentSearchHeader { + background-color: var(--newtab-search-dropdown-header-color); + color: var(--newtab-text-secondary-color); + } + + .contentSearchHeader, + .contentSearchSettingsButton { + border-color: var(--newtab-border-secondary-color); + } + + .contentSearchSuggestionsList { + border: 0; + } + + .contentSearchOneOffsTable { + background-color: var(--newtab-search-dropdown-header-color); + border-top: solid 1px var(--newtab-border-secondary-color); + } + + .contentSearchSearchWithHeaderSearchText { + color: var(--newtab-text-primary-color); + } + + .contentSearchSuggestionsContainer { + background-color: var(--newtab-search-dropdown-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-icon-secondary-color); + } + } + } + + .contentSearchOneOffsTable { + .contentSearchSuggestionsContainer { + background-color: var(--newtab-search-dropdown-header-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-secondary-color) $border-offset, var(--newtab-border-secondary-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/SectionMenu/SectionMenu.jsx b/browser/components/newtab/content-src/components/SectionMenu/SectionMenu.jsx new file mode 100644 index 0000000000..25e44ad060 --- /dev/null +++ b/browser/components/newtab/content-src/components/SectionMenu/SectionMenu.jsx @@ -0,0 +1,122 @@ +/* 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.jsm"; +import { ContextMenu } from "content-src/components/ContextMenu/ContextMenu"; +import React from "react"; +import { connect } from "react-redux"; +import { SectionMenuOptions } from "content-src/lib/section-menu-options"; + +const DEFAULT_SECTION_MENU_OPTIONS = [ + "MoveUp", + "MoveDown", + "Separator", + "RemoveSection", + "CheckCollapsed", + "Separator", + "ManageSection", +]; +const WEBEXT_SECTION_MENU_OPTIONS = [ + "MoveUp", + "MoveDown", + "Separator", + "CheckCollapsed", + "Separator", + "ManageWebExtension", +]; + +export class _SectionMenu extends React.PureComponent { + handleAddWhileCollapsed() { + const { action, userEvent } = SectionMenuOptions.ExpandSection(this.props); + this.props.dispatch(action); + if (userEvent) { + this.props.dispatch( + ac.UserEvent({ + event: userEvent, + source: this.props.source, + }) + ); + } + } + + getOptions() { + const { props } = this; + + const propOptions = props.isWebExtension + ? [...WEBEXT_SECTION_MENU_OPTIONS] + : [...DEFAULT_SECTION_MENU_OPTIONS]; + + // Remove Collapse/Expand related option if the `newNewtabExperience.enabled` + // pref is set to true. + if (props.Prefs.values["newNewtabExperience.enabled"]) { + if (props.isWebExtension) { + propOptions.splice(2, 2); + } else { + propOptions.splice(4, 1); + } + } + + // Remove the move related options if the section is fixed + if (props.isFixed) { + propOptions.splice(propOptions.indexOf("MoveUp"), 3); + } + // Prepend custom options and a separator + if (props.extraOptions) { + propOptions.splice(0, 0, ...props.extraOptions, "Separator"); + } + // Insert privacy notice before the last option ("ManageSection") + if (props.privacyNoticeURL) { + propOptions.splice(-1, 0, "PrivacyNotice"); + } + + const options = propOptions + .map(o => SectionMenuOptions[o](props)) + .map(option => { + const { action, id, type, userEvent } = option; + if (!type && id) { + option.onClick = () => { + const hasAddEvent = + userEvent === "MENU_ADD_TOPSITE" || + userEvent === "MENU_ADD_SEARCH"; + + if (props.collapsed && hasAddEvent) { + this.handleAddWhileCollapsed(); + } + + props.dispatch(action); + if (userEvent) { + props.dispatch( + ac.UserEvent({ + event: userEvent, + source: props.source, + }) + ); + } + }; + } + 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} + options={this.getOptions()} + keyboardAccess={this.props.keyboardAccess} + /> + ); + } +} + +export const SectionMenu = connect(state => ({ + Prefs: state.Prefs, +}))(_SectionMenu); 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..cc00e93b94 --- /dev/null +++ b/browser/components/newtab/content-src/components/Sections/Sections.jsx @@ -0,0 +1,390 @@ +/* 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.jsm"; +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, + icon, + 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} + icon={icon} + 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"> + {emptyState.icon && + emptyState.icon.startsWith("moz-extension://") ? ( + <span + className="empty-state-icon icon" + style={{ "background-image": `url('${emptyState.icon}')` }} + /> + ) : ( + <span + className={`empty-state-icon icon icon-${emptyState.icon}`} + /> + )} + <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..ba6e2681e0 --- /dev/null +++ b/browser/components/newtab/content-src/components/Sections/_Sections.scss @@ -0,0 +1,135 @@ +.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-icon { + background-position: center; + background-repeat: no-repeat; + background-size: 50px 50px; + -moz-context-properties: fill; + display: block; + fill: var(--newtab-icon-secondary-color); + height: 50px; + margin: 0 auto; + width: 50px; + } + + .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-section-navigation-text-color); + font-size: 12px; + line-height: 1.6; + margin-top: $topic-margin-top; + display: flex; + justify-content: space-between; + + a { + color: var(--newtab-link-secondary-color); + 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..540660d254 --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/SearchShortcutsForm.jsx @@ -0,0 +1,189 @@ +/* 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.jsm"; +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..cb57f11f4a --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/TopSite.jsx @@ -0,0 +1,823 @@ +/* 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.jsm"; +import { + MIN_CORNER_FAVICON_SIZE, + MIN_RICH_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.jsm"; +import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton"; +const SPOC_TYPE = "SPOC"; + +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 || !this.props.link.sponsored_position) && + 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": + this.dragged = true; + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/topsite-index", this.props.index); + event.target.blur(); + 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, newNewtabExperienceEnabled } = this.props; + + const { tippyTopIcon, faviconSize } = link; + let imageClassName; + let imageStyle; + let showSmallFavicon = false; + let smallFaviconStyle; + let smallFaviconFallback; + let hasScreenshotImage = + this.state.screenshotImage && this.state.screenshotImage.url; + let selectedColor; + + if (defaultStyle) { + // force no styles (letter fallback) even if the link has imagery + smallFaviconFallback = false; + if (newNewtabExperienceEnabled) { + 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 + + // TopSite spoc experiment only + const spocImgURL = + link.type === SPOC_TYPE ? link.customScreenshotURL : ""; + + imageClassName = "top-site-icon rich-icon"; + imageStyle = { + backgroundColor: link.backgroundColor, + backgroundImage: hasScreenshotImage + ? `url(${this.state.screenshotImage.url})` + : `url(${spocImgURL})`, + }; + } else if (tippyTopIcon || 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 { + // styles and class names for top sites with screenshot + small icon in top left corner + imageClassName = `screenshot${hasScreenshotImage ? " active" : ""}`; + imageStyle = { + backgroundImage: hasScreenshotImage + ? `url(${this.state.screenshotImage.url})` + : "none", + }; + // only show a favicon in top left if it's greater than 16x16 + if (faviconSize >= MIN_CORNER_FAVICON_SIZE) { + showSmallFavicon = true; + smallFaviconStyle = { backgroundImage: `url(${link.favicon})` }; + } else if (newNewtabExperienceEnabled) { + selectedColor = this.generateColor(); + imageClassName = ""; + } else if (hasScreenshotImage) { + // Don't show a small favicon if there is no screenshot, because that + // would result in two fallback icons + showSmallFavicon = true; + smallFaviconFallback = true; + } + } + + return { + showSmallFavicon, + smallFaviconFallback, + smallFaviconStyle, + imageStyle, + imageClassName, + selectedColor, + }; + } + + render() { + const { + children, + className, + isDraggable, + link, + onClick, + title, + newNewtabExperienceEnabled, + } = this.props; + const topSiteOuterClassName = `top-site-outer${ + className ? ` ${className}` : "" + }${link.isDragged ? " dragged" : ""}${ + link.searchTopSite ? " search-shortcut" : "" + }`; + const [letterFallback] = title; + const { + showSmallFavicon, + smallFaviconFallback, + smallFaviconStyle, + imageStyle, + imageClassName, + selectedColor, + } = this.calculateStyle(); + + let draggableProps = {}; + if (isDraggable) { + draggableProps = { + onClick: this.onDragEvent, + onDragEnd: this.onDragEvent, + onDragStart: this.onDragEvent, + onMouseDown: this.onDragEvent, + }; + } + + 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} + > + {(newNewtabExperienceEnabled && ( + <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={smallFaviconFallback && letterFallback} + style={smallFaviconStyle} + /> + )} + </div> + {link.searchTopSite && ( + <div className="top-site-icon search-topsite" /> + )} + </div> + )) || ( + <div + className="tile" + aria-hidden={true} + data-fallback={letterFallback} + > + <div className={imageClassName} style={imageStyle} /> + {link.searchTopSite && ( + <div className="top-site-icon search-topsite" /> + )} + {showSmallFavicon && ( + <div + className="top-site-icon default-icon" + data-fallback={smallFaviconFallback && letterFallback} + style={smallFaviconStyle} + /> + )} + </div> + )} + <div + className={`title${link.isPinned ? " has-icon pinned" : ""}${ + link.type === SPOC_TYPE || link.sponsored_position + ? " sponsored" + : "" + }`} + > + {(newNewtabExperienceEnabled && ( + <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> + {link.isPinned && <div className="icon icon-pin-small" />} + <span dir="auto">{title || <br />}</span> + <span + className="sponsored-label" + data-l10n-id="newtab-topsite-sponsored" + /> + </div> + )} + </div> + </a> + {children} + {link.type === SPOC_TYPE ? ( + <ImpressionStats + flightId={link.flightId} + rows={[ + { + id: link.id, + pos: link.pos, + shim: link.shim && link.shim.impression, + }, + ]} + dispatch={this.props.dispatch} + source={TOP_SITES_SOURCE} + /> + ) : null} + </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 ( + this.props.link.type === SPOC_TYPE || + this.props.link.sponsored_position + ) { + 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 }, + }), + }) + ); + + // Fire off a spoc specific impression. + if (this.props.link.type === SPOC_TYPE) { + 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, + }, + ], + }) + ); + } + 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="true" + 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 || site.sponsored_position) ? site : null + ); + const unpinned = topSites.filter( + site => site && !site.isPinned && !site.sponsored_position + ); + 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 (preview[nextIndex] && preview[nextIndex].sponsored_position) { + 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, + newNewtabExperienceEnabled: props.newNewtabExperienceEnabled, + }; + // 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"; + } + topSitesUI.push( + !link ? ( + <TopSitePlaceholder {...slotProps} {...commonProps} /> + ) : ( + <TopSite + link={link} + activeIndex={this.state.activeIndex} + onActivate={this.onActivate} + {...slotProps} + {...commonProps} + colors={props.colors} + /> + ) + ); + } + return ( + <ul + className={`top-sites-list${ + this.state.draggedSite ? " dnd-active" : "" + }`} + > + {topSitesUI} + </ul> + ); + } +} 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..258f749486 --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/TopSiteForm.jsx @@ -0,0 +1,330 @@ +/* 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.jsm"; +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; + + // When the newNewtabExperience is enabled by default, use only shortcut ids. + const addTopsitesHeaderL10nId = + this.props.newNewtabExperienceEnabled || + this.props.customizationMenuEnabled + ? "newtab-topsites-add-shortcut-header" + : "newtab-topsites-add-topsites-header"; + const editTopsitesHeaderL10nId = + this.props.newNewtabExperienceEnabled || + this.props.customizationMenuEnabled + ? "newtab-topsites-edit-shortcut-header" + : "newtab-topsites-edit-topsites-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" + /> + <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} + newNewtabExperienceEnabled={this.props.newNewtabExperienceEnabled} + /> + </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..6ec0271122 --- /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.shouldFocus} + 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/TopSites.jsx b/browser/components/newtab/content-src/components/TopSites/TopSites.jsx new file mode 100644 index 0000000000..2c9419cb42 --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/TopSites.jsx @@ -0,0 +1,241 @@ +/* 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.jsm"; +import { + MIN_CORNER_FAVICON_SIZE, + 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.jsm"; +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 && link.faviconSize >= MIN_CORNER_FAVICON_SIZE) { + return "screenshot_with_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_with_icon: 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 newNewtabExperienceEnabled = + props.Prefs.values["newNewtabExperience.enabled"]; + const customizationMenuEnabled = + props.Prefs.values["customizationMenu.enabled"]; + const colors = props.Prefs.values["newNewtabExperience.colors"]; + + if (props.Prefs.values["improvesearch.topSiteSearchShortcuts"]) { + extraMenuOptions.push("AddSearchShortcut"); + } + + const canShowCustomizationMenu = + newNewtabExperienceEnabled || customizationMenuEnabled; + const hideTitle = + props.Prefs.values.hideTopSitesTitle || canShowCustomizationMenu; + + // `collapsed` should be sent to CollapsibleSection as undefined if + // `props.TopSites.pref` is not set to true. + let collapsed; + if (props.TopSites.pref) { + collapsed = canShowCustomizationMenu + ? false + : props.TopSites.pref.collapsed; + } + + return ( + <ComponentPerfTimer + id="topsites" + initialized={props.TopSites.initialized} + dispatch={props.dispatch} + > + <CollapsibleSection + className="top-sites" + icon="topsites" + id="topsites" + title={props.title || { id: "newtab-section-header-topsites" }} + hideTitle={hideTitle} + extraMenuOptions={extraMenuOptions} + showPrefName="feeds.topsites" + eventSource={TOP_SITES_SOURCE} + collapsed={collapsed} + isFixed={props.isFixed} + isFirst={props.isFirst} + isLast={props.isLast} + dispatch={props.dispatch} + > + <TopSiteList + TopSites={props.TopSites} + TopSitesRows={props.TopSitesRows} + dispatch={props.dispatch} + topSiteIconType={topSiteIconType} + newNewtabExperienceEnabled={newNewtabExperienceEnabled} + 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} + newNewtabExperienceEnabled={newNewtabExperienceEnabled} + customizationMenuEnabled={customizationMenuEnabled} + /> + </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) => ({ + // For SPOC Experiment only, take TopSites from DiscoveryStream TopSites that takes in SPOC Data + TopSites: props.TopSitesWithSpoc || 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..7058557d88 --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/TopSitesConstants.js @@ -0,0 +1,43 @@ +/* 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 = [ + "PinTopSite", + "Separator", + "OpenInNewWindow", + "OpenInPrivateWindow", + "Separator", + "BlockUrl", + "ShowPrivacyInfo", +]; +export const TOP_SITES_SPONSORED_POSITION_CONTEXT_MENU_OPTIONS = [ + "PinTopSite", + "Separator", + "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 in the top left corner with a screenshot +export const MIN_CORNER_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..b4b2615613 --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/_TopSites.scss @@ -0,0 +1,803 @@ +$top-sites-size: $grid-unit; +$top-sites-size-nte: $grid-unit-small; +$top-sites-border-radius: 4px; +$nt-experience-top-sites-border-radius: 8px; +$top-sites-vertical-space: 8px; +$screenshot-size: cover; +$rich-icon-size: 96px; +$default-icon-wrapper-size: 42px; +$nt-experience-default-icon-wrapper-size: 32px; +$search-icon-wrapper-size: 42px; +$default-icon-size: 32px; +$default-icon-offset: 6px; +$half-base-gutter: $base-gutter / 2; +$hover-transition-duration: 150ms; +$letter-fallback-color: $white; + +.top-sites-list { + list-style: none; + margin: 0 (-$half-base-gutter); + padding: 0; + + // 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 { + padding: 0 $half-base-gutter; + display: inline-block; + + // container for context menu + .top-site-inner { + position: relative; + + > a { + color: inherit; + display: block; + outline: none; + } + } + + .tile { // sass-lint:disable-block property-sort-order + border-radius: $top-sites-border-radius; + box-shadow: inset $inner-box-shadow, var(--newtab-card-shadow); + cursor: pointer; + position: relative; + + // For letter fallback + align-items: center; + color: var(--newtab-text-secondary-color); + display: flex; + font-size: 32px; + font-weight: 200; + justify-content: center; + text-transform: uppercase; // sass-lint:disable-line no-disallowed-properties + + .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; // sass-lint:disable-line no-disallowed-properties + 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-topsites-background-color); + background-position: center center; + background-repeat: no-repeat; + border-radius: $top-sites-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('#{$image-path}glyph-search-16.svg'); + background-size: 26px; + background-color: $blue-60; + border-radius: $default-icon-wrapper-size; + -moz-context-properties: fill; + fill: $white; + box-shadow: var(--newtab-card-shadow); + transition-duration: $hover-transition-duration; + transition-property: background-size, bottom, inset-inline-end, height, width; + height: $search-icon-wrapper-size; + width: $search-icon-wrapper-size; + bottom: -$default-icon-offset; + inset-inline-end: -$default-icon-offset; + } + + .title { + color: var(--newtab-topsites-label-color); + font: message-box; + padding-top: 4px; + text-align: center; + position: relative; + + .icon { + fill: var(--newtab-icon-tertiary-color); + } + + span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .sponsored-label { + color: $grey-50; + 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('#{$image-path}glyph-edit-16.svg'); + } + + &.placeholder { + .screenshot { + display: none; + } + } + + &.dragged { + .tile { + background: $grey-20; + box-shadow: none; + + *, + &::before { + display: none; + } + } + + .title { + visibility: hidden; + } + } +} + +.edit-topsites-wrapper { + .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: $blue-50; + 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-textbox-background-color); + 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: $red-60; + border-radius: 2px; + color: $white; + inset-inline-start: 3px; + padding: 5px 12px; + position: absolute; + top: 44px; + z-index: 1; + + // tooltip caret + &::before { + background: $red-60; + 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; // sass-lint:disable-line no-url-domains + content: ''; + height: 21px; + left: $checkbox-offset; + position: absolute; + top: $checkbox-offset; + width: 21px; + -moz-context-properties: fill; + fill: var(--newtab-link-primary-color); + z-index: 2; + + [dir='rtl'] & { + left: auto; + right: $checkbox-offset; + } + } + + // when selected, highlight the tile + [type='checkbox']:checked + label { + .tile { + box-shadow: 0 0 0 2px var(--newtab-link-primary-color); + } + } + + // 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-link-primary-color); + } +} + +.outer-wrapper { + // Special styling for when we are using the old new tab styling, + // this is to be removed once the changes are made permanent + &:not(.newtab-experience) { + .top-site-outer { + @include context-menu-button; + + .tile { + height: $top-sites-size; + width: $top-sites-size; + transition: box-shadow $hover-transition-duration; + } + + .top-site-icon { + box-shadow: var(--newtab-topsites-icon-shadow); + } + + .title { + width: $top-sites-size; + + &.has-icon { + span { + padding: 0 13px; + } + } + + .icon { + inset-inline-start: 0; + position: absolute; + top: 0.5em; + } + } + + // container for context menu + .top-site-inner { + position: relative; + + > a { + color: inherit; + display: block; + outline: none; + + &:is(.active, :focus) { + .tile { + @include fade-in; + } + } + } + } + + .screenshot { + background-color: $white; + background-position: top left; + background-size: $screenshot-size; + border-radius: $top-sites-border-radius; + box-shadow: inset $inner-box-shadow; + height: 100%; + opacity: 0; + position: absolute; + top: 0; + left: 0; + transition: opacity 1s; + width: 100%; + + &.active { + opacity: 1; + } + } + + .default-icon, + .search-topsite { + bottom: -$default-icon-offset; + inset-inline-end: -$default-icon-offset; + } + + &:hover .search-topsite { + $hover-icon-wrapper-size: $default-icon-wrapper-size + 4; + $hover-icon-offset: -$default-icon-offset - 3; + + background-size: 28px; + border-radius: $hover-icon-wrapper-size; + bottom: $hover-icon-offset; + height: $hover-icon-wrapper-size; + inset-inline-end: $hover-icon-offset; + width: $hover-icon-wrapper-size; + } + + &.placeholder { + .tile { + box-shadow: inset $inner-box-shadow; + } + } + } + + .top-sites-list { + &:not(.dnd-active) { + .top-site-outer:is(.active, :focus, :hover) { + .tile { + @include fade-in; + } + + @include context-menu-button-hover; + } + } + + li { + margin: 0 0 $top-sites-vertical-space; + } + } + } + + // Special styling for the New Tab Experience styles, + // This is to be incorporated once the styles are made permanent + &.newtab-experience { + .top-site-outer { + @include context-menu-button-newtab-experience; + width: 120px; + padding-block: 20px 4px; + border-radius: 8px; + + .edit-button { + background-image: url('#{$image-path}glyph-edit-16.svg'); + } + + .tile { + border-radius: $nt-experience-top-sites-border-radius; + box-shadow: $inner-box-shadow-nte, $tile-shadow-second; + background-color: var(--newtab-topsites-background-color); + justify-content: center; + margin: 0 auto; + height: $top-sites-size-nte; + width: $top-sites-size-nte; + } + + .title { + color: var(--newtab-background-primary-text-color); + padding-top: 8px; + font-size: 12px; + + .icon { + margin-inline-end: 2px; + fill: var(--newtab-background-primary-text-color); + } + + .sponsored-label { + font-size: 12px; + } + } + + .default-icon, + .search-topsite { + height: $nt-experience-default-icon-wrapper-size; + width: $nt-experience-default-icon-wrapper-size; + } + + .search-topsite { + background-size: 16px; + height: 32px; + width: 32px; + } + + &:hover .search-topsite { + $hover-icon-wrapper-size: $search-icon-wrapper-size + 4; + } + + &.placeholder { + .tile { + box-shadow: $inner-box-shadow; + } + } + } + + .top-sites-list { + // 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-nte) { + :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-nte) { + :nth-child(6n) { + @include context-menu-open-left; + } + } + @media (min-width: $break-point-large) and (max-width: $break-point-large + $card-width-nte) { + :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-nte) { + :nth-child(8n) { + @include context-menu-open-left; + } + } + @media (min-width: $break-point-widest) and (max-width: $break-point-widest + $card-width-nte) { + :nth-child(8n+7) { + @include context-menu-open-left; + } + } + } + + &:not(.dnd-active) { + .top-site-outer:is(.active, :focus, :hover) { + @include nt-experience-context-menu-button-hover; + background: var(--newtab-topsites-outer-card-hover); + } + } + } + +} + +//used for tooltips below form element +@keyframes fade-up-tt { + 0% { + opacity: 0; + transform: translateY(15px); + } + + 100% { + opacity: 1; + transform: translateY(0); + } +} 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..0a2159beee --- /dev/null +++ b/browser/components/newtab/content-src/components/Topics/_Topics.scss @@ -0,0 +1,23 @@ +.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..54efde5e52 --- /dev/null +++ b/browser/components/newtab/content-src/lib/aboutwelcome-utils.js @@ -0,0 +1,235 @@ +/* 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 AboutWelcomeUtils = { + handleUserAction(action) { + window.AWSendToParent("SPECIAL_ACTION", action); + }, + sendImpressionTelemetry(messageId, context) { + window.AWSendEventTelemetry({ + event: "IMPRESSION", + event_context: context, + message_id: messageId, + }); + }, + sendActionTelemetry(messageId, elementId) { + const ping = { + event: "CLICK_BUTTON", + event_context: { + source: elementId, + page: "about:welcome", + }, + message_id: messageId, + }; + window.AWSendEventTelemetry(ping); + }, + 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); // eslint-disable-line no-console + } + } catch (e) { + flowParams = null; + } + return flowParams; + }, + sendEvent(type, detail) { + document.dispatchEvent( + new CustomEvent(`AWPage:${type}`, { + bubbles: true, + detail, + }) + ); + }, + hasDarkMode() { + return document.body.hasAttribute("lwt-newtab-brighttext"); + }, +}; + +export const DEFAULT_RTAMO_CONTENT = { + template: "return_to_amo", + content: { + header: { string_id: "onboarding-welcome-header" }, + subtitle: { string_id: "return-to-amo-subtitle" }, + text: { + string_id: "return-to-amo-addon-title", + }, + primary_button: { + label: { string_id: "return-to-amo-add-extension-label" }, + action: { + type: "INSTALL_ADDON_FROM_URL", + data: { url: null, telemetrySource: "rtamo" }, + }, + }, + startButton: { + label: { + string_id: "onboarding-not-now-button-label", + }, + message_id: "RTAMO_START_BROWSING_BUTTON", + action: { + type: "OPEN_AWESOME_BAR", + }, + }, + }, +}; + +export const DEFAULT_WELCOME_CONTENT = { + template: "multistage", + screens: [ + { + id: "AW_GET_STARTED", + order: 0, + content: { + zap: true, + title: { + string_id: "onboarding-multistage-welcome-header", + }, + subtitle: { string_id: "onboarding-multistage-welcome-subtitle" }, + primary_button: { + label: { + string_id: "onboarding-multistage-welcome-primary-button-label", + }, + action: { + navigate: true, + }, + }, + secondary_button: { + text: { + string_id: "onboarding-multistage-welcome-secondary-button-text", + }, + label: { + string_id: "onboarding-multistage-welcome-secondary-button-label", + }, + position: "top", + action: { + type: "SHOW_FIREFOX_ACCOUNTS", + addFlowParams: true, + data: { + entrypoint: "activity-stream-firstrun", + }, + }, + }, + }, + }, + { + id: "AW_IMPORT_SETTINGS", + order: 1, + content: { + zap: true, + help_text: { + text: { string_id: "onboarding-import-sites-disclaimer" }, + }, + title: { string_id: "onboarding-multistage-import-header" }, + subtitle: { string_id: "onboarding-multistage-import-subtitle" }, + tiles: { + type: "topsites", + showTitles: true, + }, + primary_button: { + label: { + string_id: "onboarding-multistage-import-primary-button-label", + }, + action: { + type: "SHOW_MIGRATION_WIZARD", + navigate: true, + }, + }, + secondary_button: { + label: { + string_id: "onboarding-multistage-import-secondary-button-label", + }, + action: { + navigate: true, + }, + }, + }, + }, + { + id: "AW_CHOOSE_THEME", + order: 2, + content: { + zap: true, + title: { string_id: "onboarding-multistage-theme-header" }, + subtitle: { string_id: "onboarding-multistage-theme-subtitle" }, + tiles: { + type: "theme", + action: { + theme: "<event>", + }, + data: [ + { + theme: "automatic", + label: { + string_id: "onboarding-multistage-theme-label-automatic", + }, + tooltip: { + string_id: "onboarding-multistage-theme-tooltip-automatic-2", + }, + description: { + string_id: + "onboarding-multistage-theme-description-automatic-2", + }, + }, + { + theme: "light", + label: { string_id: "onboarding-multistage-theme-label-light" }, + tooltip: { + string_id: "onboarding-multistage-theme-tooltip-light-2", + }, + description: { + string_id: "onboarding-multistage-theme-description-light", + }, + }, + { + theme: "dark", + label: { string_id: "onboarding-multistage-theme-label-dark" }, + tooltip: { + string_id: "onboarding-multistage-theme-tooltip-dark-2", + }, + description: { + string_id: "onboarding-multistage-theme-description-dark", + }, + }, + { + theme: "alpenglow", + label: { + string_id: "onboarding-multistage-theme-label-alpenglow", + }, + tooltip: { + string_id: "onboarding-multistage-theme-tooltip-alpenglow-2", + }, + description: { + string_id: "onboarding-multistage-theme-description-alpenglow", + }, + }, + ], + }, + primary_button: { + label: { + string_id: "onboarding-multistage-theme-primary-button-label", + }, + action: { + navigate: true, + }, + }, + secondary_button: { + label: { + string_id: "onboarding-multistage-theme-secondary-button-label", + }, + action: { + theme: "automatic", + navigate: 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..bd3da63682 --- /dev/null +++ b/browser/components/newtab/content-src/lib/constants.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/. */ + +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_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..ecc57dba72 --- /dev/null +++ b/browser/components/newtab/content-src/lib/detect-user-session-start.js @@ -0,0 +1,78 @@ +/* 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.jsm"; +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..c0931ab5d8 --- /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/frame-script */ + +import { + actionCreators as ac, + actionTypes as at, + actionUtils as au, +} from "common/Actions.jsm"; +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); // eslint-disable-line no-console + 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..cb45c0df2d --- /dev/null +++ b/browser/components/newtab/content-src/lib/link-menu-options.js @@ -0,0 +1,276 @@ +/* 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.jsm"; + +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, + }, + }), + 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, + ...(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) => ({ + 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) => + site.pocket_id + ? LinkMenuOptions.DeleteFromPocket(site) + : LinkMenuOptions.SaveToPocket(site, index), + CheckBookmarkOrArchive: site => + site.pocket_id + ? LinkMenuOptions.ArchiveFromPocket(site) + : LinkMenuOptions.CheckBookmark(site), + 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/section-menu-options.js b/browser/components/newtab/content-src/lib/section-menu-options.js new file mode 100644 index 0000000000..9a3070a9f4 --- /dev/null +++ b/browser/components/newtab/content-src/lib/section-menu-options.js @@ -0,0 +1,93 @@ +/* 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.jsm"; + +/** + * List of functions that return items that can be included as menu options in a + * SectionMenu. All functions take the section as the only parameter. + */ +export const SectionMenuOptions = { + Separator: () => ({ type: "separator" }), + MoveUp: section => ({ + id: "newtab-section-menu-move-up", + icon: "arrowhead-up", + action: ac.OnlyToMain({ + type: at.SECTION_MOVE, + data: { id: section.id, direction: -1 }, + }), + userEvent: "MENU_MOVE_UP", + disabled: !!section.isFirst, + }), + MoveDown: section => ({ + id: "newtab-section-menu-move-down", + icon: "arrowhead-down", + action: ac.OnlyToMain({ + type: at.SECTION_MOVE, + data: { id: section.id, direction: +1 }, + }), + userEvent: "MENU_MOVE_DOWN", + disabled: !!section.isLast, + }), + RemoveSection: section => ({ + id: "newtab-section-menu-remove-section", + icon: "dismiss", + action: ac.SetPref(section.showPrefName, false), + userEvent: "MENU_REMOVE", + }), + CollapseSection: section => ({ + id: "newtab-section-menu-collapse-section", + icon: "minimize", + action: ac.OnlyToMain({ + type: at.UPDATE_SECTION_PREFS, + data: { id: section.id, value: { collapsed: true } }, + }), + userEvent: "MENU_COLLAPSE", + }), + ExpandSection: section => ({ + id: "newtab-section-menu-expand-section", + icon: "maximize", + action: ac.OnlyToMain({ + type: at.UPDATE_SECTION_PREFS, + data: { id: section.id, value: { collapsed: false } }, + }), + userEvent: "MENU_EXPAND", + }), + ManageSection: section => ({ + id: "newtab-section-menu-manage-section", + icon: "settings", + action: ac.OnlyToMain({ type: at.SETTINGS_OPEN }), + userEvent: "MENU_MANAGE", + }), + ManageWebExtension: section => ({ + id: "newtab-section-menu-manage-webext", + icon: "settings", + action: ac.OnlyToMain({ type: at.OPEN_WEBEXT_SETTINGS, data: section.id }), + }), + AddTopSite: section => ({ + id: "newtab-section-menu-add-topsite", + icon: "add", + action: { type: at.TOP_SITES_EDIT, data: { index: -1 } }, + userEvent: "MENU_ADD_TOPSITE", + }), + AddSearchShortcut: section => ({ + id: "newtab-section-menu-add-search-engine", + icon: "search", + action: { type: at.TOP_SITES_OPEN_SEARCH_SHORTCUTS_MODAL }, + userEvent: "MENU_ADD_SEARCH", + }), + PrivacyNotice: section => ({ + id: "newtab-section-menu-privacy-notice", + icon: "info", + action: ac.OnlyToMain({ + type: at.OPEN_LINK, + data: { url: section.privacyNoticeURL }, + }), + userEvent: "MENU_PRIVACY_NOTICE", + }), + CheckCollapsed: section => + section.collapsed + ? SectionMenuOptions.ExpandSection(section) + : SectionMenuOptions.CollapseSection(section), +}; 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..f15fb777a5 --- /dev/null +++ b/browser/components/newtab/content-src/lib/selectLayoutRender.js @@ -0,0 +1,260 @@ +/* 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", + "Hero", + "HorizontalRule", + "List", + ]; + + const filterArray = []; + + if (!prefs["feeds.topsites"]) { + filterArray.push("TopSites"); + } + + if (!locale.startsWith("en-")) { + filterArray.push("Navigation"); + } + + 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/_OnboardingImages.scss b/browser/components/newtab/content-src/styles/_OnboardingImages.scss new file mode 100644 index 0000000000..cd7fa48ad2 --- /dev/null +++ b/browser/components/newtab/content-src/styles/_OnboardingImages.scss @@ -0,0 +1,71 @@ +// Used for Trailhead and about:welcome + +.onboardingMessageImage { + &.addons { + background-image: url('chrome://activity-stream/content/data/content/assets/illustration-addons@2x.png'); + } + + &.privatebrowsing { + background-image: url('chrome://activity-stream/content/data/content/assets/illustration-privatebrowsing@2x.png'); + } + + &.screenshots { + background-image: url('chrome://activity-stream/content/data/content/assets/illustration-screenshots@2x.png'); + } + + &.gift { + background-image: url('chrome://activity-stream/content/data/content/assets/illustration-gift@2x.png'); + } + + &.sync { + background-image: url('chrome://activity-stream/content/data/content/assets/illustration-sync@2x.png'); + } + + &.devices { + background-image: url('chrome://activity-stream/content/data/content/assets/trailhead/card-illo-devices.svg'); + } + + &.fbcont { + background-image: url('chrome://activity-stream/content/data/content/assets/trailhead/card-illo-fbcont.svg'); + } + + &.import { + background-image: url('chrome://activity-stream/content/data/content/assets/trailhead/card-illo-import.svg'); + } + + &.ffmonitor { + background-image: url('chrome://activity-stream/content/data/content/assets/trailhead/card-illo-ffmonitor.svg'); + } + + &.ffsend { + background-image: url('chrome://activity-stream/content/data/content/assets/trailhead/card-illo-ffsend.svg'); + } + + &.lockwise { + background-image: url('chrome://activity-stream/content/data/content/assets/trailhead/card-illo-lockwise.svg'); + } + + &.mobile { + background-image: url('chrome://activity-stream/content/data/content/assets/trailhead/card-illo-mobile.svg'); + } + + &.pledge { + background-image: url('chrome://activity-stream/content/data/content/assets/trailhead/card-illo-pledge.svg'); + } + + &.pocket { + background-image: url('chrome://activity-stream/content/data/content/assets/trailhead/card-illo-pocket.svg'); + } + + &.private { + background-image: url('chrome://activity-stream/content/data/content/assets/trailhead/card-illo-private.svg'); + } + + &.sendtab { + background-image: url('chrome://activity-stream/content/data/content/assets/trailhead/card-illo-sendtab.svg'); + } + + &.tracking { + background-image: url('chrome://activity-stream/content/data/content/assets/trailhead/card-illo-tracking.svg'); + } +} 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..702ef9bdd1 --- /dev/null +++ b/browser/components/newtab/content-src/styles/_activity-stream.scss @@ -0,0 +1,179 @@ +@import './normalize'; +@import './variables'; +@import './theme'; +@import './icons'; +@import './mixins'; + +html { + height: 100%; +} + +body, +#root { // sass-lint:disable-line no-ids + min-height: 100vh; +} + +#root { // sass-lint:disable-line no-ids + 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; +} + +a { + text-decoration: none; +} + +.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-button-primary-color); + border: solid 1px var(--newtab-button-primary-color); + color: $white; + 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 +@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/Hero/Hero'; +@import '../components/DiscoveryStreamComponents/Highlights/Highlights'; +@import '../components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule'; +@import '../components/DiscoveryStreamComponents/List/List'; +@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'; + +// 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'; 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..f665dc5fb3 --- /dev/null +++ b/browser/components/newtab/content-src/styles/_icons.scss @@ -0,0 +1,208 @@ +.icon { + background-position: center center; + background-repeat: no-repeat; + background-size: $icon-size; + -moz-context-properties: fill; + display: inline-block; + color: var(--newtab-icon-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-icon-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://activity-stream/content/data/content/assets/glyph-cancel-16.svg'); + } + + &.icon-delete { + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-delete-16.svg'); + } + + &.icon-search { + background-image: url('chrome://browser/skin/search-glass.svg'); + } + + &.icon-modal-delete { + flex-shrink: 0; + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-modal-delete-32.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://activity-stream/content/data/content/assets/glyph-dismiss-16.svg'); + } + + &.icon-info { + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-info-16.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://activity-stream/content/data/content/assets/glyph-edit-16.svg'); + } + + &.icon-pocket { + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-pocket-16.svg'); + } + + &.icon-pocket-save { + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-pocket-save-16.svg'); + } + + &.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://activity-stream/content/data/content/assets/glyph-trending-16.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://activity-stream/content/data/content/assets/glyph-topsites-16.svg'); + } + + &.icon-pin-small { + @include flip-icon; + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-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/download-icons.svg#arrow-with-bar'); + } + + &.icon-copy { + background-image: url('chrome://browser/skin/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://activity-stream/content/data/content/assets/glyph-highlights-16.svg'); + } + + &.icon-arrowhead-down { + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-arrowhead-down-16.svg'); + } + + &.icon-arrowhead-down-small { + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-arrowhead-down-12.svg'); + background-size: $smaller-icon-size; + height: $smaller-icon-size; + width: $smaller-icon-size; + } + + &.icon-arrowhead-forward-small { + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-arrowhead-down-12.svg'); + background-size: $smaller-icon-size; + height: $smaller-icon-size; + transform: rotate(-90deg); + width: $smaller-icon-size; + + &:dir(rtl) { + transform: rotate(90deg); + } + } + + &.icon-arrowhead-up { + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-arrowhead-down-16.svg'); + transform: rotate(180deg); + } + + &.icon-add { + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-add-16.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://activity-stream/content/data/content/assets/glyph-arrow.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..0189408b06 --- /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-card-placeholder-color); + background-position: center; + background-repeat: no-repeat; + background-size: cover; + border-radius: 4px; + box-shadow: inset 0 0 0 0.5px $black-15; +} + +// 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..32f5ef3677 --- /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; // sass-lint:disable-line 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..29e313873e --- /dev/null +++ b/browser/components/newtab/content-src/styles/_theme.scss @@ -0,0 +1,231 @@ +@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-primary-color); +$border-secondary: 1px solid var(--newtab-border-secondary-color); +$inner-box-shadow: 0 0 0 1px var(--newtab-inner-box-shadow-color); +$inner-box-shadow-nte: 0 3px 8px var(--newtab-inner-box-shadow-color-nte); +$tile-shadow-second: 0 0 2px var(--newtab-tile-shadow-secondary); +$input-border: 1px solid var(--newtab-textbox-border); +$input-border-active: 1px solid var(--newtab-textbox-focus-color); +$input-error-border: 1px solid $red-60; +$input-error-boxshadow: textbox-shadow($red-60); +$shadow-primary: 0 0 0 5px var(--newtab-card-active-outline-color); +$shadow-secondary: 0 1px 4px 0 $grey-90-20; + +// Default theme +body { + // General styles + --newtab-background-color: #{$grey-10}; + --newtab-border-primary-color: #{$grey-40}; + --newtab-border-secondary-color: #{$grey-30}; + --newtab-element-active-color: #{$grey-30-60}; + --newtab-element-hover-color: #{$grey-20}; + --newtab-icon-primary-color: #{$grey-90-80}; + --newtab-icon-secondary-color: #{$grey-90-60}; + --newtab-icon-tertiary-color: #{$grey-30}; + --newtab-inner-box-shadow-color: #{$black-10}; + --newtab-inner-box-shadow-color-nte: #{$newtab-card-firstshadow}; + --newtab-tile-shadow-secondary: #{$newtab-tile-shadow-secondary}; + + --newtab-link-primary-color: #{$blue-60}; + --newtab-link-secondary-color: #{$teal-70}; + --newtab-text-conditional-color: #{$grey-60}; + --newtab-text-primary-color: #{$grey-90}; + --newtab-text-secondary-color: #{$grey-50}; + --newtab-textbox-background-color: #{$white}; + --newtab-textbox-border: #{$grey-90-20}; + @include textbox-focus($blue-60); // sass-lint:disable-line mixins-before-declarations + + // Background buttons + --newtab-background-button-color: #{$newtab-background-button-default-color}; + --newtab-background-button-text-color: #{$newtab-background-button-default-text-color}; + --newtab-background-button-hover-color: #{$newtab-background-button-default-hover-color}; + --newtab-background-button-active-color: #{$newtab-background-button-default-active-color}; + + // Buttons + --newtab-button-primary-color: #{$blue-60}; + --newtab-button-secondary-color: inherit; + // Feed buttons + --newtab-feed-button-background: #{$grey-20}; + --newtab-feed-button-text: #{$grey-90}; + --newtab-feed-button-background-faded: #{$grey-20-60}; + --newtab-feed-button-text-faded: #{$grey-90-00}; + --newtab-feed-button-spinner: #{$grey-50}; + + + // Context menu + --newtab-contextmenu-background-color: #{$grey-10}; + --newtab-contextmenu-button-color: #{$white}; + + // Modal + overlay + --newtab-modal-color: #{$white}; + --newtab-overlay-color: #{$grey-20-80}; + + // Sections + --newtab-section-header-text-color: #{$grey-50}; + --newtab-section-navigation-text-color: #{$grey-50}; + --newtab-section-active-contextmenu-color: #{$grey-90}; + + // Search + --newtab-search-border-color: transparent; + --newtab-search-dropdown-color: #{$white}; + --newtab-search-dropdown-header-color: #{$grey-10}; + --newtab-search-header-background-color: #{$grey-10-95}; + --newtab-search-icon-color: #{$grey-90-40}; + --newtab-search-wordmark-color: #{$firefox-wordmark-default-color}; + + // Top Sites + --newtab-topsites-background-color: #{$white}; + --newtab-topsites-icon-shadow: inset #{$inner-box-shadow}; + --newtab-topsites-label-color: inherit; + --newtab-topsites-outer-card-hover: #{$newtab-card-hover-color}; + --newtab-topsites-context-menu-hover: #{$newtab-card-hover-color}; + + // Cards + --newtab-card-active-outline-color: #{$grey-30}; + --newtab-card-background-color: #{$white}; + --newtab-card-hairline-color: #{$black-10}; + --newtab-card-placeholder-color: #{$grey-30}; + --newtab-card-shadow: 0 1px 4px 0 #{$grey-90-10}; + + // Snippets + --newtab-snippets-background-color: #{$white}; + --newtab-snippets-hairline-color: transparent; + + // New New Tab + --newtab-background-primary-text-color: #{$newtab-background-primary-text-color}; + --newtab-focus-outline: #{$newtab-focus-outline-color}; + --newtab-focus-border: #{$blue-60}; + --newtab-focus-border-selected: #{$newtab-card-tint}; + --newtab-seperator-line-color: #{$newtab-card-separator-line-color}; + --newtab-primary-action-background: #{$blue-60}; + --newtab-primary-action-background-off: #{$newtab-primary-action-background-off}; + --customize-menu-primary-text-color: #{$newtab-card-primary-text-color}; + --customize-menu-check-fill: #{$white}; + --customize-menu-background: #{$white}; + --customize-menu-secondary-action-background: #{$grey-10}; + --customize-menu-secondary-action-background-hover: #{$grey-20}; + --customize-menu-secondary-action-background-active: #{$newtab-card-secondary-action-background-active}; + --customize-menu-seperator-line-color: #{$newtab-card-separator-line-color}; + --customize-menu-first-shadow: #{$newtab-card-firstshadow}; + --customize-menu-second-shadow: #{$newtab-card-secondshadow}; + --customize-menu-primary-action-text: #{$white}; + --customize-menu-line-color: #{$newtab-card-line-color}; + --newtab-search-first-shadow: #{$newtab-card-firstshadow}; + --newtab-search-second-shadow: #{$newtab-card-secondshadow}; + --newtab-search-text-color: #{$newtab-card-secondary-text-color}; + --newtab-card-first-shadow: #{$newtab-card-firstshadow}; + --newtab-card-second-shadow: #{$newtab-card-secondshadow}; + --newtab-wordmark-color: #{$newtab-wordmark-default-color}; + + &[lwt-newtab-brighttext] { + // General styles + --newtab-background-color: #{$grey-80}; + --newtab-border-primary-color: #{$grey-10-80}; + --newtab-border-secondary-color: #{$grey-10-10}; + --newtab-button-primary-color: #{$blue-60}; + --newtab-button-secondary-color: #{$grey-70}; + --newtab-element-active-color: #{$grey-10-20}; + --newtab-element-hover-color: #{$grey-10-10}; + --newtab-icon-primary-color: #{$grey-10-80}; + --newtab-icon-secondary-color: #{$grey-10-40}; + --newtab-icon-tertiary-color: #{$grey-10-40}; + --newtab-inner-box-shadow-color: #{$grey-10-20}; + --newtab-inner-box-shadow-color-nte: #{$newtab-card-darktheme-firstshadow}; + --newtab-tile-shadow-secondary: #{$newtab-tile-darktheme-shadow-secondary}; + --newtab-link-primary-color: #{$blue-40}; + --newtab-link-secondary-color: #{$pocket-teal}; + --newtab-text-conditional-color: #{$grey-10}; + --newtab-text-primary-color: #{$grey-10}; + --newtab-text-secondary-color: #{$grey-10-80}; + --newtab-textbox-background-color: #{$grey-70}; + --newtab-textbox-border: #{$grey-10-20}; + @include textbox-focus($blue-40); // sass-lint:disable-line mixins-before-declarations + + // Background buttons. + --newtab-background-button-color: #{$newtab-background-button-darktheme-color}; + --newtab-background-button-text-color: #{$newtab-background-button-darktheme-text-color}; + --newtab-background-button-hover-color: #{$newtab-background-button-darktheme-hover-color}; + --newtab-background-button-active-color: #{$newtab-background-button-darktheme-active-color}; + + // Feed buttons + --newtab-feed-button-background: #{$grey-70}; + --newtab-feed-button-text: #{$grey-10}; + --newtab-feed-button-background-faded: #{$grey-70-60}; + --newtab-feed-button-text-faded: #{$grey-10-00}; + --newtab-feed-button-spinner: #{$grey-30}; + + // Context menu + --newtab-contextmenu-background-color: #{$grey-60}; + --newtab-contextmenu-button-color: #{$grey-80}; + + // Modal + overlay + --newtab-modal-color: #{$grey-80}; + --newtab-overlay-color: #{$grey-90-80}; + + // Sections + --newtab-section-header-text-color: #{$grey-10-80}; + --newtab-section-navigation-text-color: #{$grey-10-80}; + --newtab-section-active-contextmenu-color: #{$white}; + + // Search + --newtab-search-border-color: #{$grey-10-20}; + --newtab-search-dropdown-color: #{$grey-70}; + --newtab-search-dropdown-header-color: #{$grey-60}; + --newtab-search-header-background-color: #{$grey-80-95}; + --newtab-search-icon-color: #{$grey-10-60}; + --newtab-search-wordmark-color: #{$firefox-wordmark-darktheme-color}; + + // Top Sites + --newtab-topsites-background-color: #{$grey-70}; + --newtab-topsites-icon-shadow: none; + --newtab-topsites-label-color: #{$grey-10-80}; + --newtab-topsites-outer-card-hover: #{$newtab-card-darktheme-hover-color}; + --newtab-topsites-context-menu-hover: #{$newtab-card-darktheme-hover-color}; + + // Cards + --newtab-card-active-outline-color: #{$grey-60}; + --newtab-card-background-color: #{$grey-70}; + --newtab-card-hairline-color: #{$grey-10-10}; + --newtab-card-placeholder-color: #{$grey-60}; + --newtab-card-shadow: 0 1px 8px 0 #{$grey-90-20}; + + // Snippets + --newtab-snippets-background-color: #{$grey-70}; + --newtab-snippets-hairline-color: #{$white-10}; + + // New New Tab + --newtab-background-primary-text-color: #{$newtab-background-darktheme-primary-text-color}; + --newtab-focus-outline: #{$newtab-darktheme-focus-outline-color}; + --newtab-focus-border: #{$newtab-darktheme-focus-border}; + --newtab-focus-border-selected: #{$newtab-darktheme-focus-border-selected}; + --newtab-primary-action-background: #{$newtab-darktheme-primary-action-background}; + --newtab-primary-action-background-off: #{$newtab-darktheme-primary-action-background-off}; + --newtab-seperator-line-color: #{$newtab-card-darktheme-separator-line-color}; + --customize-menu-primary-text-color: #{$newtab-card-darktheme-primary-text-color}; + --customize-menu-check-fill: #{$newtab-card-darktheme-primary-text-color}; + --customize-menu-background: #{$grey-70}; + --customize-menu-secondary-action-background: #{$newtab-card-darktheme-secondary-action-background}; + --customize-menu-secondary-action-background-hover: #{$newtab-card-darktheme-secondary-action-background-hover}; + --customize-menu-secondary-action-background-active: #{$newtab-card-darktheme-secondary-action-background-active}; + --customize-menu-seperator-line-color: #{$newtab-card-darktheme-separator-line-color}; + --customize-menu-first-shadow: #{$newtab-card-darktheme-firstshadow}; + --customize-menu-second-shadow: #{$newtab-darktheme-card-secondshadow}; + --customize-menu-primary-action-text: #{$newtab-card-darktheme-primary-text-color}; + --customize-menu-line-color: #{$newtab-card-darktheme-line-color}; + --newtab-search-first-shadow: #{$newtab-card-darktheme-firstshadow}; + --newtab-search-second-shadow: #{$newtab-darktheme-card-secondshadow}; + --newtab-search-text-color: #{$newtab-card-darktheme-secondary-text-color}; + --newtab-card-first-shadow: #{$newtab-card-darktheme-firstshadow}; + --newtab-card-second-shadow: #{$newtab-darktheme-card-secondshadow}; + --newtab-wordmark-color: #{$firefox-wordmark-darktheme-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..b43b9fe924 --- /dev/null +++ b/browser/components/newtab/content-src/styles/_variables.scss @@ -0,0 +1,323 @@ +// Photon colors from http://design.firefox.com/photon/visuals/color.html +$blue-40: #45A1FF; +$blue-50: #0A84FF; +$blue-60: #0060DF; +$blue-70: #003EAA; +$blue-80: #002275; +$grey-10: #F9F9FA; +$grey-20: #EDEDF0; +$grey-30: #D7D7DB; +$grey-40: #B1B1B3; +$grey-50: #737373; +$grey-60: #4A4A4F; +$grey-70: #38383D; +$grey-80: #2A2A2E; +$grey-90: #0C0C0D; +$teal-10: #A7FFFE; +$teal-60: #00C8D7; +$teal-70: #008EA4; +$teal-80: #005A71; +$red-60: #D70022; +$yellow-50: #FFE900; +$violet-20: #CB9EFF; + +// Photon opacity from http://design.firefox.com/photon/visuals/color.html#opacity +$grey-10-00: rgba($grey-10, 0); +$grey-10-10: rgba($grey-10, 0.1); +$grey-10-20: rgba($grey-10, 0.2); +$grey-10-30: rgba($grey-10, 0.3); +$grey-10-40: rgba($grey-10, 0.4); +$grey-10-50: rgba($grey-10, 0.5); +$grey-10-60: rgba($grey-10, 0.6); +$grey-10-80: rgba($grey-10, 0.8); +$grey-10-95: rgba($grey-10, 0.95); +$grey-20-60: rgba($grey-20, 0.6); +$grey-20-80: rgba($grey-20, 0.8); +$grey-30-60: rgba($grey-30, 0.6); +$grey-60-60: rgba($grey-60, 0.6); +$grey-60-70: rgba($grey-60, 0.7); +$grey-70-40: rgba($grey-70, 0.4); +$grey-70-60: rgba($grey-70, 0.6); +$grey-80-95: rgba($grey-80, 0.95); +$grey-90-00: rgba($grey-90, 0); +$grey-90-10: rgba($grey-90, 0.1); +$grey-90-20: rgba($grey-90, 0.2); +$grey-90-30: rgba($grey-90, 0.3); +$grey-90-40: rgba($grey-90, 0.4); +$grey-90-50: rgba($grey-90, 0.5); +$grey-90-60: rgba($grey-90, 0.6); +$grey-90-70: rgba($grey-90, 0.7); +$grey-90-80: rgba($grey-90, 0.8); +$grey-90-90: rgba($grey-90, 0.9); + +$blue-40-40: rgba($blue-40, 0.4); +$blue-50-50: rgba($blue-50, 0.5); +$blue-50-30: rgba($blue-50, 0.3); +$blue-50-50: rgba($blue-50, 0.5); + +$black: #000; +$black-5: rgba($black, 0.05); +$black-10: rgba($black, 0.1); +$black-12: rgba($black, 0.12); +$black-15: rgba($black, 0.15); +$black-20: rgba($black, 0.2); +$black-25: rgba($black, 0.25); +$black-30: rgba($black, 0.3); + +// Other colors +$white: #FFF; +$white-0: rgba($white, 0); +$white-10: rgba($white, 0.1); +$white-50: rgba($white, 0.5); +$white-60: rgba($white, 0.6); +$white-70: rgba($white, 0.7); +$white-100: rgba($white, 1); +$ghost-white: #FAFAFC; +$pocket-teal: #50BCB6; +$pocket-red: #EF4056; +$shadow-10: rgba(12, 12, 13, 0.1); +$bookmark-icon-fill: #0A84FF; +$download-icon-fill: #12BC00; +$pocket-icon-fill: #D70022; +$email-input-focus: rgba($blue-50, 0.3); +$email-input-invalid: rgba($red-60, 0.3); +$aw-extra-blue-1: #004EC2; +$aw-extra-blue-2: #0080FF; +$aw-extra-blue-3: #00C7FF; +$about-welcome-gradient: linear-gradient(to bottom, $blue-70 40%, $aw-extra-blue-1 60%, $blue-60 80%, $aw-extra-blue-2 90%, $aw-extra-blue-3 100%); +$about-welcome-extra-links: #676F7E; +$firefox-wordmark-default-color: #363959; +$firefox-wordmark-darktheme-color: $white; + +// New New Tab Experience colors. +$newtab-background-button-default-color: rgba(223, 223, 223, 0.5); +$newtab-background-button-darktheme-color: rgba(80, 80, 80, 0.5); +$newtab-background-button-default-text-color: #484848; +$newtab-background-button-darktheme-text-color: #CDCDD4; +$newtab-background-button-default-hover-color: rgba(196, 196, 196, 0.5); +$newtab-background-button-darktheme-hover-color: rgba(114, 114, 114, 0.5); +$newtab-background-button-default-active-color: rgba(151, 151, 151, 0.5); +$newtab-background-button-darktheme-active-color: rgba(173, 173, 173, 0.5); +$newtab-background-primary-text-color: #151515; +$newtab-background-darktheme-primary-text-color: #CDCDD4; +$newtab-wordmark-default-color: #20123A; + +$newtab-card-primary-text-color: #20123A; +$newtab-card-darktheme-primary-text-color: #E0E0E6; +$newtab-card-line-color: #716F87; +$newtab-card-darktheme-line-color: #B9B7CC; +$newtab-card-separator-line-color: #E1E0E6; +$newtab-card-darktheme-separator-line-color: #53515F; +$newtab-card-tint: rgba(0, 0, 0, 0.15); +$newtab-card-firstshadow: rgba(9, 32, 77, 0.12); +$newtab-card-darktheme-firstshadow: rgba(21, 20, 26, 0.5); +$newtab-card-secondshadow: rgba(29, 17, 51, 0.12); +$newtab-darktheme-card-secondshadow: rgba(21, 20, 26, 0.75); +$newtab-focus-outline-color: rgba(0, 96, 223, 0.25); +$newtab-darktheme-focus-outline-color: rgba(80, 145, 241, 0.5); +$newtab-darktheme-focus-border: #B5D3FF; +$newtab-darktheme-focus-border-selected: #B5D3FF; +$newtab-darktheme-primary-action-background: #4484E2; +$newtab-primary-action-background-off: #E9E9E9; +$newtab-darktheme-primary-action-background-off: #6A6A6F; +$newtab-card-darktheme-secondary-action-background: #515156; +$newtab-card-darktheme-secondary-action-background-hover: #606065; +$newtab-card-secondary-action-background-active: #E2E2E6; +$newtab-card-darktheme-secondary-action-background-active: #6F6F74; +$newtab-card-darktheme-hover-color: rgba(180, 180, 180, 0.1); +$newtab-card-hover-color: rgba(0, 0, 0, 0.05); +$newtab-tile-shadow-secondary: rgba(29, 17, 51, 0.2); +$newtab-tile-darktheme-shadow-secondary: rgba(21, 20, 26, 0.75); +$newtab-card-secondary-text-color: #585165; +$newtab-card-darktheme-secondary-text-color: #B1B1BD; + +// 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; +$searchbar-width-medium: $grid-unit * 4 + $base-gutter * 3; +$searchbar-width-large: $grid-unit * 6 + $base-gutter * 5; + +$searchbar-width-small-new: ($grid-unit * 2 + $base-gutter * 1) - 24px; +$searchbar-width-medium-new: ($grid-unit * 4 + $base-gutter * 3) - 120px; +$searchbar-width-large-new: ($grid-unit * 6 + $base-gutter * 5) - 136px; +$searchbar-width-largest-new: ($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: 13px; + +$card-width: $grid-unit * 2 + $base-gutter; +$card-width-nte: $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 $newtab-card-tint; + +@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-nte { + border: 0; + outline: 0; + box-shadow: 0 0 0 3px var(--newtab-focus-outline), 0 0 0 1px var(--newtab-focus-border); +} + +@mixin context-menu-button-newtab-experience { + .context-menu-button { + background-image: url('chrome://global/skin/icons/more.svg'); + border: 0; + border-radius: 4px; + cursor: pointer; + fill: var(--newtab-icon-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-topsites-context-menu-hover); + fill: var(--newtab-primary-action-background); + } + } +} + +@mixin context-menu-button { + .context-menu-button { + background-clip: padding-box; + background-color: var(--newtab-contextmenu-button-color); + 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-icon-primary-color); + height: $context-menu-button-size; + inset-inline-end: -($context-menu-button-size / 2); + opacity: 0; + position: absolute; + top: -($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 nt-experience-context-menu-button-hover { + .context-menu-button { + opacity: 1; + } +} + +@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..aaff2d2efe --- /dev/null +++ b/browser/components/newtab/content-src/styles/activity-stream-linux.scss @@ -0,0 +1,13 @@ +// sass-lint:disable no-css-comments +/* 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 */ +// sass-lint:enable no-css-comments + +$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..1c19e514fb --- /dev/null +++ b/browser/components/newtab/content-src/styles/activity-stream-mac.scss @@ -0,0 +1,17 @@ +// sass-lint:disable no-css-comments +/* 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 */ +// sass-lint:enable no-css-comments + +$os-infopanel-arrow-height: 10px; +$os-infopanel-arrow-offset-end: 7px; +$os-infopanel-arrow-width: 18px; + +[lwt-newtab-brighttext] { + -moz-osx-font-smoothing: grayscale; +} + +@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..10b8f61ef4 --- /dev/null +++ b/browser/components/newtab/content-src/styles/activity-stream-windows.scss @@ -0,0 +1,13 @@ +// sass-lint:disable no-css-comments +/* 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 */ +// sass-lint:enable no-css-comments + +$os-infopanel-arrow-height: 10px; +$os-infopanel-arrow-offset-end: 6px; +$os-infopanel-arrow-width: 20px; + +@import './activity-stream'; |