diff options
Diffstat (limited to 'browser/components/newtab/content-src')
105 files changed, 14573 insertions, 0 deletions
diff --git a/browser/components/newtab/content-src/activity-stream.jsx b/browser/components/newtab/content-src/activity-stream.jsx new file mode 100644 index 0000000000..c588e8e850 --- /dev/null +++ b/browser/components/newtab/content-src/activity-stream.jsx @@ -0,0 +1,57 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { Base } from "content-src/components/Base/Base"; +import { DetectUserSessionStart } from "content-src/lib/detect-user-session-start"; +import { initStore } from "content-src/lib/init-store"; +import { Provider } from "react-redux"; +import React from "react"; +import ReactDOM from "react-dom"; +import { reducers } from "common/Reducers.sys.mjs"; + +export const NewTab = ({ store }) => ( + <Provider store={store}> + <Base /> + </Provider> +); + +export function renderWithoutState() { + const store = initStore(reducers); + new DetectUserSessionStart(store).sendEventOrAddListener(); + + // If this document has already gone into the background by the time we've reached + // here, we can deprioritize requesting the initial state until the event loop + // frees up. If, however, the visibility changes, we then send the request. + let didRequest = false; + let requestIdleCallbackId = 0; + function doRequest() { + if (!didRequest) { + if (requestIdleCallbackId) { + cancelIdleCallback(requestIdleCallbackId); + } + didRequest = true; + store.dispatch(ac.AlsoToMain({ type: at.NEW_TAB_STATE_REQUEST })); + } + } + + if (document.hidden) { + requestIdleCallbackId = requestIdleCallback(doRequest); + addEventListener("visibilitychange", doRequest, { once: true }); + } else { + doRequest(); + } + + ReactDOM.hydrate(<NewTab store={store} />, document.getElementById("root")); +} + +export function renderCache(initialState) { + const store = initStore(reducers, initialState); + new DetectUserSessionStart(store).sendEventOrAddListener(); + + ReactDOM.hydrate(<NewTab store={store} />, document.getElementById("root")); +} diff --git a/browser/components/newtab/content-src/components/A11yLinkButton/A11yLinkButton.jsx b/browser/components/newtab/content-src/components/A11yLinkButton/A11yLinkButton.jsx new file mode 100644 index 0000000000..3aab52cdff --- /dev/null +++ b/browser/components/newtab/content-src/components/A11yLinkButton/A11yLinkButton.jsx @@ -0,0 +1,18 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; + +export function A11yLinkButton(props) { + // function for merging classes, if necessary + let className = "a11y-link-button"; + if (props.className) { + className += ` ${props.className}`; + } + return ( + <button type="button" {...props} className={className}> + {props.children} + </button> + ); +} diff --git a/browser/components/newtab/content-src/components/A11yLinkButton/_A11yLinkButton.scss b/browser/components/newtab/content-src/components/A11yLinkButton/_A11yLinkButton.scss new file mode 100644 index 0000000000..c87fc93b60 --- /dev/null +++ b/browser/components/newtab/content-src/components/A11yLinkButton/_A11yLinkButton.scss @@ -0,0 +1,13 @@ + +.a11y-link-button { + border: 0; + padding: 0; + cursor: pointer; + text-align: unset; + color: var(--newtab-primary-action-background); + + &:hover, + &:focus { + text-decoration: underline; + } +} diff --git a/browser/components/newtab/content-src/components/Base/Base.jsx b/browser/components/newtab/content-src/components/Base/Base.jsx new file mode 100644 index 0000000000..0580267f26 --- /dev/null +++ b/browser/components/newtab/content-src/components/Base/Base.jsx @@ -0,0 +1,262 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { DiscoveryStreamAdmin } from "content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin"; +import { ConfirmDialog } from "content-src/components/ConfirmDialog/ConfirmDialog"; +import { connect } from "react-redux"; +import { DiscoveryStreamBase } from "content-src/components/DiscoveryStreamBase/DiscoveryStreamBase"; +import { ErrorBoundary } from "content-src/components/ErrorBoundary/ErrorBoundary"; +import { CustomizeMenu } from "content-src/components/CustomizeMenu/CustomizeMenu"; +import React from "react"; +import { Search } from "content-src/components/Search/Search"; +import { Sections } from "content-src/components/Sections/Sections"; + +export const PrefsButton = ({ onClick, icon }) => ( + <div className="prefs-button"> + <button + className={`icon ${icon || "icon-settings"}`} + onClick={onClick} + data-l10n-id="newtab-settings-button" + /> + </div> +); + +// Returns a function will not be continuously triggered when called. The +// function will be triggered if called again after `wait` milliseconds. +function debounce(func, wait) { + let timer; + return (...args) => { + if (timer) { + return; + } + + let wakeUp = () => { + timer = null; + }; + + timer = setTimeout(wakeUp, wait); + func.apply(this, args); + }; +} + +export class _Base extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + message: {}, + }; + this.notifyContent = this.notifyContent.bind(this); + } + + notifyContent(state) { + this.setState(state); + } + + componentWillUnmount() { + this.updateTheme(); + } + + componentWillUpdate() { + this.updateTheme(); + } + + updateTheme() { + const bodyClassName = [ + "activity-stream", + // If we skipped the about:welcome overlay and removed the CSS classes + // we don't want to add them back to the Activity Stream view + document.body.classList.contains("inline-onboarding") + ? "inline-onboarding" + : "", + ] + .filter(v => v) + .join(" "); + global.document.body.className = bodyClassName; + } + + render() { + const { props } = this; + const { App } = props; + const isDevtoolsEnabled = props.Prefs.values["asrouter.devtoolsEnabled"]; + + if (!App.initialized) { + return null; + } + + return ( + <ErrorBoundary className="base-content-fallback"> + <React.Fragment> + <BaseContent {...this.props} adminContent={this.state} /> + {isDevtoolsEnabled ? ( + <DiscoveryStreamAdmin notifyContent={this.notifyContent} /> + ) : null} + </React.Fragment> + </ErrorBoundary> + ); + } +} + +export class BaseContent extends React.PureComponent { + constructor(props) { + super(props); + this.openPreferences = this.openPreferences.bind(this); + this.openCustomizationMenu = this.openCustomizationMenu.bind(this); + this.closeCustomizationMenu = this.closeCustomizationMenu.bind(this); + this.handleOnKeyDown = this.handleOnKeyDown.bind(this); + this.onWindowScroll = debounce(this.onWindowScroll.bind(this), 5); + this.setPref = this.setPref.bind(this); + this.state = { fixedSearch: false }; + } + + componentDidMount() { + global.addEventListener("scroll", this.onWindowScroll); + global.addEventListener("keydown", this.handleOnKeyDown); + } + + componentWillUnmount() { + global.removeEventListener("scroll", this.onWindowScroll); + global.removeEventListener("keydown", this.handleOnKeyDown); + } + + onWindowScroll() { + const prefs = this.props.Prefs.values; + const SCROLL_THRESHOLD = prefs["logowordmark.alwaysVisible"] ? 179 : 34; + if (global.scrollY > SCROLL_THRESHOLD && !this.state.fixedSearch) { + this.setState({ fixedSearch: true }); + } else if (global.scrollY <= SCROLL_THRESHOLD && this.state.fixedSearch) { + this.setState({ fixedSearch: false }); + } + } + + openPreferences() { + this.props.dispatch(ac.OnlyToMain({ type: at.SETTINGS_OPEN })); + this.props.dispatch(ac.UserEvent({ event: "OPEN_NEWTAB_PREFS" })); + } + + openCustomizationMenu() { + this.props.dispatch({ type: at.SHOW_PERSONALIZE }); + this.props.dispatch(ac.UserEvent({ event: "SHOW_PERSONALIZE" })); + } + + closeCustomizationMenu() { + if (this.props.App.customizeMenuVisible) { + this.props.dispatch({ type: at.HIDE_PERSONALIZE }); + this.props.dispatch(ac.UserEvent({ event: "HIDE_PERSONALIZE" })); + } + } + + handleOnKeyDown(e) { + if (e.key === "Escape") { + this.closeCustomizationMenu(); + } + } + + setPref(pref, value) { + this.props.dispatch(ac.SetPref(pref, value)); + } + + render() { + const { props } = this; + const { App } = props; + const { initialized, customizeMenuVisible } = App; + const prefs = props.Prefs.values; + + const isDiscoveryStream = + props.DiscoveryStream.config && props.DiscoveryStream.config.enabled; + let filteredSections = props.Sections.filter( + section => section.id !== "topstories" + ); + + const pocketEnabled = + prefs["feeds.section.topstories"] && prefs["feeds.system.topstories"]; + const noSectionsEnabled = + !prefs["feeds.topsites"] && + !pocketEnabled && + filteredSections.filter(section => section.enabled).length === 0; + const searchHandoffEnabled = prefs["improvesearch.handoffToAwesomebar"]; + const enabledSections = { + topSitesEnabled: prefs["feeds.topsites"], + pocketEnabled: prefs["feeds.section.topstories"], + highlightsEnabled: prefs["feeds.section.highlights"], + showSponsoredTopSitesEnabled: prefs.showSponsoredTopSites, + showSponsoredPocketEnabled: prefs.showSponsored, + showRecentSavesEnabled: prefs.showRecentSaves, + topSitesRowsCount: prefs.topSitesRows, + }; + + const pocketRegion = prefs["feeds.system.topstories"]; + const mayHaveSponsoredStories = prefs["system.showSponsored"]; + const { mayHaveSponsoredTopSites } = prefs; + + const outerClassName = [ + "outer-wrapper", + isDiscoveryStream && pocketEnabled && "ds-outer-wrapper-search-alignment", + isDiscoveryStream && "ds-outer-wrapper-breakpoint-override", + prefs.showSearch && + this.state.fixedSearch && + !noSectionsEnabled && + "fixed-search", + prefs.showSearch && noSectionsEnabled && "only-search", + prefs["logowordmark.alwaysVisible"] && "visible-logo", + ] + .filter(v => v) + .join(" "); + + return ( + <div> + <CustomizeMenu + onClose={this.closeCustomizationMenu} + onOpen={this.openCustomizationMenu} + openPreferences={this.openPreferences} + setPref={this.setPref} + enabledSections={enabledSections} + pocketRegion={pocketRegion} + mayHaveSponsoredTopSites={mayHaveSponsoredTopSites} + mayHaveSponsoredStories={mayHaveSponsoredStories} + showing={customizeMenuVisible} + /> + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions*/} + <div className={outerClassName} onClick={this.closeCustomizationMenu}> + <main> + {prefs.showSearch && ( + <div className="non-collapsible-section"> + <ErrorBoundary> + <Search + showLogo={ + noSectionsEnabled || prefs["logowordmark.alwaysVisible"] + } + handoffEnabled={searchHandoffEnabled} + {...props.Search} + /> + </ErrorBoundary> + </div> + )} + <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..1282173df5 --- /dev/null +++ b/browser/components/newtab/content-src/components/Base/_Base.scss @@ -0,0 +1,126 @@ +.outer-wrapper { + color: var(--newtab-text-primary-color); + display: flex; + flex-grow: 1; + min-height: 100vh; + padding: ($section-spacing + $section-vertical-padding) $base-gutter $base-gutter; + + &.ds-outer-wrapper-breakpoint-override { + padding: 30px 0 32px; + + @media(min-width: $break-point-medium) { + padding: 30px 32px 32px; + } + } + + &.only-search { + display: block; + padding-top: 134px; + } + + a { + color: var(--newtab-primary-action-background); + } +} + +main { + margin: auto; + width: $wrapper-default-width; + padding: 0; + + section { + margin-bottom: $section-spacing; + position: relative; + } + + .hide-main & { + visibility: hidden; + } + + @media (min-width: $break-point-medium) { + width: $wrapper-max-width-medium; + } + + @media (min-width: $break-point-large) { + width: $wrapper-max-width-large; + } + + @media (min-width: $break-point-widest) { + width: $wrapper-max-width-widest; + } +} + +.ds-outer-wrapper-search-alignment { + main { + // This override is to ensure while Discovery Stream loads, + // the search bar does not jump around. (it sticks to the top) + margin: 0 auto; + } +} + +.ds-outer-wrapper-breakpoint-override { + main { + width: 266px; + padding-bottom: 0; + + @media (min-width: $break-point-medium) { + width: 510px; + } + + @media (min-width: $break-point-large) { + width: 746px; + } + + @media (min-width: $break-point-widest) { + width: 986px; + } + } +} + +.base-content-fallback { + // Make the error message be centered against the viewport + height: 100vh; +} + +.body-wrapper { + // Hide certain elements so the page structure is fixed, e.g., placeholders, + // while avoiding flashes of changing content, e.g., icons and text + $selectors-to-hide: '.section-title, .sections-list .section:last-of-type, .topics'; + + #{$selectors-to-hide} { + opacity: 0; + } + + &.on { + #{$selectors-to-hide} { + opacity: 1; + } + } +} + +.non-collapsible-section { + padding: 0 $section-horizontal-padding; +} + +.prefs-button { + button { + background-color: transparent; + border: 0; + border-radius: 2px; + cursor: pointer; + inset-inline-end: 15px; + padding: 15px; + position: fixed; + top: 15px; + z-index: 1000; + + &:hover, + &:focus { + background-color: var(--newtab-element-hover-color); + } + + &:active { + background-color: var(--newtab-element-active-color); + } + } +} diff --git a/browser/components/newtab/content-src/components/Card/Card.jsx b/browser/components/newtab/content-src/components/Card/Card.jsx new file mode 100644 index 0000000000..9d03377f1b --- /dev/null +++ b/browser/components/newtab/content-src/components/Card/Card.jsx @@ -0,0 +1,362 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { cardContextTypes } from "./types"; +import { connect } from "react-redux"; +import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton"; +import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu"; +import React from "react"; +import { ScreenshotUtils } from "content-src/lib/screenshot-utils"; + +// Keep track of pending image loads to only request once +const gImageLoading = new Map(); + +/** + * Card component. + * Cards are found within a Section component and contain information about a link such + * as preview image, page title, page description, and some context about if the page + * was visited, bookmarked, trending etc... + * Each Section can make an unordered list of Cards which will create one instane of + * this class. Each card will then get a context menu which reflects the actions that + * can be done on this Card. + */ +export class _Card extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + activeCard: null, + imageLoaded: false, + cardImage: null, + }; + this.onMenuButtonUpdate = this.onMenuButtonUpdate.bind(this); + this.onLinkClick = this.onLinkClick.bind(this); + } + + /** + * Helper to conditionally load an image and update state when it loads. + */ + async maybeLoadImage() { + // No need to load if it's already loaded or no image + const { cardImage } = this.state; + if (!cardImage) { + return; + } + + const imageUrl = cardImage.url; + if (!this.state.imageLoaded) { + // Initialize a promise to share a load across multiple card updates + if (!gImageLoading.has(imageUrl)) { + const loaderPromise = new Promise((resolve, reject) => { + const loader = new Image(); + loader.addEventListener("load", resolve); + loader.addEventListener("error", reject); + loader.src = imageUrl; + }); + + // Save and remove the promise only while it's pending + gImageLoading.set(imageUrl, loaderPromise); + loaderPromise + .catch(ex => ex) + .then(() => gImageLoading.delete(imageUrl)) + .catch(); + } + + // Wait for the image whether just started loading or reused promise + try { + await gImageLoading.get(imageUrl); + } catch (ex) { + // Ignore the failed image without changing state + return; + } + + // Only update state if we're still waiting to load the original image + if ( + ScreenshotUtils.isRemoteImageLocal( + this.state.cardImage, + this.props.link.image + ) && + !this.state.imageLoaded + ) { + this.setState({ imageLoaded: true }); + } + } + } + + /** + * Helper to obtain the next state based on nextProps and prevState. + * + * NOTE: Rename this method to getDerivedStateFromProps when we update React + * to >= 16.3. We will need to update tests as well. We cannot rename this + * method to getDerivedStateFromProps now because there is a mismatch in + * the React version that we are using for both testing and production. + * (i.e. react-test-render => "16.3.2", react => "16.2.0"). + * + * See https://github.com/airbnb/enzyme/blob/master/packages/enzyme-adapter-react-16/package.json#L43. + */ + static getNextStateFromProps(nextProps, prevState) { + const { image } = nextProps.link; + const imageInState = ScreenshotUtils.isRemoteImageLocal( + prevState.cardImage, + image + ); + let nextState = null; + + // Image is updating. + if (!imageInState && nextProps.link) { + nextState = { imageLoaded: false }; + } + + if (imageInState) { + return nextState; + } + + // Since image was updated, attempt to revoke old image blob URL, if it exists. + ScreenshotUtils.maybeRevokeBlobObjectURL(prevState.cardImage); + + nextState = nextState || {}; + nextState.cardImage = ScreenshotUtils.createLocalImageObject(image); + + return nextState; + } + + onMenuButtonUpdate(isOpen) { + if (isOpen) { + this.setState({ activeCard: this.props.index }); + } else { + this.setState({ activeCard: null }); + } + } + + /** + * Report to telemetry additional information about the item. + */ + _getTelemetryInfo() { + // Filter out "history" type for being the default + if (this.props.link.type !== "history") { + return { value: { card_type: this.props.link.type } }; + } + + return null; + } + + onLinkClick(event) { + event.preventDefault(); + const { altKey, button, ctrlKey, metaKey, shiftKey } = event; + if (this.props.link.type === "download") { + this.props.dispatch( + ac.OnlyToMain({ + type: at.OPEN_DOWNLOAD_FILE, + data: Object.assign(this.props.link, { + event: { button, ctrlKey, metaKey, shiftKey }, + }), + }) + ); + } else { + this.props.dispatch( + ac.OnlyToMain({ + type: at.OPEN_LINK, + data: Object.assign(this.props.link, { + event: { altKey, button, ctrlKey, metaKey, shiftKey }, + }), + }) + ); + } + if (this.props.isWebExtension) { + this.props.dispatch( + ac.WebExtEvent(at.WEBEXT_CLICK, { + source: this.props.eventSource, + url: this.props.link.url, + action_position: this.props.index, + }) + ); + } else { + this.props.dispatch( + ac.UserEvent( + Object.assign( + { + event: "CLICK", + source: this.props.eventSource, + action_position: this.props.index, + }, + this._getTelemetryInfo() + ) + ) + ); + + if (this.props.shouldSendImpressionStats) { + this.props.dispatch( + ac.ImpressionStats({ + source: this.props.eventSource, + click: 0, + tiles: [{ id: this.props.link.guid, pos: this.props.index }], + }) + ); + } + } + } + + componentDidMount() { + this.maybeLoadImage(); + } + + componentDidUpdate() { + this.maybeLoadImage(); + } + + // NOTE: Remove this function when we update React to >= 16.3 since React will + // call getDerivedStateFromProps automatically. We will also need to + // rename getNextStateFromProps to getDerivedStateFromProps. + componentWillMount() { + const nextState = _Card.getNextStateFromProps(this.props, this.state); + if (nextState) { + this.setState(nextState); + } + } + + // NOTE: Remove this function when we update React to >= 16.3 since React will + // call getDerivedStateFromProps automatically. We will also need to + // rename getNextStateFromProps to getDerivedStateFromProps. + componentWillReceiveProps(nextProps) { + const nextState = _Card.getNextStateFromProps(nextProps, this.state); + if (nextState) { + this.setState(nextState); + } + } + + componentWillUnmount() { + ScreenshotUtils.maybeRevokeBlobObjectURL(this.state.cardImage); + } + + render() { + const { + index, + className, + link, + dispatch, + contextMenuOptions, + eventSource, + shouldSendImpressionStats, + } = this.props; + const { props } = this; + const title = link.title || link.hostname; + const isContextMenuOpen = this.state.activeCard === index; + // Display "now" as "trending" until we have new strings #3402 + const { icon, fluentID } = + cardContextTypes[link.type === "now" ? "trending" : link.type] || {}; + const hasImage = this.state.cardImage || link.hasImage; + const imageStyle = { + backgroundImage: this.state.cardImage + ? `url(${this.state.cardImage.url})` + : "none", + }; + const outerClassName = [ + "card-outer", + className, + isContextMenuOpen && "active", + props.placeholder && "placeholder", + ] + .filter(v => v) + .join(" "); + + return ( + <li className={outerClassName}> + <a + href={link.type === "pocket" ? link.open_url : link.url} + onClick={!props.placeholder ? this.onLinkClick : undefined} + > + <div className="card"> + <div className="card-preview-image-outer"> + {hasImage && ( + <div + className={`card-preview-image${ + this.state.imageLoaded ? " loaded" : "" + }`} + style={imageStyle} + /> + )} + </div> + <div className="card-details"> + {link.type === "download" && ( + <div + className="card-host-name alternate" + data-l10n-id="newtab-menu-open-file" + /> + )} + {link.hostname && ( + <div className="card-host-name"> + {link.hostname.slice(0, 100)} + {link.type === "download" && ` \u2014 ${link.description}`} + </div> + )} + <div + className={[ + "card-text", + icon ? "" : "no-context", + link.description ? "" : "no-description", + link.hostname ? "" : "no-host-name", + ].join(" ")} + > + <h4 className="card-title" dir="auto"> + {link.title} + </h4> + <p className="card-description" dir="auto"> + {link.description} + </p> + </div> + <div className="card-context"> + {icon && !link.context && ( + <span + aria-haspopup="true" + className={`card-context-icon icon icon-${icon}`} + /> + )} + {link.icon && link.context && ( + <span + aria-haspopup="true" + className="card-context-icon icon" + style={{ backgroundImage: `url('${link.icon}')` }} + /> + )} + {fluentID && !link.context && ( + <div className="card-context-label" data-l10n-id={fluentID} /> + )} + {link.context && ( + <div className="card-context-label">{link.context}</div> + )} + </div> + </div> + </div> + </a> + {!props.placeholder && ( + <ContextMenuButton + tooltip="newtab-menu-content-tooltip" + tooltipArgs={{ title }} + onUpdate={this.onMenuButtonUpdate} + > + <LinkMenu + dispatch={dispatch} + index={index} + source={eventSource} + options={link.contextMenuOptions || contextMenuOptions} + site={link} + siteInfo={this._getTelemetryInfo()} + shouldSendImpressionStats={shouldSendImpressionStats} + /> + </ContextMenuButton> + )} + </li> + ); + } +} +_Card.defaultProps = { link: {} }; +export const Card = connect(state => ({ + platform: state.Prefs.values.platform, +}))(_Card); +export const PlaceholderCard = props => ( + <Card placeholder={true} className={props.className} /> +); diff --git a/browser/components/newtab/content-src/components/Card/_Card.scss b/browser/components/newtab/content-src/components/Card/_Card.scss new file mode 100644 index 0000000000..74288ff07f --- /dev/null +++ b/browser/components/newtab/content-src/components/Card/_Card.scss @@ -0,0 +1,333 @@ +@use 'sass:math'; + +/* stylelint-disable max-nesting-depth */ + +.card-outer { + @include context-menu-button; + + background: var(--newtab-background-color-secondary); + border-radius: $border-radius-new; + display: inline-block; + height: $card-height; + margin-inline-end: $base-gutter; + position: relative; + width: 100%; + + &:is(:focus):not(.placeholder) { + @include ds-focus; + + transition: none; + } + + &:hover { + box-shadow: none; + transition: none; + } + + &.placeholder { + background: transparent; + + .card-preview-image-outer, + .card-context { + display: none; + } + } + + .card { + border-radius: $border-radius-new; + box-shadow: $shadow-card; + height: 100%; + } + + > a { + color: inherit; + display: block; + height: 100%; + outline: none; + position: absolute; + width: 100%; + + &:is(:focus) { + .card { + @include ds-focus; + } + } + + &:is(.active, :focus) { + .card { + @include fade-in-card; + } + + .card-title { + color: var(--newtab-primary-action-background); + } + } + } + + &:is(:hover, :focus, .active):not(.placeholder) { + @include context-menu-button-hover; + + outline: none; + + .card-title { + color: var(--newtab-primary-action-background); + } + + .alternate ~ .card-host-name { + display: none; + } + + .card-host-name.alternate { + display: block; + } + } + + .card-preview-image-outer { + background-color: var(--newtab-element-secondary-color); + border-radius: $border-radius-new $border-radius-new 0 0; + height: $card-preview-image-height; + overflow: hidden; + position: relative; + + &::after { + border-bottom: 1px solid var(--newtab-card-hairline-color); + bottom: 0; + content: ''; + position: absolute; + width: 100%; + } + + .card-preview-image { + background-position: center; + background-repeat: no-repeat; + background-size: cover; + height: 100%; + opacity: 0; + transition: opacity 1s $photon-easing; + width: 100%; + + &.loaded { + opacity: 1; + } + } + } + + .card-details { + padding: 15px 16px 12px; + } + + .card-text { + max-height: 4 * $card-text-line-height + $card-title-margin; + overflow: hidden; + + &.no-host-name, + &.no-context { + max-height: 5 * $card-text-line-height + $card-title-margin; + } + + &.no-host-name.no-context { + max-height: 6 * $card-text-line-height + $card-title-margin; + } + + &:not(.no-description) .card-title { + max-height: 3 * $card-text-line-height; + overflow: hidden; + } + } + + .card-host-name { + color: var(--newtab-text-secondary-color); + font-size: 10px; + overflow: hidden; + padding-bottom: 4px; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; + } + + .card-host-name.alternate { display: none; } + + .card-title { + font-size: 14px; + font-weight: 600; + line-height: $card-text-line-height; + margin: 0 0 $card-title-margin; + word-wrap: break-word; + } + + .card-description { + font-size: 12px; + line-height: $card-text-line-height; + margin: 0; + overflow: hidden; + word-wrap: break-word; + } + + .card-context { + bottom: 0; + color: var(--newtab-text-secondary-color); + display: flex; + font-size: 11px; + inset-inline-start: 0; + padding: 9px 16px 9px 14px; + position: absolute; + } + + .card-context-icon { + fill: var(--newtab-text-secondary-color); + height: 22px; + margin-inline-end: 6px; + } + + .card-context-label { + flex-grow: 1; + line-height: 22px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.normal-cards { + .card-outer { + // Wide layout styles + @media (min-width: $break-point-widest) { + $line-height: 23px; + + height: $card-height-large; + + .card-preview-image-outer { + height: $card-preview-image-height-large; + } + + .card-details { + padding: 13px 16px 12px; + } + + .card-text { + max-height: 6 * $line-height + $card-title-margin; + } + + .card-host-name { + font-size: 12px; + padding-bottom: 5px; + } + + .card-title { + font-size: 17px; + line-height: $line-height; + margin-bottom: 0; + } + + .card-text:not(.no-description) { + .card-title { + max-height: 3 * $line-height; + } + } + + .card-description { + font-size: 15px; + line-height: $line-height; + } + + .card-context { + bottom: 4px; + font-size: 14px; + } + } + } +} + +.compact-cards { + $card-detail-vertical-spacing: 12px; + $card-title-font-size: 12px; + + .card-outer { + height: $card-height-compact; + + .card-preview-image-outer { + height: $card-preview-image-height-compact; + } + + .card-details { + padding: $card-detail-vertical-spacing 16px; + } + + .card-host-name { + line-height: 10px; + } + + .card-text { + .card-title, + &:not(.no-description) .card-title { + font-size: $card-title-font-size; + line-height: $card-title-font-size + 1; + max-height: $card-title-font-size + 5; + overflow: hidden; + padding: 0 0 4px; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .card-description { + display: none; + } + + .card-context { + $icon-size: 16px; + $container-size: 32px; + + background-color: var(--newtab-background-color-secondary); + border-radius: math.div($container-size, 2); + clip-path: inset(-1px -1px $container-size - ($card-height-compact - $card-preview-image-height-compact - 2 * $card-detail-vertical-spacing)); + height: $container-size; + width: $container-size; + padding: math.div($container-size - $icon-size, 2); + // The -1 at the end is so both opacity borders don't overlap, which causes bug 1629483 + top: $card-preview-image-height-compact - math.div($container-size, 2) - 1; + inset-inline-end: 12px; + inset-inline-start: auto; + + &::after { + border: 1px solid var(--newtab-card-hairline-color); + border-bottom: 0; + border-radius: math.div($container-size, 2) + 1 math.div($container-size, 2) + 1 0 0; + content: ''; + position: absolute; + height: math.div($container-size + 2, 2); + width: $container-size + 2; + top: -1px; + left: -1px; + } + + .card-context-icon { + margin-inline-end: 0; + height: $icon-size; + width: $icon-size; + + &.icon-bookmark-added { + fill: $bookmark-icon-fill; + } + + &.icon-download { + fill: $download-icon-fill; + } + + &.icon-pocket { + fill: $pocket-icon-fill; + } + } + + .card-context-label { + display: none; + } + } + } + + @media not all and (min-width: $break-point-widest) { + .hide-for-narrow { + display: none; + } + } +} diff --git a/browser/components/newtab/content-src/components/Card/types.js b/browser/components/newtab/content-src/components/Card/types.js new file mode 100644 index 0000000000..0b17eea408 --- /dev/null +++ b/browser/components/newtab/content-src/components/Card/types.js @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export const cardContextTypes = { + history: { + fluentID: "newtab-label-visited", + icon: "history-item", + }, + removedBookmark: { + fluentID: "newtab-label-removed-bookmark", + icon: "bookmark-removed", + }, + bookmark: { + fluentID: "newtab-label-bookmarked", + icon: "bookmark-added", + }, + trending: { + fluentID: "newtab-label-recommended", + icon: "trending", + }, + pocket: { + fluentID: "newtab-label-saved", + icon: "pocket", + }, + download: { + fluentID: "newtab-label-download", + icon: "download", + }, +}; diff --git a/browser/components/newtab/content-src/components/CollapsibleSection/CollapsibleSection.jsx b/browser/components/newtab/content-src/components/CollapsibleSection/CollapsibleSection.jsx new file mode 100644 index 0000000000..679e8e137f --- /dev/null +++ b/browser/components/newtab/content-src/components/CollapsibleSection/CollapsibleSection.jsx @@ -0,0 +1,116 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { ErrorBoundary } from "content-src/components/ErrorBoundary/ErrorBoundary"; +import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText"; +import React from "react"; +import { connect } from "react-redux"; + +/** + * A section that can collapse. As of bug 1710937, it can no longer collapse. + * See bug 1727365 for follow-up work to simplify this component. + */ +export class _CollapsibleSection extends React.PureComponent { + constructor(props) { + super(props); + this.onBodyMount = this.onBodyMount.bind(this); + this.onMenuButtonMouseEnter = this.onMenuButtonMouseEnter.bind(this); + this.onMenuButtonMouseLeave = this.onMenuButtonMouseLeave.bind(this); + this.onMenuUpdate = this.onMenuUpdate.bind(this); + this.state = { + menuButtonHover: false, + showContextMenu: false, + }; + this.setContextMenuButtonRef = this.setContextMenuButtonRef.bind(this); + } + + setContextMenuButtonRef(element) { + this.contextMenuButtonRef = element; + } + + onBodyMount(node) { + this.sectionBody = node; + } + + onMenuButtonMouseEnter() { + this.setState({ menuButtonHover: true }); + } + + onMenuButtonMouseLeave() { + this.setState({ menuButtonHover: false }); + } + + onMenuUpdate(showContextMenu) { + this.setState({ showContextMenu }); + } + + render() { + const { isAnimating, maxHeight, menuButtonHover, showContextMenu } = + this.state; + const { id, collapsed, learnMore, title, subTitle } = this.props; + const active = menuButtonHover || showContextMenu; + let bodyStyle; + if (isAnimating && !collapsed) { + bodyStyle = { maxHeight }; + } else if (!isAnimating && collapsed) { + bodyStyle = { display: "none" }; + } + let titleStyle; + if (this.props.hideTitle) { + titleStyle = { visibility: "hidden" }; + } + const hasSubtitleClassName = subTitle ? `has-subtitle` : ``; + return ( + <section + className={`collapsible-section ${this.props.className}${ + active ? " active" : "" + }`} + // Note: data-section-id is used for web extension api tests in mozilla central + data-section-id={id} + > + <div className="section-top-bar"> + <h3 + className={`section-title-container ${hasSubtitleClassName}`} + style={titleStyle} + > + <span className="section-title"> + <FluentOrText message={title} /> + </span> + <span className="learn-more-link-wrapper"> + {learnMore && ( + <span className="learn-more-link"> + <FluentOrText message={learnMore.link.message}> + <a href={learnMore.link.href} /> + </FluentOrText> + </span> + )} + </span> + {subTitle && ( + <span className="section-sub-title"> + <FluentOrText message={subTitle} /> + </span> + )} + </h3> + </div> + <ErrorBoundary className="section-body-fallback"> + <div ref={this.onBodyMount} style={bodyStyle}> + {this.props.children} + </div> + </ErrorBoundary> + </section> + ); + } +} + +_CollapsibleSection.defaultProps = { + document: global.document || { + addEventListener: () => {}, + removeEventListener: () => {}, + visibilityState: "hidden", + }, +}; + +export const CollapsibleSection = connect(state => ({ + Prefs: state.Prefs, +}))(_CollapsibleSection); diff --git a/browser/components/newtab/content-src/components/CollapsibleSection/_CollapsibleSection.scss b/browser/components/newtab/content-src/components/CollapsibleSection/_CollapsibleSection.scss new file mode 100644 index 0000000000..10cc58a1b1 --- /dev/null +++ b/browser/components/newtab/content-src/components/CollapsibleSection/_CollapsibleSection.scss @@ -0,0 +1,108 @@ +/* stylelint-disable max-nesting-depth */ + +.collapsible-section { + padding: $section-vertical-padding $section-horizontal-padding; + + .section-title-container { + margin: 0; + + &.has-subtitle { + display: flex; + flex-direction: column; + + @media (min-width: $break-point-large) { + flex-direction: row; + align-items: baseline; + justify-content: space-between; + } + } + } + + .section-title { + font-size: $section-title-font-size; + font-weight: 600; + color: var(--newtab-text-primary-color); + + &.grey-title { + color: var(--newtab-text-primary-color); + display: inline-block; + fill: var(--newtab-text-primary-color); + vertical-align: middle; + } + + .section-title-contents { + // Center "What's Pocket?" for "mobile" viewport + @media (max-width: $break-point-medium - 1) { + display: block; + + .learn-more-link-wrapper { + display: block; + text-align: center; + + .learn-more-link { + margin-inline-start: 0; + } + } + } + + vertical-align: top; + } + } + + .section-sub-title { + font-size: 14px; + line-height: 16px; + color: var(--newtab-text-secondary-color); + opacity: 0.3; + } + + .section-top-bar { + min-height: 19px; + margin-bottom: 13px; + position: relative; + } + + &.active { + background: var(--newtab-element-hover-color); + border-radius: 4px; + } + + .learn-more-link { + font-size: 13px; + margin-inline-start: 12px; + + a { + color: var(--newtab-primary-action-background); + } + } + + .section-body-fallback { + height: $card-height; + } + + .section-body { + // This is so the top sites favicon and card dropshadows don't get clipped during animation: + $horizontal-padding: 7px; + + margin: 0 (-$horizontal-padding); + padding: 0 $horizontal-padding; + + &.animating { + overflow: hidden; + pointer-events: none; + } + } + + &[data-section-id='topsites'] { + .section-top-bar { + display: none; + } + } + + // Hide first story card for the medium breakpoint to prevent orphaned third story + &[data-section-id='topstories'] .card-outer:first-child { + @media (min-width: $break-point-medium) and (max-width: $break-point-large - 1) { + display: none; + } + } +} diff --git a/browser/components/newtab/content-src/components/ComponentPerfTimer/ComponentPerfTimer.jsx b/browser/components/newtab/content-src/components/ComponentPerfTimer/ComponentPerfTimer.jsx new file mode 100644 index 0000000000..4efd8c712e --- /dev/null +++ b/browser/components/newtab/content-src/components/ComponentPerfTimer/ComponentPerfTimer.jsx @@ -0,0 +1,177 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { perfService as perfSvc } from "content-src/lib/perf-service"; +import React from "react"; + +// Currently record only a fixed set of sections. This will prevent data +// from custom sections from showing up or from topstories. +const RECORDED_SECTIONS = ["highlights", "topsites"]; + +export class ComponentPerfTimer extends React.Component { + constructor(props) { + super(props); + // Just for test dependency injection: + this.perfSvc = this.props.perfSvc || perfSvc; + + this._sendBadStateEvent = this._sendBadStateEvent.bind(this); + this._sendPaintedEvent = this._sendPaintedEvent.bind(this); + this._reportMissingData = false; + this._timestampHandled = false; + this._recordedFirstRender = false; + } + + componentDidMount() { + if (!RECORDED_SECTIONS.includes(this.props.id)) { + return; + } + + this._maybeSendPaintedEvent(); + } + + componentDidUpdate() { + if (!RECORDED_SECTIONS.includes(this.props.id)) { + return; + } + + this._maybeSendPaintedEvent(); + } + + /** + * Call the given callback after the upcoming frame paints. + * + * @note Both setTimeout and requestAnimationFrame are throttled when the page + * is hidden, so this callback may get called up to a second or so after the + * requestAnimationFrame "paint" for hidden tabs. + * + * Newtabs hidden while loading will presumably be fairly rare (other than + * preloaded tabs, which we will be filtering out on the server side), so such + * cases should get lost in the noise. + * + * If we decide that it's important to find out when something that's hidden + * has "painted", however, another option is to post a message to this window. + * That should happen even faster than setTimeout, and, at least as of this + * writing, it's not throttled in hidden windows in Firefox. + * + * @param {Function} callback + * + * @returns void + */ + _afterFramePaint(callback) { + requestAnimationFrame(() => setTimeout(callback, 0)); + } + + _maybeSendBadStateEvent() { + // Follow up bugs: + // https://github.com/mozilla/activity-stream/issues/3691 + if (!this.props.initialized) { + // Remember to report back when data is available. + this._reportMissingData = true; + } else if (this._reportMissingData) { + this._reportMissingData = false; + // Report how long it took for component to become initialized. + this._sendBadStateEvent(); + } + } + + _maybeSendPaintedEvent() { + // If we've already handled a timestamp, don't do it again. + if (this._timestampHandled || !this.props.initialized) { + return; + } + + // And if we haven't, we're doing so now, so remember that. Even if + // something goes wrong in the callback, we can't try again, as we'd be + // sending back the wrong data, and we have to do it here, so that other + // calls to this method while waiting for the next frame won't also try to + // handle it. + this._timestampHandled = true; + this._afterFramePaint(this._sendPaintedEvent); + } + + /** + * Triggered by call to render. Only first call goes through due to + * `_recordedFirstRender`. + */ + _ensureFirstRenderTsRecorded() { + // Used as t0 for recording how long component took to initialize. + if (!this._recordedFirstRender) { + this._recordedFirstRender = true; + // topsites_first_render_ts, highlights_first_render_ts. + const key = `${this.props.id}_first_render_ts`; + this.perfSvc.mark(key); + } + } + + /** + * Creates `SAVE_SESSION_PERF_DATA` with timestamp in ms + * of how much longer the data took to be ready for display than it would + * have been the ideal case. + * https://github.com/mozilla/ping-centre/issues/98 + */ + _sendBadStateEvent() { + // highlights_data_ready_ts, topsites_data_ready_ts. + const dataReadyKey = `${this.props.id}_data_ready_ts`; + this.perfSvc.mark(dataReadyKey); + + try { + const firstRenderKey = `${this.props.id}_first_render_ts`; + // value has to be Int32. + const value = parseInt( + this.perfSvc.getMostRecentAbsMarkStartByName(dataReadyKey) - + this.perfSvc.getMostRecentAbsMarkStartByName(firstRenderKey), + 10 + ); + this.props.dispatch( + ac.OnlyToMain({ + type: at.SAVE_SESSION_PERF_DATA, + // highlights_data_late_by_ms, topsites_data_late_by_ms. + data: { [`${this.props.id}_data_late_by_ms`]: value }, + }) + ); + } catch (ex) { + // If this failed, it's likely because the `privacy.resistFingerprinting` + // pref is true. + } + } + + _sendPaintedEvent() { + // Record first_painted event but only send if topsites. + if (this.props.id !== "topsites") { + return; + } + + // topsites_first_painted_ts. + const key = `${this.props.id}_first_painted_ts`; + this.perfSvc.mark(key); + + try { + const data = {}; + data[key] = this.perfSvc.getMostRecentAbsMarkStartByName(key); + + this.props.dispatch( + ac.OnlyToMain({ + type: at.SAVE_SESSION_PERF_DATA, + data, + }) + ); + } catch (ex) { + // If this failed, it's likely because the `privacy.resistFingerprinting` + // pref is true. We should at least not blow up, and should continue + // to set this._timestampHandled to avoid going through this again. + } + } + + render() { + if (RECORDED_SECTIONS.includes(this.props.id)) { + this._ensureFirstRenderTsRecorded(); + this._maybeSendBadStateEvent(); + } + return this.props.children; + } +} diff --git a/browser/components/newtab/content-src/components/ConfirmDialog/ConfirmDialog.jsx b/browser/components/newtab/content-src/components/ConfirmDialog/ConfirmDialog.jsx new file mode 100644 index 0000000000..f69e540079 --- /dev/null +++ b/browser/components/newtab/content-src/components/ConfirmDialog/ConfirmDialog.jsx @@ -0,0 +1,103 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { actionCreators as ac, actionTypes } from "common/Actions.sys.mjs"; +import { connect } from "react-redux"; +import React from "react"; + +/** + * ConfirmDialog component. + * One primary action button, one cancel button. + * + * Content displayed is controlled by `data` prop the component receives. + * Example: + * data: { + * // Any sort of data needed to be passed around by actions. + * payload: site.url, + * // Primary button AlsoToMain action. + * action: "DELETE_HISTORY_URL", + * // Primary button USerEvent action. + * userEvent: "DELETE", + * // Array of locale ids to display. + * message_body: ["confirm_history_delete_p1", "confirm_history_delete_notice_p2"], + * // Text for primary button. + * confirm_button_string_id: "menu_action_delete" + * }, + */ +export class _ConfirmDialog extends React.PureComponent { + constructor(props) { + super(props); + this._handleCancelBtn = this._handleCancelBtn.bind(this); + this._handleConfirmBtn = this._handleConfirmBtn.bind(this); + } + + _handleCancelBtn() { + this.props.dispatch({ type: actionTypes.DIALOG_CANCEL }); + this.props.dispatch( + ac.UserEvent({ + event: actionTypes.DIALOG_CANCEL, + source: this.props.data.eventSource, + }) + ); + } + + _handleConfirmBtn() { + this.props.data.onConfirm.forEach(this.props.dispatch); + } + + _renderModalMessage() { + const message_body = this.props.data.body_string_id; + + if (!message_body) { + return null; + } + + return ( + <span> + {message_body.map(msg => ( + <p key={msg} data-l10n-id={msg} /> + ))} + </span> + ); + } + + render() { + if (!this.props.visible) { + return null; + } + + return ( + <div className="confirmation-dialog"> + <div + className="modal-overlay" + onClick={this._handleCancelBtn} + role="presentation" + /> + <div className="modal"> + <section className="modal-message"> + {this.props.data.icon && ( + <span + className={`icon icon-spacer icon-${this.props.data.icon}`} + /> + )} + {this._renderModalMessage()} + </section> + <section className="actions"> + <button + onClick={this._handleCancelBtn} + data-l10n-id={this.props.data.cancel_button_string_id} + /> + <button + className="done" + onClick={this._handleConfirmBtn} + data-l10n-id={this.props.data.confirm_button_string_id} + /> + </section> + </div> + </div> + ); + } +} + +export const ConfirmDialog = connect(state => state.Dialog)(_ConfirmDialog); diff --git a/browser/components/newtab/content-src/components/ConfirmDialog/_ConfirmDialog.scss b/browser/components/newtab/content-src/components/ConfirmDialog/_ConfirmDialog.scss new file mode 100644 index 0000000000..ca9940ffc5 --- /dev/null +++ b/browser/components/newtab/content-src/components/ConfirmDialog/_ConfirmDialog.scss @@ -0,0 +1,68 @@ +.confirmation-dialog { + .modal { + box-shadow: $shadow-secondary; + left: 0; + margin: auto; + position: fixed; + right: 0; + top: 20%; + width: 400px; + } + + section { + margin: 0; + } + + .modal-message { + display: flex; + padding: 16px; + padding-bottom: 0; + + p { + margin: 0; + margin-bottom: 16px; + } + } + + .actions { + border: 0; + display: flex; + flex-wrap: nowrap; + padding: 0 16px; + + button { + margin-inline-end: 16px; + padding-inline-end: 18px; + padding-inline-start: 18px; + white-space: normal; + width: 50%; + + &.done { + margin-inline-end: 0; + margin-inline-start: 0; + } + } + } + + .icon { + margin-inline-end: 16px; + } +} + +.modal-overlay { + background: var(--newtab-overlay-color); + height: 100%; + left: 0; + position: fixed; + top: 0; + width: 100%; + z-index: 11001; +} + +.modal { + background: var(--newtab-background-color-secondary); + border: $border-secondary; + border-radius: 5px; + font-size: 15px; + z-index: 11002; +} diff --git a/browser/components/newtab/content-src/components/ContextMenu/ContextMenu.jsx b/browser/components/newtab/content-src/components/ContextMenu/ContextMenu.jsx new file mode 100644 index 0000000000..5ea6a57f71 --- /dev/null +++ b/browser/components/newtab/content-src/components/ContextMenu/ContextMenu.jsx @@ -0,0 +1,176 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +import { connect } from "react-redux"; + +export class ContextMenu extends React.PureComponent { + constructor(props) { + super(props); + this.hideContext = this.hideContext.bind(this); + this.onShow = this.onShow.bind(this); + this.onClick = this.onClick.bind(this); + } + + hideContext() { + this.props.onUpdate(false); + } + + onShow() { + if (this.props.onShow) { + this.props.onShow(); + } + } + + componentDidMount() { + this.onShow(); + setTimeout(() => { + global.addEventListener("click", this.hideContext); + }, 0); + } + + componentWillUnmount() { + global.removeEventListener("click", this.hideContext); + } + + onClick(event) { + // Eat all clicks on the context menu so they don't bubble up to window. + // This prevents the context menu from closing when clicking disabled items + // or the separators. + event.stopPropagation(); + } + + render() { + // Disabling focus on the menu span allows the first tab to focus on the first menu item instead of the wrapper. + return ( + // eslint-disable-next-line jsx-a11y/interactive-supports-focus + <span className="context-menu"> + <ul + role="menu" + onClick={this.onClick} + onKeyDown={this.onClick} + className="context-menu-list" + > + {this.props.options.map((option, i) => + option.type === "separator" ? ( + <li key={i} className="separator" role="separator" /> + ) : ( + option.type !== "empty" && ( + <ContextMenuItem + key={i} + option={option} + hideContext={this.hideContext} + keyboardAccess={this.props.keyboardAccess} + /> + ) + ) + )} + </ul> + </span> + ); + } +} + +export class _ContextMenuItem extends React.PureComponent { + constructor(props) { + super(props); + this.onClick = this.onClick.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + this.onKeyUp = this.onKeyUp.bind(this); + this.focusFirst = this.focusFirst.bind(this); + } + + onClick(event) { + this.props.hideContext(); + this.props.option.onClick(event); + } + + // Focus the first menu item if the menu was accessed via the keyboard. + focusFirst(button) { + if (this.props.keyboardAccess && button) { + button.focus(); + } + } + + // This selects the correct node based on the key pressed + focusSibling(target, key) { + const parent = target.parentNode; + const closestSiblingSelector = + key === "ArrowUp" ? "previousSibling" : "nextSibling"; + if (!parent[closestSiblingSelector]) { + return; + } + if (parent[closestSiblingSelector].firstElementChild) { + parent[closestSiblingSelector].firstElementChild.focus(); + } else { + parent[closestSiblingSelector][ + closestSiblingSelector + ].firstElementChild.focus(); + } + } + + onKeyDown(event) { + const { option } = this.props; + switch (event.key) { + case "Tab": + // tab goes down in context menu, shift + tab goes up in context menu + // if we're on the last item, one more tab will close the context menu + // similarly, if we're on the first item, one more shift + tab will close it + if ( + (event.shiftKey && option.first) || + (!event.shiftKey && option.last) + ) { + this.props.hideContext(); + } + break; + case "ArrowUp": + case "ArrowDown": + event.preventDefault(); + this.focusSibling(event.target, event.key); + break; + case "Enter": + case " ": + event.preventDefault(); + this.props.hideContext(); + option.onClick(); + break; + case "Escape": + this.props.hideContext(); + break; + } + } + + // Prevents the default behavior of spacebar + // scrolling the page & auto-triggering buttons. + onKeyUp(event) { + if (event.key === " ") { + event.preventDefault(); + } + } + + render() { + const { option } = this.props; + return ( + <li role="presentation" className="context-menu-item"> + <button + className={option.disabled ? "disabled" : ""} + role="menuitem" + onClick={this.onClick} + onKeyDown={this.onKeyDown} + onKeyUp={this.onKeyUp} + ref={option.first ? this.focusFirst : null} + aria-haspopup={ + option.id === "newtab-menu-edit-topsites" ? "dialog" : null + } + > + <span data-l10n-id={option.string_id || option.id} /> + </button> + </li> + ); + } +} + +export const ContextMenuItem = connect(state => ({ + Prefs: state.Prefs, +}))(_ContextMenuItem); diff --git a/browser/components/newtab/content-src/components/ContextMenu/ContextMenuButton.jsx b/browser/components/newtab/content-src/components/ContextMenu/ContextMenuButton.jsx new file mode 100644 index 0000000000..0364f5386a --- /dev/null +++ b/browser/components/newtab/content-src/components/ContextMenu/ContextMenuButton.jsx @@ -0,0 +1,72 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; + +export class ContextMenuButton extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + showContextMenu: false, + contextMenuKeyboard: false, + }; + this.onClick = this.onClick.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + this.onUpdate = this.onUpdate.bind(this); + } + + openContextMenu(isKeyBoard, event) { + if (this.props.onUpdate) { + this.props.onUpdate(true); + } + this.setState({ + showContextMenu: true, + contextMenuKeyboard: isKeyBoard, + }); + } + + onClick(event) { + event.preventDefault(); + this.openContextMenu(false, event); + } + + onKeyDown(event) { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + this.openContextMenu(true, event); + } + } + + onUpdate(showContextMenu) { + if (this.props.onUpdate) { + this.props.onUpdate(showContextMenu); + } + this.setState({ showContextMenu }); + } + + render() { + const { tooltipArgs, tooltip, children, refFunction } = this.props; + const { showContextMenu, contextMenuKeyboard } = this.state; + + return ( + <React.Fragment> + <button + aria-haspopup="true" + data-l10n-id={tooltip} + data-l10n-args={tooltipArgs ? JSON.stringify(tooltipArgs) : null} + className="context-menu-button icon" + onKeyDown={this.onKeyDown} + onClick={this.onClick} + ref={refFunction} + /> + {showContextMenu + ? React.cloneElement(children, { + keyboardAccess: contextMenuKeyboard, + onUpdate: this.onUpdate, + }) + : null} + </React.Fragment> + ); + } +} diff --git a/browser/components/newtab/content-src/components/ContextMenu/_ContextMenu.scss b/browser/components/newtab/content-src/components/ContextMenu/_ContextMenu.scss new file mode 100644 index 0000000000..e3192a944c --- /dev/null +++ b/browser/components/newtab/content-src/components/ContextMenu/_ContextMenu.scss @@ -0,0 +1,59 @@ +@use 'sass:math'; + +/* stylelint-disable max-nesting-depth */ + +.context-menu { + background: var(--newtab-background-color-secondary); + border-radius: $context-menu-border-radius; + box-shadow: $context-menu-shadow; + display: block; + font-size: $context-menu-font-size; + margin-inline-start: 5px; + inset-inline-start: 100%; + position: absolute; + top: math.div($context-menu-button-size, 4); + z-index: 8; + + > ul { + list-style: none; + margin: 0; + padding: $context-menu-outer-padding 0; + + > li { + margin: 0; + width: 100%; + + &.separator { + border-bottom: $border-secondary; + margin: $context-menu-outer-padding 0; + } + + > a, + > button { + align-items: center; + color: inherit; + cursor: pointer; + display: flex; + width: 100%; + line-height: 16px; + outline: none; + border: 0; + padding: $context-menu-item-padding; + white-space: nowrap; + + &:is(:focus, :hover) { + background: var(--newtab-element-secondary-hover-color); + } + + &:active { + background: var(--newtab-element-secondary-active-color); + } + + &.disabled { + opacity: 0.4; + pointer-events: none; + } + } + } + } +} diff --git a/browser/components/newtab/content-src/components/CustomizeMenu/BackgroundsSection/BackgroundsSection.jsx b/browser/components/newtab/content-src/components/CustomizeMenu/BackgroundsSection/BackgroundsSection.jsx new file mode 100644 index 0000000000..522ea6841f --- /dev/null +++ b/browser/components/newtab/content-src/components/CustomizeMenu/BackgroundsSection/BackgroundsSection.jsx @@ -0,0 +1,11 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; + +export class BackgroundsSection extends React.PureComponent { + render() { + return <div />; + } +} diff --git a/browser/components/newtab/content-src/components/CustomizeMenu/ContentSection/ContentSection.jsx b/browser/components/newtab/content-src/components/CustomizeMenu/ContentSection/ContentSection.jsx new file mode 100644 index 0000000000..57ed935e93 --- /dev/null +++ b/browser/components/newtab/content-src/components/CustomizeMenu/ContentSection/ContentSection.jsx @@ -0,0 +1,270 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +import { actionCreators as ac } from "common/Actions.sys.mjs"; + +export class ContentSection extends React.PureComponent { + constructor(props) { + super(props); + this.onPreferenceSelect = this.onPreferenceSelect.bind(this); + + // Refs are necessary for dynamically measuring drawer heights for slide animations + this.topSitesDrawerRef = React.createRef(); + this.pocketDrawerRef = React.createRef(); + } + + inputUserEvent(eventSource, status) { + this.props.dispatch( + ac.UserEvent({ + event: "PREF_CHANGED", + source: eventSource, + value: { status, menu_source: "CUSTOMIZE_MENU" }, + }) + ); + } + + onPreferenceSelect(e) { + // eventSource: TOP_SITES | TOP_STORIES | HIGHLIGHTS + const { preference, eventSource } = e.target.dataset; + 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); + } + } else if (e.target.nodeName === "MOZ-TOGGLE") { + value = e.target.pressed; + if (eventSource) { + this.inputUserEvent(eventSource, value); + } + } + this.props.setPref(preference, value); + } + + componentDidMount() { + this.setDrawerMargins(); + } + + componentDidUpdate() { + this.setDrawerMargins(); + } + + setDrawerMargins() { + this.setDrawerMargin( + `TOP_SITES`, + this.props.enabledSections.topSitesEnabled + ); + this.setDrawerMargin( + `TOP_STORIES`, + this.props.enabledSections.pocketEnabled + ); + } + + setDrawerMargin(drawerID, isOpen) { + let drawerRef; + + if (drawerID === `TOP_SITES`) { + drawerRef = this.topSitesDrawerRef.current; + } else if (drawerID === `TOP_STORIES`) { + drawerRef = this.pocketDrawerRef.current; + } else { + return; + } + + let drawerHeight; + + if (drawerRef) { + drawerHeight = parseFloat(window.getComputedStyle(drawerRef)?.height); + + if (isOpen) { + drawerRef.style.marginTop = `0`; + } else { + drawerRef.style.marginTop = `-${drawerHeight}px`; + } + } + } + + render() { + const { + enabledSections, + mayHaveSponsoredTopSites, + pocketRegion, + mayHaveSponsoredStories, + mayHaveRecentSaves, + openPreferences, + } = this.props; + const { + topSitesEnabled, + pocketEnabled, + highlightsEnabled, + showSponsoredTopSitesEnabled, + showSponsoredPocketEnabled, + showRecentSavesEnabled, + topSitesRowsCount, + } = enabledSections; + + return ( + <div className="home-section"> + <div id="shortcuts-section" className="section"> + <moz-toggle + id="shortcuts-toggle" + pressed={topSitesEnabled || null} + onToggle={this.onPreferenceSelect} + data-preference="feeds.topsites" + data-eventSource="TOP_SITES" + data-l10n-id="newtab-custom-shortcuts-toggle" + data-l10n-attrs="label, description" + /> + <div> + <div className="more-info-top-wrapper"> + <div className="more-information" ref={this.topSitesDrawerRef}> + <select + id="row-selector" + className="selector" + name="row-count" + data-preference="topSitesRows" + value={topSitesRowsCount} + onChange={this.onPreferenceSelect} + disabled={!topSitesEnabled} + aria-labelledby="custom-shortcuts-title" + > + <option + value="1" + data-l10n-id="newtab-custom-row-selector" + data-l10n-args='{"num": 1}' + /> + <option + value="2" + data-l10n-id="newtab-custom-row-selector" + data-l10n-args='{"num": 2}' + /> + <option + value="3" + data-l10n-id="newtab-custom-row-selector" + data-l10n-args='{"num": 3}' + /> + <option + value="4" + data-l10n-id="newtab-custom-row-selector" + data-l10n-args='{"num": 4}' + /> + </select> + {mayHaveSponsoredTopSites && ( + <div className="check-wrapper" role="presentation"> + <input + id="sponsored-shortcuts" + className="sponsored-checkbox" + disabled={!topSitesEnabled} + checked={showSponsoredTopSitesEnabled} + type="checkbox" + onChange={this.onPreferenceSelect} + data-preference="showSponsoredTopSites" + data-eventSource="SPONSORED_TOP_SITES" + /> + <label + className="sponsored" + htmlFor="sponsored-shortcuts" + data-l10n-id="newtab-custom-sponsored-sites" + /> + </div> + )} + </div> + </div> + </div> + </div> + + {pocketRegion && ( + <div id="pocket-section" className="section"> + <label className="switch"> + <moz-toggle + id="pocket-toggle" + pressed={pocketEnabled || null} + onToggle={this.onPreferenceSelect} + aria-describedby="custom-pocket-subtitle" + data-preference="feeds.section.topstories" + data-eventSource="TOP_STORIES" + data-l10n-id="newtab-custom-stories-toggle" + data-l10n-attrs="label, description" + /> + </label> + <div> + {(mayHaveSponsoredStories || mayHaveRecentSaves) && ( + <div className="more-info-pocket-wrapper"> + <div className="more-information" ref={this.pocketDrawerRef}> + {mayHaveSponsoredStories && ( + <div className="check-wrapper" role="presentation"> + <input + id="sponsored-pocket" + className="sponsored-checkbox" + disabled={!pocketEnabled} + checked={showSponsoredPocketEnabled} + type="checkbox" + onChange={this.onPreferenceSelect} + data-preference="showSponsored" + data-eventSource="POCKET_SPOCS" + /> + <label + className="sponsored" + htmlFor="sponsored-pocket" + data-l10n-id="newtab-custom-pocket-sponsored" + /> + </div> + )} + {mayHaveRecentSaves && ( + <div className="check-wrapper" role="presentation"> + <input + id="recent-saves-pocket" + className="sponsored-checkbox" + disabled={!pocketEnabled} + checked={showRecentSavesEnabled} + type="checkbox" + onChange={this.onPreferenceSelect} + data-preference="showRecentSaves" + data-eventSource="POCKET_RECENT_SAVES" + /> + <label + className="sponsored" + htmlFor="recent-saves-pocket" + data-l10n-id="newtab-custom-pocket-show-recent-saves" + /> + </div> + )} + </div> + </div> + )} + </div> + </div> + )} + + <div id="recent-section" className="section"> + <label className="switch"> + <moz-toggle + id="highlights-toggle" + pressed={highlightsEnabled || null} + onToggle={this.onPreferenceSelect} + data-preference="feeds.section.highlights" + data-eventSource="HIGHLIGHTS" + data-l10n-id="newtab-custom-recent-toggle" + data-l10n-attrs="label, description" + /> + </label> + </div> + + <span className="divider" role="separator"></span> + + <div> + <button + id="settings-link" + className="external-link" + onClick={openPreferences} + data-l10n-id="newtab-custom-settings" + /> + </div> + </div> + ); + } +} diff --git a/browser/components/newtab/content-src/components/CustomizeMenu/CustomizeMenu.jsx b/browser/components/newtab/content-src/components/CustomizeMenu/CustomizeMenu.jsx new file mode 100644 index 0000000000..3d33f6fde7 --- /dev/null +++ b/browser/components/newtab/content-src/components/CustomizeMenu/CustomizeMenu.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 { BackgroundsSection } from "content-src/components/CustomizeMenu/BackgroundsSection/BackgroundsSection"; +import { ContentSection } from "content-src/components/CustomizeMenu/ContentSection/ContentSection"; +import { connect } from "react-redux"; +import React from "react"; +import { CSSTransition } from "react-transition-group"; + +export class _CustomizeMenu extends React.PureComponent { + constructor(props) { + super(props); + this.onEntered = this.onEntered.bind(this); + this.onExited = this.onExited.bind(this); + } + + onEntered() { + if (this.closeButton) { + this.closeButton.focus(); + } + } + + onExited() { + if (this.openButton) { + this.openButton.focus(); + } + } + + render() { + return ( + <span> + <CSSTransition + timeout={300} + classNames="personalize-animate" + in={!this.props.showing} + appear={true} + > + <button + className="icon icon-settings personalize-button" + onClick={() => this.props.onOpen()} + data-l10n-id="newtab-personalize-icon-label" + ref={c => (this.openButton = c)} + /> + </CSSTransition> + <CSSTransition + timeout={250} + classNames="customize-animate" + in={this.props.showing} + onEntered={this.onEntered} + onExited={this.onExited} + appear={true} + > + <div + className="customize-menu" + role="dialog" + data-l10n-id="newtab-personalize-dialog-label" + > + <button + onClick={() => this.props.onClose()} + className="close-button" + data-l10n-id="newtab-custom-close-button" + ref={c => (this.closeButton = c)} + /> + <BackgroundsSection /> + <ContentSection + openPreferences={this.props.openPreferences} + setPref={this.props.setPref} + enabledSections={this.props.enabledSections} + pocketRegion={this.props.pocketRegion} + mayHaveSponsoredTopSites={this.props.mayHaveSponsoredTopSites} + mayHaveSponsoredStories={this.props.mayHaveSponsoredStories} + mayHaveRecentSaves={this.props.DiscoveryStream.recentSavesEnabled} + dispatch={this.props.dispatch} + /> + </div> + </CSSTransition> + </span> + ); + } +} + +export const CustomizeMenu = connect(state => ({ + DiscoveryStream: state.DiscoveryStream, +}))(_CustomizeMenu); diff --git a/browser/components/newtab/content-src/components/CustomizeMenu/_CustomizeMenu.scss b/browser/components/newtab/content-src/components/CustomizeMenu/_CustomizeMenu.scss new file mode 100644 index 0000000000..f534b8701b --- /dev/null +++ b/browser/components/newtab/content-src/components/CustomizeMenu/_CustomizeMenu.scss @@ -0,0 +1,244 @@ +@media (height < 700px) { + .personalize-button { + position: absolute; + top: 16px; + inset-inline-end: 16px; + } +} + +@media (height >= 700px) { + .personalize-button { + position: fixed; + top: 16px; + inset-inline-end: 16px; + z-index: 1000; + } +} + +.personalize-button { + border: 0; + border-radius: 4px; + background-color: transparent; + padding: 15px; + + &:hover { + background-color: var(--newtab-element-hover-color); + } + + &:active { + background-color: var(--newtab-element-active-color); + } + + &:focus-visible { + @include ds-focus; + } + + &.personalize-animate-exit-done { + visibility: hidden; + } +} + +.customize-menu { + color: var(--newtab-text-primary-color); + background-color: var(--newtab-background-color-secondary); + width: 432px; + height: 100%; + position: fixed; + inset-block: 0; + inset-inline-end: 0; + z-index: 1001; + padding: 16px; + overflow: auto; + transform: translateX(435px); + visibility: hidden; + cursor: default; + + @media (prefers-reduced-motion: no-preference) { + // We need customize-animate-enter and customize-animate-exit to fix bug 1868232 + // These first 2 classes happen only while the element is animating. + &.customize-animate-enter, + &.customize-animate-exit, + // We only add these so the css is visible for inspecting while not animating. + // Otherwise it's difficult to see and inspect this css because the transition is so fast. + &.customize-animate-enter-done, + &.customize-animate-exit-done { + transition: transform 250ms $customize-menu-slide-bezier, visibility 250ms; + } + } + + @media (forced-colors: active) { + border-inline-start: solid 1px; + } + + &:dir(rtl) { + transform: translateX(-435px); + } + + &.customize-animate-enter-done, + &.customize-animate-enter-active { + box-shadow: $shadow-large; + visibility: visible; + transform: translateX(0); + } + + &.customize-animate-exit-active { + box-shadow: $shadow-large; + } + + .close-button { + margin-inline-start: auto; + margin-bottom: 28px; + white-space: nowrap; + display: block; + background-color: var(--newtab-element-secondary-color); + padding: 8px 10px; + border: $customize-menu-border-tint; + border-radius: 4px; + color: var(--newtab-text-primary-color); + font-size: 13px; + font-weight: 600; + } + + .close-button:hover { + background-color: var(--newtab-element-secondary-hover-color); + } + + .close-button:hover:active { + background-color: var(--newtab-element-secondary-active-color); + } +} + +.grid-skip { + display: contents; +} + +.home-section { + display: grid; + grid-template-columns: 1fr; + grid-template-rows: repeat(4, auto); + grid-row-gap: 32px; + padding: 0 16px; + + .section { + moz-toggle { + margin-bottom: 10px; + } + + .sponsored { + font-size: 14px; + margin-inline-start: 5px; + } + + .check-wrapper { + position: relative; + } + + .sponsored-checkbox { + margin-inline-start: 2px; + width: 16px; + height: 16px; + vertical-align: middle; + border: $customize-menu-border-tint; + box-sizing: border-box; + border-radius: 4px; + appearance: none; + background-color: var(--newtab-element-secondary-color); + } + + .sponsored-checkbox:checked { + -moz-context-properties: fill; + fill: var(--newtab-primary-element-text-color); + background: url('chrome://global/skin/icons/check.svg') center no-repeat; + background-color: var(--newtab-primary-action-background); + + @media (forced-colors: active) { + fill: $black; + } + } + + .sponsored-checkbox:active + .checkmark { + fill: var(--newtab-element-secondary-color); + } + + .selector { + color: var(--newtab-text-primary-color); + width: 118px; + display: block; + border: 1px solid var(--newtab-border-color); + border-radius: 4px; + appearance: none; + padding-block: 7px; + padding-inline: 10px 13px; + margin-inline-start: 2px; + margin-bottom: 2px; + -moz-context-properties: fill; + fill: var(--newtab-text-primary-color); + background: url('chrome://global/skin/icons/arrow-down-12.svg') right no-repeat; + background-size: 8px; + background-origin: content-box; + background-color: var(--newtab-background-color-secondary); + + &:dir(rtl) { + background-position-x: left; + } + } + + .more-info-top-wrapper, + .more-info-pocket-wrapper { + margin-inline-start: -2px; + overflow: hidden; + + .more-information { + position: relative; + transition: margin-top 250ms $customize-menu-expand-bezier; + } + } + + .more-info-top-wrapper { + .check-wrapper { + margin-top: 10px; + } + } + } + + .divider { + border-top: 1px var(--newtab-border-color) solid; + margin: 0 -16px; + } + + .external-link { + font-size: 14px; + cursor: pointer; + border: 1px solid transparent; + border-radius: 4px; + -moz-context-properties: fill; + fill: var(--newtab-text-primary-color); + background: url('chrome://global/skin/icons/settings.svg') left no-repeat; + background-size: 16px; + padding-inline-start: 21px; + margin-bottom: 20px; + text-decoration: underline; + + @media (forced-colors: active) { + padding: 8px 10px; + padding-inline-start: 21px; + } + + &:dir(rtl) { + background-position-x: right; + } + } + + .external-link:hover { + text-decoration: none; + } +} + +.home-section .section .sponsored-checkbox:focus-visible, +.selector:focus-visible, +.external-link:focus-visible, +.close-button:focus-visible { + border: 1px solid var(--newtab-primary-action-background); + outline: 0; + box-shadow: $shadow-focus; +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin.jsx new file mode 100644 index 0000000000..0112013391 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin.jsx @@ -0,0 +1,506 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { 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(); +} + +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 TogglePrefCheckbox extends React.PureComponent { + constructor(props) { + super(props); + this.onChange = this.onChange.bind(this); + } + + onChange(event) { + this.props.onChange(this.props.pref, event.target.checked); + } + + render() { + return ( + <> + <input + type="checkbox" + checked={this.props.checked} + onChange={this.onChange} + disabled={this.props.disabled} + />{" "} + {this.props.pref}{" "} + </> + ); + } +} + +export class Personalization extends React.PureComponent { + constructor(props) { + super(props); + this.togglePersonalization = this.togglePersonalization.bind(this); + } + + togglePersonalization() { + this.props.dispatch( + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_PERSONALIZATION_TOGGLE, + }) + ); + } + + render() { + const { lastUpdated, initialized } = this.props.state.Personalization; + return ( + <React.Fragment> + <table> + <tbody> + <Row> + <td colSpan="2"> + <TogglePrefCheckbox + checked={this.props.personalized} + pref="personalized" + onChange={this.togglePersonalization} + /> + </td> + </Row> + <Row> + <td className="min">Personalization Last Updated</td> + <td>{relativeTime(lastUpdated) || "(no data)"}</td> + </Row> + <Row> + <td className="min">Personalization Initialized</td> + <td>{initialized ? "true" : "false"}</td> + </Row> + </tbody> + </table> + </React.Fragment> + ); + } +} + +export class DiscoveryStreamAdminUI 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.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); + } + + 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> + ); + } + + 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 collapsible".split(" "); + const { config, layout } = this.props.state.DiscoveryStream; + const personalized = + this.props.otherPrefs["discoverystream.personalization.enabled"]; + return ( + <div> + <button className="button" onClick={this.restorePrefDefaults}> + Restore Pref Defaults + </button>{" "} + <button className="button" onClick={this.refreshCache}> + Refresh Cache + </button> + <br /> + <button className="button" onClick={this.expireCache}> + Expire Cache + </button>{" "} + <button className="button" onClick={this.systemTick}> + Trigger System Tick + </button>{" "} + <button className="button" onClick={this.idleDaily}> + Trigger Idle Daily + </button> + <br /> + <button className="button" onClick={this.syncRemoteSettings}> + Sync Remote Settings + </button> + <table> + <tbody> + {prefToggles.map(pref => ( + <Row key={pref}> + <td> + <TogglePrefCheckbox + checked={config[pref]} + pref={pref} + onChange={this.setConfigValue} + /> + </td> + </Row> + ))} + </tbody> + </table> + <h3>Layout</h3> + {layout.map((row, rowIndex) => ( + <div key={`row-${rowIndex}`}> + {row.components.map((component, componentIndex) => ( + <div key={`component-${componentIndex}`} className="ds-component"> + {this.renderComponent(row.width, component)} + </div> + ))} + </div> + ))} + <h3>Personalization</h3> + <Personalization + personalized={personalized} + dispatch={this.props.dispatch} + state={{ + Personalization: this.props.state.Personalization, + }} + /> + <h3>Spocs</h3> + {this.renderSpocs()} + <h3>Feeds Data</h3> + {this.renderFeedsData()} + </div> + ); + } +} + +export class DiscoveryStreamAdminInner extends React.PureComponent { + constructor(props) { + super(props); + this.setState = this.setState.bind(this); + } + + render() { + return ( + <div + className={`discoverystream-admin ${ + this.props.collapsed ? "collapsed" : "expanded" + }`} + > + <main className="main-panel"> + <h1>Discovery Stream Admin</h1> + + <p className="helpLink"> + <span className="icon icon-small-spacer icon-info" />{" "} + <span> + Need to access the ASRouter Admin dev tools?{" "} + <a target="blank" href="about:asrouter"> + Click here + </a> + </span> + </p> + + <React.Fragment> + <DiscoveryStreamAdminUI + state={{ + DiscoveryStream: this.props.DiscoveryStream, + Personalization: this.props.Personalization, + }} + otherPrefs={this.props.Prefs.values} + dispatch={this.props.dispatch} + /> + </React.Fragment> + </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("#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"); + } + + 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={`discoverystream-admin-toggle ${ + isCollapsed ? "collapsed" : "expanded" + }`} + onClick={this.renderAdmin ? this.onCollapseToggle : null} + > + <span className="icon icon-devtools" /> + </a> + {renderAdmin ? ( + <DiscoveryStreamAdminInner + {...props} + collapsed={this.state.collapsed} + /> + ) : null} + </React.Fragment> + ); + } +} + +const _DiscoveryStreamAdmin = props => ( + <SimpleHashRouter> + <CollapseToggle {...props} /> + </SimpleHashRouter> +); + +export const DiscoveryStreamAdmin = connect(state => ({ + Sections: state.Sections, + DiscoveryStream: state.DiscoveryStream, + Personalization: state.Personalization, + Prefs: state.Prefs, +}))(_DiscoveryStreamAdmin); diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin.scss b/browser/components/newtab/content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin.scss new file mode 100644 index 0000000000..a01227dd3d --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin.scss @@ -0,0 +1,337 @@ +/* stylelint-disable max-nesting-depth */ + +.discoverystream-admin-toggle { + position: fixed; + top: 50px; + inset-inline-end: 15px; + border: 0; + background: none; + z-index: 1; + border-radius: 2px; + + .icon-devtools { + background-image: url('chrome://global/skin/icons/developer.svg'); + padding: 15px; + } + + &:dir(rtl) { + transform: scaleX(-1); + } + + &:hover { + background: var(--newtab-element-hover-color); + } + + &.expanded { + background: $black-20; + } +} + +.discoverystream-admin { + $border-color: var(--newtab-border-color); + $monospace: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Droid Sans Mono', + 'Source Code Pro', monospace; + $sidebar-width: 240px; + + position: fixed; + top: 0; + inset-inline-start: 0; + width: 100%; + background: var(--newtab-background-color); + height: 100%; + overflow-y: scroll; + margin: 0 auto; + font-size: 14px; + padding-inline-start: $sidebar-width; + color: var(--newtab-text-primary-color); + + &.collapsed { + display: none; + } + + .sidebar { + inset-inline-start: 0; + position: fixed; + width: $sidebar-width; + padding: 30px 20px; + + ul { + margin: 0; + padding: 0; + list-style: none; + } + + li a { + padding: 10px 34px; + display: block; + color: var(--lwt-sidebar-text-color); + + &:hover { + background: var(--newtab-background-color-secondary); + } + } + } + + h1 { + font-weight: 200; + font-size: 32px; + } + + h2 .button, + p .button { + font-size: 14px; + padding: 6px 12px; + margin-inline-start: 5px; + margin-bottom: 0; + } + + .general-textarea { + direction: ltr; + width: 740px; + height: 500px; + overflow: auto; + resize: none; + border-radius: 4px; + display: flex; + } + + .wnp-textarea { + direction: ltr; + width: 740px; + height: 500px; + overflow: auto; + resize: none; + border-radius: 4px; + display: flex; + } + + .json-button { + display: inline-flex; + font-size: 10px; + padding: 4px 10px; + margin-bottom: 6px; + margin-inline-end: 4px; + + &:hover { + background-color: var(--newtab-element-hover-color); + box-shadow: none; + } + } + + table { + border-collapse: collapse; + width: 100%; + + &.minimal-table { + border-collapse: collapse; + border: 1px solid $border-color; + + td { + padding: 8px; + } + + td:first-child { + width: 1%; + white-space: nowrap; + } + + td:not(:first-child) { + font-family: $monospace; + } + } + + &.errorReporting { + tr { + border: 1px solid var(--newtab-background-color-secondary); + } + + td { + padding: 4px; + + &[rowspan] { + border: 1px solid var(--newtab-background-color-secondary); + } + } + } + } + + .sourceLabel { + background: var(--newtab-background-color-secondary); + padding: 2px 5px; + border-radius: 3px; + + &.isDisabled { + background: $email-input-invalid; + color: var(--newtab-status-error); + } + } + + .message-item { + &:first-child td { + border-top: 1px solid $border-color; + } + + td { + vertical-align: top; + padding: 8px; + border-bottom: 1px solid $border-color; + + &.min { + width: 1%; + white-space: nowrap; + } + + &.message-summary { + width: 60%; + } + + &.button-column { + width: 15%; + } + + &:first-child { + border-inline-start: 1px solid $border-color; + } + + &:last-child { + border-inline-end: 1px solid $border-color; + } + } + + &.blocked { + .message-id, + .message-summary { + opacity: 0.5; + } + + .message-id { + opacity: 0.5; + } + } + + .message-id { + font-family: $monospace; + font-size: 12px; + } + } + + .providerUrl { + font-size: 12px; + } + + pre { + background: var(--newtab-background-color-secondary); + margin: 0; + padding: 8px; + font-size: 12px; + max-width: 750px; + overflow: auto; + font-family: $monospace; + } + + .errorState { + border: $input-error-border; + } + + .helpLink { + padding: 10px; + display: flex; + background: $black-10; + border-radius: 3px; + align-items: center; + + a { + text-decoration: underline; + } + + .icon { + min-width: 18px; + min-height: 18px; + } + } + + .ds-component { + margin-bottom: 20px; + } + + .modalOverlayInner { + height: 80%; + } + + .clearButton { + border: 0; + padding: 4px; + border-radius: 4px; + display: flex; + + &:hover { + background: var(--newtab-element-hover-color); + } + } + + .collapsed { + display: none; + } + + .icon { + display: inline-table; + cursor: pointer; + width: 18px; + height: 18px; + } + + .button { + &:disabled, + &:disabled:active { + opacity: 0.5; + cursor: unset; + box-shadow: none; + } + } + + .impressions-section { + display: flex; + flex-direction: column; + gap: 16px; + + .impressions-item { + display: flex; + flex-flow: column nowrap; + padding: 8px; + border: 1px solid $border-color; + border-radius: 5px; + + .impressions-inner-box { + display: flex; + flex-flow: row nowrap; + gap: 8px; + } + + .impressions-category { + font-size: 1.15em; + white-space: nowrap; + flex-grow: 0.1; + } + + .impressions-buttons { + display: flex; + flex-direction: column; + gap: 8px; + + button { + margin: 0; + } + } + + .impressions-editor { + display: flex; + flex-grow: 1.5; + + .general-textarea { + width: auto; + flex-grow: 1; + } + } + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamAdmin/SimpleHashRouter.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamAdmin/SimpleHashRouter.jsx new file mode 100644 index 0000000000..9c3fd8579c --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamAdmin/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/DiscoveryStreamBase/DiscoveryStreamBase.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx new file mode 100644 index 0000000000..dff122b366 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx @@ -0,0 +1,386 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { CardGrid } from "content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid"; +import { CollectionCardGrid } from "content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid"; +import { CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection"; +import { connect } from "react-redux"; +import { DSMessage } from "content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage"; +import { DSPrivacyModal } from "content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal"; +import { DSSignup } from "content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup"; +import { DSTextPromo } from "content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo"; +import { Highlights } from "content-src/components/DiscoveryStreamComponents/Highlights/Highlights"; +import { HorizontalRule } from "content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule"; +import { Navigation } from "content-src/components/DiscoveryStreamComponents/Navigation/Navigation"; +import { PrivacyLink } from "content-src/components/DiscoveryStreamComponents/PrivacyLink/PrivacyLink"; +import React from "react"; +import { SectionTitle } from "content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle"; +import { selectLayoutRender } from "content-src/lib/selectLayoutRender"; +import { TopSites } from "content-src/components/TopSites/TopSites"; + +const ALLOWED_CSS_URL_PREFIXES = [ + "chrome://", + "resource://", + "https://img-getpocket.cdn.mozilla.net/", +]; +const DUMMY_CSS_SELECTOR = "DUMMY#CSS.SELECTOR"; + +/** + * Validate a CSS declaration. The values are assumed to be normalized by CSSOM. + */ +export function isAllowedCSS(property, value) { + // Bug 1454823: INTERNAL properties, e.g., -moz-context-properties, are + // exposed but their values aren't resulting in getting nothing. Fortunately, + // we don't care about validating the values of the current set of properties. + if (value === undefined) { + return true; + } + + // Make sure all urls are of the allowed protocols/prefixes + const urls = value.match(/url\("[^"]+"\)/g); + return ( + !urls || + urls.every(url => + ALLOWED_CSS_URL_PREFIXES.some(prefix => url.slice(5).startsWith(prefix)) + ) + ); +} + +export class _DiscoveryStreamBase extends React.PureComponent { + constructor(props) { + super(props); + this.onStyleMount = this.onStyleMount.bind(this); + } + + onStyleMount(style) { + // Unmounting style gets rid of old styles, so nothing else to do + if (!style) { + return; + } + + const { sheet } = style; + const styles = JSON.parse(style.dataset.styles); + styles.forEach((row, rowIndex) => { + row.forEach((component, componentIndex) => { + // Nothing to do without optional styles overrides + if (!component) { + return; + } + + Object.entries(component).forEach(([selectors, declarations]) => { + // Start with a dummy rule to validate declarations and selectors + sheet.insertRule(`${DUMMY_CSS_SELECTOR} {}`); + const [rule] = sheet.cssRules; + + // Validate declarations and remove any offenders. CSSOM silently + // discards invalid entries, so here we apply extra restrictions. + rule.style = declarations; + [...rule.style].forEach(property => { + const value = rule.style[property]; + if (!isAllowedCSS(property, value)) { + console.error(`Bad CSS declaration ${property}: ${value}`); + rule.style.removeProperty(property); + } + }); + + // Set the actual desired selectors scoped to the component + const prefix = `.ds-layout > .ds-column:nth-child(${ + rowIndex + 1 + }) .ds-column-grid > :nth-child(${componentIndex + 1})`; + // NB: Splitting on "," doesn't work with strings with commas, but + // we're okay with not supporting those selectors + rule.selectorText = selectors + .split(",") + .map( + selector => + prefix + + // Assume :pseudo-classes are for component instead of descendant + (selector[0] === ":" ? "" : " ") + + selector + ) + .join(","); + + // CSSOM silently ignores bad selectors, so we'll be noisy instead + if (rule.selectorText === DUMMY_CSS_SELECTOR) { + console.error(`Bad CSS selector ${selectors}`); + } + }); + }); + }); + } + + renderComponent(component, embedWidth) { + switch (component.type) { + case "Highlights": + return <Highlights />; + case "TopSites": + return ( + <div className="ds-top-sites"> + <TopSites isFixed={true} title={component.header?.title} /> + </div> + ); + case "TextPromo": + return ( + <DSTextPromo + dispatch={this.props.dispatch} + type={component.type} + data={component.data} + /> + ); + case "Signup": + return ( + <DSSignup + dispatch={this.props.dispatch} + type={component.type} + data={component.data} + /> + ); + case "Message": + return ( + <DSMessage + title={component.header && component.header.title} + subtitle={component.header && component.header.subtitle} + link_text={component.header && component.header.link_text} + link_url={component.header && component.header.link_url} + icon={component.header && component.header.icon} + essentialReadsHeader={component.essentialReadsHeader} + editorsPicksHeader={component.editorsPicksHeader} + /> + ); + case "SectionTitle": + return <SectionTitle header={component.header} />; + case "Navigation": + return ( + <Navigation + dispatch={this.props.dispatch} + links={component.properties.links} + extraLinks={component.properties.extraLinks} + alignment={component.properties.alignment} + explore_topics={component.properties.explore_topics} + header={component.header} + locale={this.props.App.locale} + newFooterSection={component.newFooterSection} + privacyNoticeURL={component.properties.privacyNoticeURL} + /> + ); + case "CollectionCardGrid": + const { DiscoveryStream } = this.props; + return ( + <CollectionCardGrid + data={component.data} + feed={component.feed} + spocs={DiscoveryStream.spocs} + placement={component.placement} + type={component.type} + items={component.properties.items} + dismissible={this.props.DiscoveryStream.isCollectionDismissible} + dispatch={this.props.dispatch} + /> + ); + case "CardGrid": + return ( + <CardGrid + title={component.header && component.header.title} + data={component.data} + feed={component.feed} + widgets={component.widgets} + type={component.type} + dispatch={this.props.dispatch} + items={component.properties.items} + hybridLayout={component.properties.hybridLayout} + hideCardBackground={component.properties.hideCardBackground} + fourCardLayout={component.properties.fourCardLayout} + compactGrid={component.properties.compactGrid} + essentialReadsHeader={component.properties.essentialReadsHeader} + onboardingExperience={component.properties.onboardingExperience} + ctaButtonSponsors={component.properties.ctaButtonSponsors} + ctaButtonVariant={component.properties.ctaButtonVariant} + editorsPicksHeader={component.properties.editorsPicksHeader} + recentSavesEnabled={this.props.DiscoveryStream.recentSavesEnabled} + hideDescriptions={this.props.DiscoveryStream.hideDescriptions} + /> + ); + case "HorizontalRule": + return <HorizontalRule />; + case "PrivacyLink": + return <PrivacyLink properties={component.properties} />; + default: + return <div>{component.type}</div>; + } + } + + renderStyles(styles) { + // Use json string as both the key and styles to render so React knows when + // to unmount and mount a new instance for new styles. + const json = JSON.stringify(styles); + return <style key={json} data-styles={json} ref={this.onStyleMount} />; + } + + render() { + const { locale } = this.props; + // Select layout render data by adding spocs and position to recommendations + const { layoutRender } = selectLayoutRender({ + state: this.props.DiscoveryStream, + prefs: this.props.Prefs.values, + locale, + }); + const { config } = this.props.DiscoveryStream; + + // Allow rendering without extracting special components + if (!config.collapsible) { + return this.renderLayout(layoutRender); + } + + // Find the first component of a type and remove it from layout + const extractComponent = type => { + for (const [rowIndex, row] of Object.entries(layoutRender)) { + for (const [index, component] of Object.entries(row.components)) { + if (component.type === type) { + // Remove the row if it was the only component or the single item + if (row.components.length === 1) { + layoutRender.splice(rowIndex, 1); + } else { + row.components.splice(index, 1); + } + return component; + } + } + } + return null; + }; + + // Get "topstories" Section state for default values + const topStories = this.props.Sections.find(s => s.id === "topstories"); + + if (!topStories) { + return null; + } + + // Extract TopSites to render before the rest and Message to use for header + const topSites = extractComponent("TopSites"); + const sponsoredCollection = extractComponent("CollectionCardGrid"); + const message = extractComponent("Message") || { + header: { + link_text: topStories.learnMore.link.message, + link_url: topStories.learnMore.link.href, + title: topStories.title, + }, + }; + + const privacyLinkComponent = extractComponent("PrivacyLink"); + let learnMore = { + link: { + href: message.header.link_url, + message: message.header.link_text, + }, + }; + let sectionTitle = message.header.title; + let subTitle = ""; + + // If we're in one of these experiments, override the default message. + // For now this is English only. + if (message.essentialReadsHeader || message.editorsPicksHeader) { + learnMore = null; + subTitle = "Recommended By Pocket"; + if (message.essentialReadsHeader) { + sectionTitle = "Today’s Essential Reads"; + } else if (message.editorsPicksHeader) { + sectionTitle = "Editor’s Picks"; + } + } + + // Render a DS-style TopSites then the rest if any in a collapsible section + return ( + <React.Fragment> + {this.props.DiscoveryStream.isPrivacyInfoModalVisible && ( + <DSPrivacyModal dispatch={this.props.dispatch} /> + )} + {topSites && + this.renderLayout([ + { + width: 12, + components: [topSites], + }, + ])} + {sponsoredCollection && + this.renderLayout([ + { + width: 12, + components: [sponsoredCollection], + }, + ])} + {!!layoutRender.length && ( + <CollapsibleSection + className="ds-layout" + collapsed={topStories.pref.collapsed} + dispatch={this.props.dispatch} + id={topStories.id} + isFixed={true} + learnMore={learnMore} + privacyNoticeURL={topStories.privacyNoticeURL} + showPrefName={topStories.pref.feed} + title={sectionTitle} + subTitle={subTitle} + eventSource="CARDGRID" + > + {this.renderLayout(layoutRender)} + </CollapsibleSection> + )} + {this.renderLayout([ + { + width: 12, + components: [{ type: "Highlights" }], + }, + ])} + {privacyLinkComponent && + this.renderLayout([ + { + width: 12, + components: [privacyLinkComponent], + }, + ])} + </React.Fragment> + ); + } + + renderLayout(layoutRender) { + const styles = []; + return ( + <div className="discovery-stream ds-layout"> + {layoutRender.map((row, rowIndex) => ( + <div + key={`row-${rowIndex}`} + className={`ds-column ds-column-${row.width}`} + > + <div className="ds-column-grid"> + {row.components.map((component, componentIndex) => { + if (!component) { + return null; + } + styles[rowIndex] = [ + ...(styles[rowIndex] || []), + component.styles, + ]; + return ( + <div key={`component-${componentIndex}`}> + {this.renderComponent(component, row.width)} + </div> + ); + })} + </div> + </div> + ))} + {this.renderStyles(styles)} + </div> + ); + } +} + +export const DiscoveryStreamBase = connect(state => ({ + DiscoveryStream: state.DiscoveryStream, + Prefs: state.Prefs, + Sections: state.Sections, + document: global.document, + App: state.App, +}))(_DiscoveryStreamBase); diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamBase/_DiscoveryStreamBase.scss b/browser/components/newtab/content-src/components/DiscoveryStreamBase/_DiscoveryStreamBase.scss new file mode 100644 index 0000000000..015cb57a8b --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamBase/_DiscoveryStreamBase.scss @@ -0,0 +1,67 @@ +$ds-width: 936px; + +.discovery-stream.ds-layout { + $columns: 12; + --gridColumnGap: 48px; + --gridRowGap: 24px; + + grid-template-columns: repeat($columns, 1fr); + grid-column-gap: var(--gridColumnGap); + grid-row-gap: var(--gridRowGap); + margin: 0 auto; + + @while $columns > 0 { + .ds-column-#{$columns} { + grid-column-start: auto; + grid-column-end: span $columns; + } + + $columns: $columns - 1; + } + + .ds-column-grid { + display: grid; + grid-row-gap: var(--gridRowGap); + + // We want to completely hide components with no content, + // otherwise, it creates grid-row-gap gaps around nothing. + > div:empty { + display: none; + } + } +} + +.ds-header { + margin: 8px 0; + + .ds-context { + font-weight: 400; + } +} + +.ds-header, +.ds-layout .section-title span { + color: var(--newtab-text-primary-color); + font-size: $section-title-font-size; + font-weight: 600; + line-height: 20px; + + .icon { + fill: var(--newtab-text-secondary-color); + } +} + +.collapsible-section.ds-layout { + margin: auto; + + .section-top-bar { + .learn-more-link a { + color: var(--newtab-primary-action-background); + font-weight: 500; + + &:is(:focus, :hover) { + text-decoration: none; + } + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx new file mode 100644 index 0000000000..f13e0eb7ed --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx @@ -0,0 +1,542 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { DSCard, PlaceholderDSCard } from "../DSCard/DSCard.jsx"; +import { DSEmptyState } from "../DSEmptyState/DSEmptyState.jsx"; +import { DSDismiss } from "content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss"; +import { TopicsWidget } from "../TopicsWidget/TopicsWidget.jsx"; +import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; +import { FluentOrText } from "../../FluentOrText/FluentOrText.jsx"; +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import React, { useEffect, useState, useRef, useCallback } from "react"; +import { connect, useSelector } from "react-redux"; +const PREF_ONBOARDING_EXPERIENCE_DISMISSED = + "discoverystream.onboardingExperience.dismissed"; +const INTERSECTION_RATIO = 0.5; +const VISIBLE = "visible"; +const VISIBILITY_CHANGE_EVENT = "visibilitychange"; +const WIDGET_IDS = { + TOPICS: 1, +}; + +export function DSSubHeader({ children }) { + return ( + <div className="section-top-bar ds-sub-header"> + <h3 className="section-title-container">{children}</h3> + </div> + ); +} + +export function OnboardingExperience({ + children, + dispatch, + windowObj = global, +}) { + const [dismissed, setDismissed] = useState(false); + const [maxHeight, setMaxHeight] = useState(null); + const heightElement = useRef(null); + + const onDismissClick = useCallback(() => { + // We update this as state and redux. + // The state update is for this newtab, + // and the redux update is for other tabs, offscreen tabs, and future tabs. + // We need the state update for this tab to support the transition. + setDismissed(true); + dispatch(ac.SetPref(PREF_ONBOARDING_EXPERIENCE_DISMISSED, true)); + dispatch( + ac.DiscoveryStreamUserEvent({ + event: "BLOCK", + source: "POCKET_ONBOARDING", + }) + ); + }, [dispatch]); + + useEffect(() => { + const resizeObserver = new windowObj.ResizeObserver(() => { + if (heightElement.current) { + setMaxHeight(heightElement.current.offsetHeight); + } + }); + + const options = { threshold: INTERSECTION_RATIO }; + const intersectionObserver = new windowObj.IntersectionObserver(entries => { + if ( + entries.some( + entry => + entry.isIntersecting && + entry.intersectionRatio >= INTERSECTION_RATIO + ) + ) { + dispatch( + ac.DiscoveryStreamUserEvent({ + event: "IMPRESSION", + source: "POCKET_ONBOARDING", + }) + ); + // Once we have observed an impression, we can stop for this instance of newtab. + intersectionObserver.unobserve(heightElement.current); + } + }, options); + + const onVisibilityChange = () => { + intersectionObserver.observe(heightElement.current); + windowObj.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + onVisibilityChange + ); + }; + + if (heightElement.current) { + resizeObserver.observe(heightElement.current); + // Check visibility or setup a visibility event to make + // sure we don't fire this for off screen pre loaded tabs. + if (windowObj.document.visibilityState === VISIBLE) { + intersectionObserver.observe(heightElement.current); + } else { + windowObj.document.addEventListener( + VISIBILITY_CHANGE_EVENT, + onVisibilityChange + ); + } + setMaxHeight(heightElement.current.offsetHeight); + } + + // Return unmount callback to clean up observers. + return () => { + resizeObserver?.disconnect(); + intersectionObserver?.disconnect(); + windowObj.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + onVisibilityChange + ); + }; + }, [dispatch, windowObj]); + + const style = {}; + if (dismissed) { + style.maxHeight = "0"; + style.opacity = "0"; + style.transition = "max-height 0.26s ease, opacity 0.26s ease"; + } else if (maxHeight) { + style.maxHeight = `${maxHeight}px`; + } + + return ( + <div style={style}> + <div className="ds-onboarding-ref" ref={heightElement}> + <div className="ds-onboarding-container"> + <DSDismiss + onDismissClick={onDismissClick} + extraClasses={`ds-onboarding`} + > + <div> + <header> + <span className="icon icon-pocket" /> + <span data-l10n-id="newtab-pocket-onboarding-discover" /> + </header> + <p data-l10n-id="newtab-pocket-onboarding-cta" /> + </div> + <div className="ds-onboarding-graphic" /> + </DSDismiss> + </div> + </div> + </div> + ); +} + +export function IntersectionObserver({ + children, + windowObj = window, + onIntersecting, +}) { + const intersectionElement = useRef(null); + + useEffect(() => { + let observer; + if (!observer && onIntersecting && intersectionElement.current) { + observer = new windowObj.IntersectionObserver(entries => { + const entry = entries.find(e => e.isIntersecting); + + if (entry) { + // Stop observing since element has been seen + if (observer && intersectionElement.current) { + observer.unobserve(intersectionElement.current); + } + + onIntersecting(); + } + }); + observer.observe(intersectionElement.current); + } + // Cleanup + return () => observer?.disconnect(); + }, [windowObj, onIntersecting]); + + return <div ref={intersectionElement}>{children}</div>; +} + +export function RecentSavesContainer({ + gridClassName = "", + dispatch, + windowObj = window, + items = 3, + source = "CARDGRID_RECENT_SAVES", +}) { + const { + recentSavesData, + isUserLoggedIn, + experimentData: { utmCampaign, utmContent, utmSource }, + } = useSelector(state => state.DiscoveryStream); + + const [visible, setVisible] = useState(false); + const onIntersecting = useCallback(() => setVisible(true), []); + + useEffect(() => { + if (visible) { + dispatch( + ac.AlsoToMain({ + type: at.DISCOVERY_STREAM_POCKET_STATE_INIT, + }) + ); + } + }, [visible, dispatch]); + + // The user has not yet scrolled to this section, + // so wait before potentially requesting Pocket data. + if (!visible) { + return ( + <IntersectionObserver + windowObj={windowObj} + onIntersecting={onIntersecting} + /> + ); + } + + // Intersection observer has finished, but we're not yet logged in. + if (visible && !isUserLoggedIn) { + return null; + } + + let queryParams = `?utm_source=${utmSource}`; + // We really only need to add these params to urls we own. + if (utmCampaign && utmContent) { + queryParams += `&utm_content=${utmContent}&utm_campaign=${utmCampaign}`; + } + + function renderCard(rec, index) { + const url = new URL(rec.url); + const urlSearchParams = new URLSearchParams(queryParams); + if (rec?.id && !url.href.match(/getpocket\.com\/read/)) { + url.href = `https://getpocket.com/read/${rec.id}`; + } + + for (let [key, val] of urlSearchParams.entries()) { + url.searchParams.set(key, val); + } + + return ( + <DSCard + key={`dscard-${rec?.id || index}`} + id={rec.id} + pos={index} + type={source} + image_src={rec.image_src} + raw_image_src={rec.raw_image_src} + word_count={rec.word_count} + time_to_read={rec.time_to_read} + title={rec.title} + excerpt={rec.excerpt} + url={url.href} + source={rec.domain} + isRecentSave={true} + dispatch={dispatch} + /> + ); + } + + function onMyListClicked() { + dispatch( + ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source: `${source}_VIEW_LIST`, + }) + ); + } + + const recentSavesCards = []; + // We fill the cards with a for loop over an inline map because + // we want empty placeholders if there are not enough cards. + for (let index = 0; index < items; index++) { + const recentSave = recentSavesData[index]; + if (!recentSave) { + recentSavesCards.push(<PlaceholderDSCard key={`dscard-${index}`} />); + } else { + recentSavesCards.push( + renderCard( + { + id: recentSave.id, + image_src: recentSave.top_image_url, + raw_image_src: recentSave.top_image_url, + word_count: recentSave.word_count, + time_to_read: recentSave.time_to_read, + title: recentSave.resolved_title || recentSave.given_title, + url: recentSave.resolved_url || recentSave.given_url, + domain: recentSave.domain_metadata?.name, + excerpt: recentSave.excerpt, + }, + index + ) + ); + } + } + + // We are visible and logged in. + return ( + <> + <DSSubHeader> + <span className="section-title"> + <FluentOrText message="Recently Saved to your List" /> + </span> + <SafeAnchor + onLinkClick={onMyListClicked} + className="section-sub-link" + url={`https://getpocket.com/a${queryParams}`} + > + <FluentOrText message="View My List" /> + </SafeAnchor> + </DSSubHeader> + <div className={`ds-card-grid-recent-saves ${gridClassName}`}> + {recentSavesCards} + </div> + </> + ); +} + +export class _CardGrid extends React.PureComponent { + renderCards() { + const prefs = this.props.Prefs.values; + const { + items, + hybridLayout, + hideCardBackground, + fourCardLayout, + compactGrid, + essentialReadsHeader, + editorsPicksHeader, + onboardingExperience, + ctaButtonSponsors, + ctaButtonVariant, + widgets, + recentSavesEnabled, + hideDescriptions, + DiscoveryStream, + } = this.props; + const { saveToPocketCard } = DiscoveryStream; + const showRecentSaves = prefs.showRecentSaves && recentSavesEnabled; + const isOnboardingExperienceDismissed = + prefs[PREF_ONBOARDING_EXPERIENCE_DISMISSED]; + + const recs = this.props.data.recommendations.slice(0, items); + const cards = []; + let essentialReadsCards = []; + let editorsPicksCards = []; + + for (let index = 0; index < items; index++) { + const rec = recs[index]; + cards.push( + !rec || rec.placeholder ? ( + <PlaceholderDSCard key={`dscard-${index}`} /> + ) : ( + <DSCard + key={`dscard-${rec.id}`} + pos={rec.pos} + flightId={rec.flight_id} + image_src={rec.image_src} + raw_image_src={rec.raw_image_src} + word_count={rec.word_count} + time_to_read={rec.time_to_read} + title={rec.title} + excerpt={rec.excerpt} + url={rec.url} + id={rec.id} + shim={rec.shim} + type={this.props.type} + context={rec.context} + sponsor={rec.sponsor} + sponsored_by_override={rec.sponsored_by_override} + dispatch={this.props.dispatch} + source={rec.domain} + publisher={rec.publisher} + pocket_id={rec.pocket_id} + context_type={rec.context_type} + bookmarkGuid={rec.bookmarkGuid} + is_collection={this.props.is_collection} + saveToPocketCard={saveToPocketCard} + ctaButtonSponsors={ctaButtonSponsors} + ctaButtonVariant={ctaButtonVariant} + recommendation_id={rec.recommendation_id} + /> + ) + ); + } + + if (widgets?.positions?.length && widgets?.data?.length) { + let positionIndex = 0; + const source = "CARDGRID_WIDGET"; + + for (const widget of widgets.data) { + let widgetComponent = null; + const position = widgets.positions[positionIndex]; + + // Stop if we run out of positions to place widgets. + if (!position) { + break; + } + + switch (widget?.type) { + case "TopicsWidget": + widgetComponent = ( + <TopicsWidget + position={position.index} + dispatch={this.props.dispatch} + source={source} + id={WIDGET_IDS.TOPICS} + /> + ); + break; + } + + if (widgetComponent) { + // We found a widget, so up the position for next try. + positionIndex++; + // We replace an existing card with the widget. + cards.splice(position.index, 1, widgetComponent); + } + } + } + + let moreRecsHeader = ""; + // For now this is English only. + if (showRecentSaves || (essentialReadsHeader && editorsPicksHeader)) { + let spliceAt = 6; + // For 4 card row layouts, second row is 8 cards, and regular it is 6 cards. + if (fourCardLayout) { + spliceAt = 8; + } + // If we have a custom header, ensure the more recs section also has a header. + moreRecsHeader = "More Recommendations"; + // Put the first 2 rows into essentialReadsCards. + essentialReadsCards = [...cards.splice(0, spliceAt)]; + // Put the rest into editorsPicksCards. + if (essentialReadsHeader && editorsPicksHeader) { + editorsPicksCards = [...cards.splice(0, cards.length)]; + } + } + + const hideCardBackgroundClass = hideCardBackground + ? `ds-card-grid-hide-background` + : ``; + const fourCardLayoutClass = fourCardLayout + ? `ds-card-grid-four-card-variant` + : ``; + const hideDescriptionsClassName = !hideDescriptions + ? `ds-card-grid-include-descriptions` + : ``; + const compactGridClassName = compactGrid ? `ds-card-grid-compact` : ``; + const hybridLayoutClassName = hybridLayout + ? `ds-card-grid-hybrid-layout` + : ``; + + const gridClassName = `ds-card-grid ${hybridLayoutClassName} ${hideCardBackgroundClass} ${fourCardLayoutClass} ${hideDescriptionsClassName} ${compactGridClassName}`; + + return ( + <> + {!isOnboardingExperienceDismissed && onboardingExperience && ( + <OnboardingExperience dispatch={this.props.dispatch} /> + )} + {essentialReadsCards?.length > 0 && ( + <div className={gridClassName}>{essentialReadsCards}</div> + )} + {showRecentSaves && ( + <RecentSavesContainer + gridClassName={gridClassName} + dispatch={this.props.dispatch} + /> + )} + {editorsPicksCards?.length > 0 && ( + <> + <DSSubHeader> + <span className="section-title"> + <FluentOrText message="Editor’s Picks" /> + </span> + </DSSubHeader> + <div className={gridClassName}>{editorsPicksCards}</div> + </> + )} + {cards?.length > 0 && ( + <> + {moreRecsHeader && ( + <DSSubHeader> + <span className="section-title"> + <FluentOrText message={moreRecsHeader} /> + </span> + </DSSubHeader> + )} + <div className={gridClassName}>{cards}</div> + </> + )} + </> + ); + } + + render() { + const { data } = this.props; + + // Handle a render before feed has been fetched by displaying nothing + if (!data) { + return null; + } + + // Handle the case where a user has dismissed all recommendations + const isEmpty = data.recommendations.length === 0; + + return ( + <div> + {this.props.title && ( + <div className="ds-header"> + <div className="title">{this.props.title}</div> + {this.props.context && ( + <FluentOrText message={this.props.context}> + <div className="ds-context" /> + </FluentOrText> + )} + </div> + )} + {isEmpty ? ( + <div className="ds-card-grid empty"> + <DSEmptyState + status={data.status} + dispatch={this.props.dispatch} + feed={this.props.feed} + /> + </div> + ) : ( + this.renderCards() + )} + </div> + ); + } +} + +_CardGrid.defaultProps = { + items: 4, // Number of stories to display +}; + +export const CardGrid = connect(state => ({ + Prefs: state.Prefs, + DiscoveryStream: state.DiscoveryStream, +}))(_CardGrid); diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/_CardGrid.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/_CardGrid.scss new file mode 100644 index 0000000000..cab3a20578 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/_CardGrid.scss @@ -0,0 +1,352 @@ +$col4-header-line-height: 20; +$col4-header-font-size: 14; + +.ds-onboarding-container, +.ds-card-grid .ds-card { + @include dark-theme-only { + background: none; + } + + background: $white; + border-radius: 4px; + + &:not(.placeholder) { + @include dark-theme-only { + background: var(--newtab-background-color-secondary); + } + + border-radius: $border-radius-new; + box-shadow: $shadow-card; + + .img-wrapper .img { + img, + .placeholder-image { + border-radius: $border-radius-new $border-radius-new 0 0; + } + } + } +} + +.ds-onboarding-container { + padding-inline-start: 16px; + padding-inline-end: 16px; + + @media (min-width: $break-point-medium) { + padding-inline-end: 48px; + } + + @media (min-width: $break-point-large) { + padding-inline-end: 56px; + } + + margin-bottom: 24px; + // This is to position the dismiss button to the right most of this element. + position: relative; + + .ds-onboarding { + position: static; + display: flex; + + .ds-dismiss-button { + inset-inline-end: 8px; + top: 8px; + } + } + + header { + @include dark-theme-only { + color: var(--newtab-background-color-primary); + } + + display: flex; + margin: 32px 0 8px; + + @media (min-width: $break-point-medium) { + margin: 16px 0 8px; + display: block; + height: 24px; + } + + font-size: 17px; + line-height: 23.8px; + font-weight: 600; + color: $pocket-icon-fill; + } + + p { + margin: 8px 0 16px; + font-size: 13px; + line-height: 19.5px; + } + + .icon-pocket { + @include dark-theme-only { + @media (forced-colors: active) { + fill: CurrentColor; + } + + fill: var(--newtab-text-primary-color); + } + + @media (forced-colors: active) { + fill: CurrentColor; + } + + fill: $pocket-icon-fill; + margin-top: 3px; + margin-inline-end: 8px; + height: 22px; + width: 22px; + background-image: url('chrome://global/skin/icons/pocket.svg'); + + @media (min-width: $break-point-medium) { + margin-top: -5px; + margin-inline-start: -2px; + margin-inline-end: 15px; + height: 30px; + width: 30px; + } + + background-size: contain; + } + + .ds-onboarding-graphic { + background-image: image-set(url('chrome://activity-stream/content/data/content/assets/pocket-onboarding.avif'), url('chrome://activity-stream/content/data/content/assets/pocket-onboarding@2x.avif') 2x); + border-radius: 8px; + background-size: contain; + background-repeat: no-repeat; + background-position: center; + height: 120px; + width: 200px; + margin-top: 16px; + margin-bottom: 16px; + margin-inline-start: 54px; + flex-shrink: 0; + display: none; + + @media (min-width: $break-point-large) { + display: block; + } + } +} + +.ds-card-grid { + display: grid; + grid-gap: 24px; + + &.ds-card-grid-compact { + grid-gap: 20px; + } + + &.ds-card-grid-recent-saves { + .ds-card { + // Hide the second row orphan on narrow screens. + @media (min-width: $break-point-medium) and (max-width: $break-point-large) { + &:last-child:nth-child(2n - 1) { + display: none; + } + } + } + } + + .ds-card-link:focus { + @include ds-focus; + + transition: none; + border-radius: $border-radius-new; + } + + // "2/3 width layout" + .ds-column-5 &, + .ds-column-6 &, + .ds-column-7 &, + .ds-column-8 & { + grid-template-columns: repeat(2, 1fr); + } + + // "Full width layout" + .ds-column-9 &, + .ds-column-10 &, + .ds-column-11 &, + .ds-column-12 & { + grid-template-columns: repeat(1, 1fr); + + @media (min-width: $break-point-medium) { + grid-template-columns: repeat(2, 1fr); + } + + @media (min-width: $break-point-large) { + grid-template-columns: repeat(3, 1fr); + } + + .title { + font-size: 17px; + line-height: 24px; + } + + .excerpt { + @include limit-visible-lines(3, 24, 15); + } + } + + &.empty { + grid-template-columns: auto; + } + + @mixin small-cards { + .ds-card { + &.placeholder { + min-height: 247px; + } + + .meta { + .story-footer { + margin-top: 8px; + } + + .source, + .story-sponsored-label, + .status-message .story-context-label { + color: var(--newtab-text-secondary-color); + -webkit-line-clamp: 2; + } + + .source, + .story-sponsored-label { + font-size: 13px; + } + + .status-message .story-context-label { + font-size: 11.7px; + } + + .story-badge-icon { + margin-inline-end: 2px; + margin-bottom: 2px; + height: 14px; + width: 14px; + background-size: 14px; + } + + .title { + font-size: 14px; + line-height: 20px; + } + + .info-wrap { + flex-grow: 0; + } + } + } + } + + &.ds-card-grid-four-card-variant { + // "Full width layout" + .ds-column-9 &, + .ds-column-10 &, + .ds-column-11 &, + .ds-column-12 & { + grid-template-columns: repeat(1, 1fr); + + @media (min-width: $break-point-medium) { + grid-template-columns: repeat(2, 1fr); + } + + @media (min-width: $break-point-large) { + grid-template-columns: repeat(3, 1fr); + } + + @media (min-width: $break-point-widest) { + grid-template-columns: repeat(4, 1fr); + } + } + + @include small-cards; + } + + &.ds-card-grid-hybrid-layout { + .ds-column-9 &, + .ds-column-10 &, + .ds-column-11 &, + .ds-column-12 & { + grid-template-columns: repeat(1, 1fr); + + @media (min-width: $break-point-medium) { + grid-template-columns: repeat(2, 1fr); + } + + @media (min-width: $break-point-large) { + grid-template-columns: repeat(3, 1fr); + } + + @media (height <= 1065px) { + .excerpt { + display: none; + } + } + + @media (max-width: $break-point-widest) { + @include small-cards; + } + + @media (min-width: $break-point-widest) and (height <= 964px) { + @include small-cards; + + grid-template-columns: repeat(4, 1fr); + } + } + } +} + +.outer-wrapper .ds-card-grid.ds-card-grid-hide-background .ds-card, +.outer-wrapper.newtab-experience .ds-card-grid.ds-card-grid-hide-background .ds-card { + &:not(.placeholder) { + box-shadow: none; + background: none; + + .ds-card-link:focus { + box-shadow: none; + + .img-wrapper .img img { + @include ds-focus; + } + } + + .img-wrapper .img img { + border-radius: 8px; + box-shadow: $shadow-card; + } + + .meta { + padding: 12px 0 0; + } + } +} + +.ds-layout { + .ds-sub-header { + margin-top: 24px; + + .section-title-container { + flex-direction: row; + align-items: baseline; + justify-content: space-between; + display: flex; + } + + .section-sub-link { + color: var(--newtab-primary-action-background); + font-size: 14px; + line-height: 16px; + cursor: pointer; + + &:hover { + text-decoration: underline; + } + + &:active { + color: var(--newtab-primary-element-active-color); + } + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid.jsx new file mode 100644 index 0000000000..d089a5c8ab --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid.jsx @@ -0,0 +1,139 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { actionCreators as ac } from "common/Actions.sys.mjs"; +import { CardGrid } from "content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid"; +import { DSDismiss } from "content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss"; +import { LinkMenuOptions } from "content-src/lib/link-menu-options"; +import React from "react"; + +export class CollectionCardGrid extends React.PureComponent { + constructor(props) { + super(props); + this.onDismissClick = this.onDismissClick.bind(this); + this.state = { + dismissed: false, + }; + } + + onDismissClick() { + const { data } = this.props; + if (this.props.dispatch && data && data.spocs && data.spocs.length) { + this.setState({ + dismissed: true, + }); + const pos = 0; + const source = this.props.type.toUpperCase(); + // Grab the available items in the array to dismiss. + // This fires a ping for all items available, even if below the fold. + const spocsData = data.spocs.map(item => ({ + url: item.url, + guid: item.id, + shim: item.shim, + flight_id: item.flightId, + })); + + const blockUrlOption = LinkMenuOptions.BlockUrls(spocsData, pos, source); + const { action, impression, userEvent } = blockUrlOption; + this.props.dispatch(action); + + this.props.dispatch( + ac.DiscoveryStreamUserEvent({ + event: userEvent, + source, + action_position: pos, + }) + ); + if (impression) { + this.props.dispatch(impression); + } + } + } + + render() { + const { data, dismissible, pocket_button_enabled } = this.props; + if ( + this.state.dismissed || + !data || + !data.spocs || + !data.spocs[0] || + // We only display complete collections. + data.spocs.length < 3 + ) { + return null; + } + const { spocs, placement, feed } = this.props; + // spocs.data is spocs state data, and not an array of spocs. + const { title, context, sponsored_by_override, sponsor } = + spocs.data[placement.name] || {}; + // Just in case of bad data, don't display a broken collection. + if (!title) { + return null; + } + + let sponsoredByMessage = ""; + + // If override is not false or an empty string. + if (sponsored_by_override || sponsored_by_override === "") { + // We specifically want to display nothing if the server returns an empty string. + // So the server can turn off the label. + // This is to support the use cases where the sponsored context is displayed elsewhere. + sponsoredByMessage = sponsored_by_override; + } else if (sponsor) { + sponsoredByMessage = { + id: `newtab-label-sponsored-by`, + values: { sponsor }, + }; + } else if (context) { + sponsoredByMessage = context; + } + + // Generally a card grid displays recs with spocs already injected. + // Normally it doesn't care which rec is a spoc and which isn't, + // it just displays content in a grid. + // For collections, we're only displaying a list of spocs. + // We don't need to tell the card grid that our list of cards are spocs, + // it shouldn't need to care. So we just pass our spocs along as recs. + // Think of it as injecting all rec positions with spocs. + // Consider maybe making recommendations in CardGrid use a more generic name. + const recsData = { + recommendations: data.spocs, + }; + + // All cards inside of a collection card grid have a slightly different type. + // For the case of interactions to the card grid, we use the type "COLLECTIONCARDGRID". + // Example, you dismiss the whole collection, we use the type "COLLECTIONCARDGRID". + // For interactions inside the card grid, example, you dismiss a single card in the collection, + // we use the type "COLLECTIONCARDGRID_CARD". + const type = `${this.props.type}_card`; + + const collectionGrid = ( + <div className="ds-collection-card-grid"> + <CardGrid + pocket_button_enabled={pocket_button_enabled} + title={title} + context={sponsoredByMessage} + data={recsData} + feed={feed} + type={type} + is_collection={true} + dispatch={this.props.dispatch} + items={this.props.items} + /> + </div> + ); + + if (dismissible) { + return ( + <DSDismiss + onDismissClick={this.onDismissClick} + extraClasses={`ds-dismiss-ds-collection`} + > + {collectionGrid} + </DSDismiss> + ); + } + return collectionGrid; + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/_CollectionCardGrid.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/_CollectionCardGrid.scss new file mode 100644 index 0000000000..f4778f3b95 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/_CollectionCardGrid.scss @@ -0,0 +1,38 @@ +.ds-dismiss.ds-dismiss-ds-collection { + .ds-dismiss-button { + margin: 15px 0 0; + inset-inline-end: 25px; + } + + &.hovering { + background: var(--newtab-element-hover-color); + } +} + +.ds-collection-card-grid { + padding: 10px 25px 25px; + margin: 0 0 20px; + + .story-footer { + display: none; + } + + .ds-header { + padding: 0 40px 0 0; + margin-bottom: 12px; + + .title { + color: var(--newtab-text-primary-color); + font-weight: 600; + font-size: 17px; + line-height: 24px; + } + + .ds-context { + color: var(--newtab-text-secondary-color); + font-weight: normal; + font-size: 13px; + line-height: 24px; + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx new file mode 100644 index 0000000000..6aef56fb33 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx @@ -0,0 +1,529 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { DSImage } from "../DSImage/DSImage.jsx"; +import { DSLinkMenu } from "../DSLinkMenu/DSLinkMenu"; +import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats"; +import React from "react"; +import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; +import { + DSContextFooter, + SponsorLabel, + DSMessageFooter, +} from "../DSContextFooter/DSContextFooter.jsx"; +import { FluentOrText } from "../../FluentOrText/FluentOrText.jsx"; +import { connect } from "react-redux"; + +const READING_WPM = 220; + +/** + * READ TIME FROM WORD COUNT + * @param {int} wordCount number of words in an article + * @returns {int} number of words per minute in minutes + */ +export function readTimeFromWordCount(wordCount) { + if (!wordCount) { + return false; + } + return Math.ceil(parseInt(wordCount, 10) / READING_WPM); +} + +export const DSSource = ({ + source, + timeToRead, + newSponsoredLabel, + context, + sponsor, + sponsored_by_override, +}) => { + // First try to display sponsored label or time to read here. + if (newSponsoredLabel) { + // If we can display something for spocs, do so. + if (sponsored_by_override || sponsor || context) { + return ( + <SponsorLabel + context={context} + sponsor={sponsor} + sponsored_by_override={sponsored_by_override} + newSponsoredLabel="new-sponsored-label" + /> + ); + } + } + + // If we are not a spoc, and can display a time to read value. + if (source && timeToRead) { + return ( + <p className="source clamp time-to-read"> + <FluentOrText + message={{ + id: `newtab-label-source-read-time`, + values: { source, timeToRead }, + }} + /> + </p> + ); + } + + // Otherwise display a default source. + return <p className="source clamp">{source}</p>; +}; + +export const DefaultMeta = ({ + source, + title, + excerpt, + timeToRead, + newSponsoredLabel, + context, + context_type, + sponsor, + sponsored_by_override, + saveToPocketCard, + isRecentSave, + ctaButtonVariant, +}) => ( + <div className="meta"> + <div className="info-wrap"> + {ctaButtonVariant !== "variant-b" && ( + <DSSource + source={source} + timeToRead={timeToRead} + newSponsoredLabel={newSponsoredLabel} + context={context} + sponsor={sponsor} + sponsored_by_override={sponsored_by_override} + /> + )} + <header title={title} className="title clamp"> + {title} + </header> + {excerpt && <p className="excerpt clamp">{excerpt}</p>} + </div> + {!newSponsoredLabel && ( + <DSContextFooter + context_type={context_type} + context={context} + sponsor={sponsor} + sponsored_by_override={sponsored_by_override} + cta_button_variant={ctaButtonVariant} + source={source} + /> + )} + {/* Sponsored label is normally in the way of any message. + newSponsoredLabel cards sponsored label is moved to just under the thumbnail, + so we can display both, so we specifically don't pass in context. */} + {newSponsoredLabel && ( + <DSMessageFooter + context_type={context_type} + context={null} + saveToPocketCard={saveToPocketCard} + /> + )} + </div> +); + +export class _DSCard extends React.PureComponent { + constructor(props) { + super(props); + + this.onLinkClick = this.onLinkClick.bind(this); + this.onSaveClick = this.onSaveClick.bind(this); + this.onMenuUpdate = this.onMenuUpdate.bind(this); + this.onMenuShow = this.onMenuShow.bind(this); + + this.setContextMenuButtonHostRef = element => { + this.contextMenuButtonHostElement = element; + }; + this.setPlaceholderRef = element => { + this.placeholderElement = element; + }; + + this.state = { + isSeen: false, + }; + + // If this is for the about:home startup cache, then we always want + // to render the DSCard, regardless of whether or not its been seen. + if (props.App.isForStartupCache) { + this.state.isSeen = true; + } + + // We want to choose the optimal thumbnail for the underlying DSImage, but + // want to do it in a performant way. The breakpoints used in the + // CSS of the page are, unfortuntely, not easy to retrieve without + // causing a style flush. To avoid that, we hardcode them here. + // + // The values chosen here were the dimensions of the card thumbnails as + // computed by getBoundingClientRect() for each type of viewport width + // across both high-density and normal-density displays. + this.dsImageSizes = [ + { + mediaMatcher: "(min-width: 1122px)", + width: 296, + height: 148, + }, + + { + mediaMatcher: "(min-width: 866px)", + width: 218, + height: 109, + }, + + { + mediaMatcher: "(max-width: 610px)", + width: 202, + height: 101, + }, + ]; + } + + onLinkClick(event) { + if (this.props.dispatch) { + this.props.dispatch( + ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source: this.props.type.toUpperCase(), + action_position: this.props.pos, + value: { + card_type: this.props.flightId ? "spoc" : "organic", + recommendation_id: this.props.recommendation_id, + tile_id: this.props.id, + ...(this.props.shim && this.props.shim.click + ? { shim: this.props.shim.click } + : {}), + }, + }) + ); + + this.props.dispatch( + ac.ImpressionStats({ + source: this.props.type.toUpperCase(), + click: 0, + window_inner_width: this.props.windowObj.innerWidth, + window_inner_height: this.props.windowObj.innerHeight, + tiles: [ + { + id: this.props.id, + pos: this.props.pos, + ...(this.props.shim && this.props.shim.click + ? { shim: this.props.shim.click } + : {}), + type: this.props.flightId ? "spoc" : "organic", + recommendation_id: this.props.recommendation_id, + }, + ], + }) + ); + } + } + + onSaveClick(event) { + if (this.props.dispatch) { + this.props.dispatch( + ac.AlsoToMain({ + type: at.SAVE_TO_POCKET, + data: { site: { url: this.props.url, title: this.props.title } }, + }) + ); + + this.props.dispatch( + ac.DiscoveryStreamUserEvent({ + event: "SAVE_TO_POCKET", + source: "CARDGRID_HOVER", + action_position: this.props.pos, + value: { + card_type: this.props.flightId ? "spoc" : "organic", + recommendation_id: this.props.recommendation_id, + tile_id: this.props.id, + ...(this.props.shim && this.props.shim.save + ? { shim: this.props.shim.save } + : {}), + }, + }) + ); + + this.props.dispatch( + ac.ImpressionStats({ + source: "CARDGRID_HOVER", + pocket: 0, + tiles: [ + { + id: this.props.id, + pos: this.props.pos, + ...(this.props.shim && this.props.shim.save + ? { shim: this.props.shim.save } + : {}), + recommendation_id: this.props.recommendation_id, + }, + ], + }) + ); + } + } + + onMenuUpdate(showContextMenu) { + if (!showContextMenu) { + const dsLinkMenuHostDiv = this.contextMenuButtonHostElement; + if (dsLinkMenuHostDiv) { + dsLinkMenuHostDiv.classList.remove("active", "last-item"); + } + } + } + + async onMenuShow() { + const dsLinkMenuHostDiv = this.contextMenuButtonHostElement; + if (dsLinkMenuHostDiv) { + // Force translation so we can be sure it's ready before measuring. + await this.props.windowObj.document.l10n.translateFragment( + dsLinkMenuHostDiv + ); + if (this.props.windowObj.scrollMaxX > 0) { + dsLinkMenuHostDiv.classList.add("last-item"); + } + dsLinkMenuHostDiv.classList.add("active"); + } + } + + onSeen(entries) { + if (this.state) { + const entry = entries.find(e => e.isIntersecting); + + if (entry) { + if (this.placeholderElement) { + this.observer.unobserve(this.placeholderElement); + } + + // Stop observing since element has been seen + this.setState({ + isSeen: true, + }); + } + } + } + + onIdleCallback() { + if (!this.state.isSeen) { + if (this.observer && this.placeholderElement) { + this.observer.unobserve(this.placeholderElement); + } + this.setState({ + isSeen: true, + }); + } + } + + componentDidMount() { + this.idleCallbackId = this.props.windowObj.requestIdleCallback( + this.onIdleCallback.bind(this) + ); + if (this.placeholderElement) { + this.observer = new IntersectionObserver(this.onSeen.bind(this)); + this.observer.observe(this.placeholderElement); + } + } + + componentWillUnmount() { + // Remove observer on unmount + if (this.observer && this.placeholderElement) { + this.observer.unobserve(this.placeholderElement); + } + if (this.idleCallbackId) { + this.props.windowObj.cancelIdleCallback(this.idleCallbackId); + } + } + + render() { + if (this.props.placeholder || !this.state.isSeen) { + return ( + <div className="ds-card placeholder" ref={this.setPlaceholderRef} /> + ); + } + + const { isRecentSave, DiscoveryStream, saveToPocketCard } = this.props; + let source = this.props.source || this.props.publisher; + if (!source) { + try { + source = new URL(this.props.url).hostname; + } catch (e) {} + } + + const { + pocketButtonEnabled, + hideDescriptions, + compactImages, + imageGradient, + newSponsoredLabel, + titleLines = 3, + descLines = 3, + readTime: displayReadTime, + } = DiscoveryStream; + + const excerpt = !hideDescriptions ? this.props.excerpt : ""; + + let timeToRead; + if (displayReadTime) { + timeToRead = + this.props.time_to_read || readTimeFromWordCount(this.props.word_count); + } + + const ctaButtonEnabled = this.props.ctaButtonSponsors?.includes( + this.props.sponsor?.toLowerCase() + ); + let ctaButtonVariant = ""; + if (ctaButtonEnabled) { + ctaButtonVariant = this.props.ctaButtonVariant; + } + let ctaButtonVariantClassName = ctaButtonVariant; + + const ctaButtonClassName = ctaButtonEnabled ? `ds-card-cta-button` : ``; + const compactImagesClassName = compactImages ? `ds-card-compact-image` : ``; + const imageGradientClassName = imageGradient + ? `ds-card-image-gradient` + : ``; + const titleLinesName = `ds-card-title-lines-${titleLines}`; + const descLinesClassName = `ds-card-desc-lines-${descLines}`; + + let stpButton = () => { + return ( + <button className="card-stp-button" onClick={this.onSaveClick}> + {this.props.context_type === "pocket" ? ( + <> + <span className="story-badge-icon icon icon-pocket" /> + <span data-l10n-id="newtab-pocket-saved" /> + </> + ) : ( + <> + <span className="story-badge-icon icon icon-pocket-save" /> + <span data-l10n-id="newtab-pocket-save" /> + </> + )} + </button> + ); + }; + + return ( + <div + className={`ds-card ${compactImagesClassName} ${imageGradientClassName} ${titleLinesName} ${descLinesClassName} ${ctaButtonClassName} ${ctaButtonVariantClassName}`} + ref={this.setContextMenuButtonHostRef} + > + <SafeAnchor + className="ds-card-link" + dispatch={this.props.dispatch} + onLinkClick={!this.props.placeholder ? this.onLinkClick : undefined} + url={this.props.url} + > + <div className="img-wrapper"> + <DSImage + extraClassNames="img" + source={this.props.image_src} + rawSource={this.props.raw_image_src} + sizes={this.dsImageSizes} + url={this.props.url} + title={this.props.title} + isRecentSave={isRecentSave} + /> + </div> + {ctaButtonVariant === "variant-b" && ( + <div className="cta-header">Shop Now</div> + )} + <DefaultMeta + source={source} + title={this.props.title} + excerpt={excerpt} + newSponsoredLabel={newSponsoredLabel} + timeToRead={timeToRead} + context={this.props.context} + context_type={this.props.context_type} + sponsor={this.props.sponsor} + sponsored_by_override={this.props.sponsored_by_override} + saveToPocketCard={saveToPocketCard} + ctaButtonVariant={ctaButtonVariant} + /> + <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 } + : {}), + recommendation_id: this.props.recommendation_id, + }, + ]} + dispatch={this.props.dispatch} + source={this.props.type} + /> + </SafeAnchor> + {saveToPocketCard && ( + <div className="card-stp-button-hover-background"> + <div className="card-stp-button-position-wrapper"> + {!this.props.flightId && stpButton()} + <DSLinkMenu + id={this.props.id} + index={this.props.pos} + dispatch={this.props.dispatch} + url={this.props.url} + title={this.props.title} + source={source} + type={this.props.type} + pocket_id={this.props.pocket_id} + shim={this.props.shim} + bookmarkGuid={this.props.bookmarkGuid} + flightId={ + !this.props.is_collection ? this.props.flightId : undefined + } + showPrivacyInfo={!!this.props.flightId} + onMenuUpdate={this.onMenuUpdate} + onMenuShow={this.onMenuShow} + saveToPocketCard={saveToPocketCard} + pocket_button_enabled={pocketButtonEnabled} + isRecentSave={isRecentSave} + /> + </div> + </div> + )} + {!saveToPocketCard && ( + <DSLinkMenu + id={this.props.id} + index={this.props.pos} + dispatch={this.props.dispatch} + url={this.props.url} + title={this.props.title} + source={source} + type={this.props.type} + pocket_id={this.props.pocket_id} + shim={this.props.shim} + bookmarkGuid={this.props.bookmarkGuid} + flightId={ + !this.props.is_collection ? this.props.flightId : undefined + } + showPrivacyInfo={!!this.props.flightId} + hostRef={this.contextMenuButtonHostRef} + onMenuUpdate={this.onMenuUpdate} + onMenuShow={this.onMenuShow} + pocket_button_enabled={pocketButtonEnabled} + isRecentSave={isRecentSave} + /> + )} + </div> + ); + } +} + +_DSCard.defaultProps = { + windowObj: window, // Added to support unit tests +}; + +export const DSCard = connect(state => ({ + App: state.App, + DiscoveryStream: state.DiscoveryStream, +}))(_DSCard); + +export const PlaceholderDSCard = props => <DSCard placeholder={true} />; diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss new file mode 100644 index 0000000000..92afedff26 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss @@ -0,0 +1,303 @@ +// Type sizes +$header-font-size: 17; +$header-line-height: 24; +$excerpt-font-size: 14; +$excerpt-line-height: 20; +$ds-card-image-gradient-fade: rgba(0, 0, 0, 0%); +$ds-card-image-gradient-solid: rgba(0, 0, 0, 100%); + +.ds-card { + display: flex; + flex-direction: column; + position: relative; + + &.placeholder { + background: transparent; + box-shadow: inset $inner-box-shadow; + border-radius: 4px; + min-height: 300px; + } + + .img-wrapper { + width: 100%; + position: relative; + } + + .card-stp-button-hover-background { + opacity: 0; + width: 100%; + position: absolute; + top: 0; + height: 0; + transition: opacity; + transition-duration: 0s; + padding-top: 50%; + pointer-events: none; + background: $black-40; + border-radius: 8px 8px 0 0; + + .card-stp-button-position-wrapper { + position: absolute; + inset-inline-end: 10px; + top: 10px; + display: flex; + justify-content: end; + align-items: center; + } + + .icon-pocket-save, + .icon-pocket { + margin-inline-end: 4px; + height: 15px; + width: 15px; + background-size: 15px; + fill: $white; + } + + .context-menu-button { + position: static; + transition: none; + border-radius: 3px; + } + + .context-menu-position-container { + position: relative; + } + + .context-menu { + margin-inline-start: 18.5px; + inset-inline-start: auto; + position: absolute; + top: 20.25px; + } + + .card-stp-button { + display: flex; + margin-inline-end: 7px; + font-weight: 400; + font-size: 13px; + line-height: 16px; + background-color: $pocket-icon-fill; + border: 0; + border-radius: 4px; + padding: 6px; + white-space: nowrap; + color: $white; + } + + button, + .context-menu { + pointer-events: auto; + } + + button { + cursor: pointer; + } + } + + &.last-item { + .card-stp-button-hover-background { + .context-menu { + margin-inline-start: auto; + margin-inline-end: 18.5px; + } + } + } + + // The active class is added when the context menu is open. + &.active, + &:focus-within, + &:hover { + .card-stp-button-hover-background { + display: block; + opacity: 1; + transition-duration: 0.3s; + + .context-menu-button { + opacity: 1; + transform: scale(1); + } + } + } + + .img { + height: 0; + padding-top: 50%; // 2:1 aspect ratio + + img { + border-radius: 4px; + box-shadow: $shadow-image-inset; + } + } + + .ds-card-link { + height: 100%; + display: flex; + flex-direction: column; + text-decoration: none; + + &:hover { + header { + color: var(--newtab-primary-action-background); + } + } + + &:focus { + @include ds-focus; + + transition: none; + + header { + color: var(--newtab-primary-action-background); + } + } + + &:active { + header { + color: var(--newtab-primary-element-active-color); + } + } + } + + .meta { + display: flex; + flex-direction: column; + padding: 12px 16px; + flex-grow: 1; + + .info-wrap { + flex-grow: 1; + } + + .title { + // show only 3 lines of copy + @include limit-visible-lines(3, $header-line-height, $header-font-size); + + font-weight: 600; + } + + .excerpt { + // show only 3 lines of copy + @include limit-visible-lines( + 3, + $excerpt-line-height, + $excerpt-font-size + ); + } + + .source { + -webkit-line-clamp: 1; + margin-bottom: 2px; + font-size: 13px; + color: var(--newtab-text-secondary-color); + + span { + display: inline-block; + } + } + + .new-sponsored-label { + font-size: 13px; + margin-bottom: 2px; + } + } + + &.ds-card-title-lines-2 .meta .title { + // show only 2 lines of copy + @include limit-visible-lines(2, $header-line-height, $header-font-size); + } + + &.ds-card-title-lines-1 .meta .title { + // show only 1 line of copy + @include limit-visible-lines(1, $header-line-height, $header-font-size); + } + + &.ds-card-desc-lines-2 .meta .excerpt { + // show only 2 lines of copy + @include limit-visible-lines(2, $excerpt-line-height, $excerpt-font-size); + } + + &.ds-card-desc-lines-1 .meta .excerpt { + // show only 1 line of copy + @include limit-visible-lines(1, $excerpt-line-height, $excerpt-font-size); + } + + &.ds-card-compact-image .img { + padding-top: 47%; + } + + &.ds-card-image-gradient { + img { + mask-image: linear-gradient(to top, $ds-card-image-gradient-fade, $ds-card-image-gradient-solid 40px); + } + + .meta { + padding: 3px 15px 11px; + } + } + + &.ds-card-cta-button.variant-a, + &.ds-card-cta-button.variant-b { + .img { + padding-top: 52.4%; + } + + .story-sponsored-label { + margin: var(--space-small) 0 0; + } + + .source { + text-decoration: underline; + } + + .story-footer { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + column-gap: var(--space-small); + margin-top: 0; + } + + .story-cta-button { + cursor: inherit; + background: var(--button-background-color); + border-radius: var(--border-radius-small); + border: none; + padding: var(--space-xsmall) 16px; + font-size: var(--font-size-small); + font-weight: var(--font-weight-bold); + min-height: var(--size-item-large); + min-width: 97px; + color: var(--newtab-text-primary-color); + margin-top: var(--space-small); + + &:hover { + background: var(--button-background-color-hover); + } + } + + .cta-header { + background: var(--button-background-color); + font-size: var(--font-size-root); + font-weight: var(--font-weight-bold); + text-align: end; + padding: var(--space-xsmall) 16px; + color: var(--newtab-text-primary-color); + min-height: var(--size-item-large); + } + } + + header { + line-height: $header-line-height * 1px; + font-size: $header-font-size * 1px; + color: var(--newtab-text-primary-color); + } + + p { + font-size: $excerpt-font-size * 1px; + line-height: $excerpt-line-height * 1px; + color: var(--newtab-text-primary-color); + margin: 0; + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter.jsx new file mode 100644 index 0000000000..5c7e79685e --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter.jsx @@ -0,0 +1,145 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { cardContextTypes } from "../../Card/types.js"; +import { CSSTransition, TransitionGroup } from "react-transition-group"; +import { FluentOrText } from "../../FluentOrText/FluentOrText.jsx"; +import React from "react"; + +// Animation time is mirrored in DSContextFooter.scss +const ANIMATION_DURATION = 3000; + +export const DSMessageLabel = props => { + const { context, context_type } = props; + const { icon, fluentID } = cardContextTypes[context_type] || {}; + + if (!context && context_type) { + return ( + <TransitionGroup component={null}> + <CSSTransition + key={fluentID} + timeout={ANIMATION_DURATION} + classNames="story-animate" + > + <StatusMessage icon={icon} fluentID={fluentID} /> + </CSSTransition> + </TransitionGroup> + ); + } + + return null; +}; + +export const StatusMessage = ({ icon, fluentID }) => ( + <div className="status-message"> + <span + aria-haspopup="true" + className={`story-badge-icon icon icon-${icon}`} + /> + <div className="story-context-label" data-l10n-id={fluentID} /> + </div> +); + +export const SponsorLabel = ({ + sponsored_by_override, + sponsor, + context, + newSponsoredLabel, +}) => { + const classList = `story-sponsored-label ${newSponsoredLabel || ""} clamp`; + // If override is not false or an empty string. + if (sponsored_by_override) { + return <p className={classList}>{sponsored_by_override}</p>; + } else if (sponsored_by_override === "") { + // We specifically want to display nothing if the server returns an empty string. + // So the server can turn off the label. + // This is to support the use cases where the sponsored context is displayed elsewhere. + return null; + } else if (sponsor) { + return ( + <p className={classList}> + <FluentOrText + message={{ + id: `newtab-label-sponsored-by`, + values: { sponsor }, + }} + /> + </p> + ); + } else if (context) { + return <p className={classList}>{context}</p>; + } + return null; +}; + +export class DSContextFooter extends React.PureComponent { + render() { + const { + context, + context_type, + sponsor, + sponsored_by_override, + cta_button_variant, + source, + } = this.props; + + const sponsorLabel = SponsorLabel({ + sponsored_by_override, + sponsor, + context, + }); + const dsMessageLabel = DSMessageLabel({ + context, + context_type, + }); + + if (cta_button_variant === "variant-a") { + return ( + <div className="story-footer"> + {/* this button is decorative only */} + <button aria-hidden="true" className="story-cta-button"> + Shop Now + </button> + {sponsorLabel} + </div> + ); + } + + if (cta_button_variant === "variant-b") { + return ( + <div className="story-footer"> + {sponsorLabel} + <span className="source clamp cta-footer-source">{source}</span> + </div> + ); + } + + if (sponsorLabel || dsMessageLabel) { + return ( + <div className="story-footer"> + {sponsorLabel} + {dsMessageLabel} + </div> + ); + } + + return null; + } +} + +export const DSMessageFooter = props => { + const { context, context_type, saveToPocketCard } = props; + + const dsMessageLabel = DSMessageLabel({ + context, + context_type, + }); + + // This case is specific and already displayed to the user elsewhere. + if (!dsMessageLabel || (saveToPocketCard && context_type === "pocket")) { + return null; + } + + return <div className="story-footer">{dsMessageLabel}</div>; +}; diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/_DSContextFooter.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/_DSContextFooter.scss new file mode 100644 index 0000000000..c23bb1c661 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/_DSContextFooter.scss @@ -0,0 +1,81 @@ +.story-footer { + color: var(--newtab-text-secondary-color); + inset-inline-start: 0; + margin-top: 12px; + position: relative; + + .story-sponsored-label span { + display: inline-block; + } + + .story-sponsored-label, + .status-message { + -webkit-line-clamp: 1; + font-size: 13px; + line-height: 24px; + color: var(--newtab-text-secondary-color); + } + + .status-message { + display: flex; + align-items: center; + height: 24px; + + .story-badge-icon { + fill: var(--newtab-text-secondary-color); + height: 16px; + margin-inline-end: 6px; + + &.icon-bookmark-removed { + background-image: url('#{$image-path}icon-removed-bookmark.svg'); + } + } + + .story-context-label { + color: var(--newtab-text-secondary-color); + flex-grow: 1; + font-size: 13px; + line-height: 24px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } +} + +.story-animate-enter { + opacity: 0; +} + +.story-animate-enter-active { + opacity: 1; + transition: opacity 150ms ease-in 300ms; + + .story-badge-icon, + .story-context-label { + animation: color 3s ease-out 0.3s; + + @keyframes color { + 0% { + color: var(--newtab-status-success); + fill: var(--newtab-status-success); + } + + 100% { + color: var(--newtab-text-secondary-color); + fill: var(--newtab-text-secondary-color); + } + } + } +} + +.story-animate-exit { + position: absolute; + top: 0; + opacity: 1; +} + +.story-animate-exit-active { + opacity: 0; + transition: opacity 250ms ease-in; +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss.jsx new file mode 100644 index 0000000000..dc01e0cc51 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss.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 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" + 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..4b4db5df33 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/_DSDismiss.scss @@ -0,0 +1,48 @@ +.ds-dismiss { + position: relative; + border-radius: 8px; + transition-duration: 250ms; + transition-property: background; + + &:hover { + .ds-dismiss-button { + opacity: 1; + } + } + + .ds-dismiss-button { + border: 0; + cursor: pointer; + height: 32px; + width: 32px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + position: absolute; + inset-inline-end: 0; + top: 0; + border-radius: 50%; + background-color: transparent; + + .icon { + @media (forced-colors: active) { + fill: CurrentColor; + } + + fill: var(--newtab-text-primary-color); + } + + &:hover { + background: var(--newtab-element-hover-color); + } + + &:active { + background: var(--newtab-element-active-color); + } + + &:focus { + box-shadow: $shadow-secondary; + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx new file mode 100644 index 0000000000..ff3886b407 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx @@ -0,0 +1,100 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import React from "react"; + +export class DSEmptyState extends React.PureComponent { + constructor(props) { + super(props); + this.onReset = this.onReset.bind(this); + this.state = {}; + } + + componentWillUnmount() { + if (this.timeout) { + clearTimeout(this.timeout); + } + } + + onReset() { + if (this.props.dispatch && this.props.feed) { + const { feed } = this.props; + const { url } = feed; + this.props.dispatch({ + type: at.DISCOVERY_STREAM_FEED_UPDATE, + data: { + feed: { + ...feed, + data: { + ...feed.data, + status: "waiting", + }, + }, + url, + }, + }); + + this.setState({ waiting: true }); + this.timeout = setTimeout(() => { + this.timeout = null; + this.setState({ + waiting: false, + }); + }, 300); + + this.props.dispatch( + ac.OnlyToMain({ type: at.DISCOVERY_STREAM_RETRY_FEED, data: { feed } }) + ); + } + } + + renderButton() { + if (this.props.status === "waiting" || this.state.waiting) { + return ( + <button + className="try-again-button waiting" + data-l10n-id="newtab-discovery-empty-section-topstories-loading" + /> + ); + } + + return ( + <button + className="try-again-button" + onClick={this.onReset} + data-l10n-id="newtab-discovery-empty-section-topstories-try-again-button" + /> + ); + } + + renderState() { + if (this.props.status === "waiting" || this.props.status === "failed") { + return ( + <React.Fragment> + <h2 data-l10n-id="newtab-discovery-empty-section-topstories-timed-out" /> + {this.renderButton()} + </React.Fragment> + ); + } + + return ( + <React.Fragment> + <h2 data-l10n-id="newtab-discovery-empty-section-topstories-header" /> + <p data-l10n-id="newtab-discovery-empty-section-topstories-content" /> + </React.Fragment> + ); + } + + render() { + return ( + <div className="section-empty-state"> + <div className="empty-state-message">{this.renderState()}</div> + </div> + ); + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/_DSEmptyState.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/_DSEmptyState.scss new file mode 100644 index 0000000000..9f9accf71b --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/_DSEmptyState.scss @@ -0,0 +1,83 @@ +.section-empty-state { + border: $border-secondary; + border-radius: 4px; + display: flex; + height: $card-height-compact; + width: 100%; + + .empty-state-message { + color: var(--newtab-text-secondary-color); + font-size: 14px; + line-height: 20px; + text-align: center; + margin: auto; + max-width: 936px; + } + + .try-again-button { + margin-top: 12px; + padding: 6px 32px; + border-radius: 2px; + border: 0; + background: var(--newtab-button-secondary-color); + color: var(--newtab-text-primary-color); + cursor: pointer; + position: relative; + transition: background 0.2s ease, color 0.2s ease; + + &:not(.waiting) { + &:focus { + @include ds-fade-in; + + @include dark-theme-only { + @include ds-fade-in($blue-40-40); + } + } + + &:hover { + @include ds-fade-in(var(--newtab-element-secondary-color)); + } + } + + &::after { + content: ''; + height: 20px; + width: 20px; + animation: spinner 1s linear infinite; + opacity: 0; + position: absolute; + top: 50%; + left: 50%; + margin: -10px 0 0 -10px; + mask-image: url('chrome://activity-stream/content/data/content/assets/spinner.svg'); + mask-size: 20px; + background: var(--newtab-text-secondary-color); + } + + &.waiting { + cursor: initial; + background: var(--newtab-element-secondary-color); + color: transparent; + transition: background 0.2s ease; + + &::after { + transition: opacity 0.2s ease; + opacity: 1; + } + } + } + + h2 { + font-size: 15px; + font-weight: 600; + margin: 0; + } + + p { + margin: 0; + } +} + +@keyframes spinner { + to { transform: rotate(360deg); } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/DSImage.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/DSImage.jsx new file mode 100644 index 0000000000..8a6cefed3a --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/DSImage.jsx @@ -0,0 +1,263 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; + +const PLACEHOLDER_IMAGE_DATA_ARRAY = [ + { + rotation: "0deg", + offsetx: "20px", + offsety: "8px", + scale: "45%", + }, + { + rotation: "54deg", + offsetx: "-26px", + offsety: "62px", + scale: "55%", + }, + { + rotation: "-30deg", + offsetx: "78px", + offsety: "30px", + scale: "68%", + }, + { + rotation: "-22deg", + offsetx: "0", + offsety: "92px", + scale: "60%", + }, + { + rotation: "-65deg", + offsetx: "66px", + offsety: "28px", + scale: "60%", + }, + { + rotation: "22deg", + offsetx: "-35px", + offsety: "62px", + scale: "52%", + }, + { + rotation: "-25deg", + offsetx: "86px", + offsety: "-15px", + scale: "68%", + }, +]; + +const PLACEHOLDER_IMAGE_COLORS_ARRAY = + "#0090ED #FF4F5F #2AC3A2 #FF7139 #A172FF #FFA437 #FF2A8A".split(" "); + +function generateIndex({ keyCode, max }) { + if (!keyCode) { + // Just grab a random index if we cannot generate an index from a key. + return Math.floor(Math.random() * max); + } + + const hashStr = str => { + let hash = 0; + for (let i = 0; i < str.length; i++) { + let charCode = str.charCodeAt(i); + hash += charCode; + } + return hash; + }; + + const hash = hashStr(keyCode); + return hash % max; +} + +export function PlaceholderImage({ urlKey, titleKey }) { + const dataIndex = generateIndex({ + keyCode: urlKey, + max: PLACEHOLDER_IMAGE_DATA_ARRAY.length, + }); + const colorIndex = generateIndex({ + keyCode: titleKey, + max: PLACEHOLDER_IMAGE_COLORS_ARRAY.length, + }); + const { rotation, offsetx, offsety, scale } = + PLACEHOLDER_IMAGE_DATA_ARRAY[dataIndex]; + const color = PLACEHOLDER_IMAGE_COLORS_ARRAY[colorIndex]; + const style = { + "--placeholderBackgroundColor": color, + "--placeholderBackgroundRotation": rotation, + "--placeholderBackgroundOffsetx": offsetx, + "--placeholderBackgroundOffsety": offsety, + "--placeholderBackgroundScale": scale, + }; + + return <div style={style} className="placeholder-image" />; +} + +export class DSImage extends React.PureComponent { + constructor(props) { + super(props); + + this.onOptimizedImageError = this.onOptimizedImageError.bind(this); + this.onNonOptimizedImageError = this.onNonOptimizedImageError.bind(this); + this.onLoad = this.onLoad.bind(this); + + this.state = { + isLoaded: false, + optimizedImageFailed: false, + useTransition: false, + }; + } + + onIdleCallback() { + if (!this.state.isLoaded) { + this.setState({ + useTransition: true, + }); + } + } + + reformatImageURL(url, width, height) { + // Change the image URL to request a size tailored for the parent container width + // Also: force JPEG, quality 60, no upscaling, no EXIF data + // Uses Thumbor: https://thumbor.readthedocs.io/en/latest/usage.html + return `https://img-getpocket.cdn.mozilla.net/${width}x${height}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/${encodeURIComponent( + url + )}`; + } + + componentDidMount() { + this.idleCallbackId = this.props.windowObj.requestIdleCallback( + this.onIdleCallback.bind(this) + ); + } + + componentWillUnmount() { + if (this.idleCallbackId) { + this.props.windowObj.cancelIdleCallback(this.idleCallbackId); + } + } + + render() { + let classNames = `ds-image + ${this.props.extraClassNames ? ` ${this.props.extraClassNames}` : ``} + ${this.state && this.state.useTransition ? ` use-transition` : ``} + ${this.state && this.state.isLoaded ? ` loaded` : ``} + `; + + let img; + + if (this.state) { + if ( + this.props.optimize && + this.props.rawSource && + !this.state.optimizedImageFailed + ) { + let baseSource = this.props.rawSource; + + let sizeRules = []; + let srcSetRules = []; + + for (let rule of this.props.sizes) { + let { mediaMatcher, width, height } = rule; + let sizeRule = `${mediaMatcher} ${width}px`; + sizeRules.push(sizeRule); + let srcSetRule = `${this.reformatImageURL( + baseSource, + width, + height + )} ${width}w`; + let srcSetRule2x = `${this.reformatImageURL( + baseSource, + width * 2, + height * 2 + )} ${width * 2}w`; + srcSetRules.push(srcSetRule); + srcSetRules.push(srcSetRule2x); + } + + if (this.props.sizes.length) { + // We have to supply a fallback in the very unlikely event that none of + // the media queries match. The smallest dimension was chosen arbitrarily. + sizeRules.push( + `${this.props.sizes[this.props.sizes.length - 1].width}px` + ); + } + + img = ( + <img + loading="lazy" + alt={this.props.alt_text} + crossOrigin="anonymous" + onLoad={this.onLoad} + onError={this.onOptimizedImageError} + sizes={sizeRules.join(",")} + src={baseSource} + srcSet={srcSetRules.join(",")} + /> + ); + } else if (this.props.source && !this.state.nonOptimizedImageFailed) { + img = ( + <img + loading="lazy" + alt={this.props.alt_text} + crossOrigin="anonymous" + onLoad={this.onLoad} + onError={this.onNonOptimizedImageError} + src={this.props.source} + /> + ); + } else { + // We consider a failed to load img or source without an image as loaded. + classNames = `${classNames} loaded`; + // Remove the img element if we have no source. Render a placeholder instead. + // This only happens for recent saves without a source. + if ( + this.props.isRecentSave && + !this.props.rawSource && + !this.props.source + ) { + img = ( + <PlaceholderImage + urlKey={this.props.url} + titleKey={this.props.title} + /> + ); + } else { + img = <div className="broken-image" />; + } + } + } + + return <picture className={classNames}>{img}</picture>; + } + + onOptimizedImageError() { + // This will trigger a re-render and the unoptimized 450px image will be used as a fallback + this.setState({ + optimizedImageFailed: true, + }); + } + + onNonOptimizedImageError() { + this.setState({ + nonOptimizedImageFailed: true, + }); + } + + onLoad() { + this.setState({ + isLoaded: true, + }); + } +} + +DSImage.defaultProps = { + source: null, // The current source style from Pocket API (always 450px) + rawSource: null, // Unadulterated image URL to filter through Thumbor + extraClassNames: null, // Additional classnames to append to component + optimize: true, // Measure parent container to request exact sizes + alt_text: null, + windowObj: window, // Added to support unit tests + sizes: [], +}; diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/_DSImage.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/_DSImage.scss new file mode 100644 index 0000000000..b11bcdcf55 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/_DSImage.scss @@ -0,0 +1,48 @@ +.ds-image { + display: block; + position: relative; + opacity: 0; + + &.use-transition { + transition: opacity 0.8s; + } + + &.loaded { + opacity: 1; + } + + img, + .placeholder-image, + .broken-image { + background-color: var(--newtab-element-secondary-color); + position: absolute; + top: 0; + width: 100%; + height: 100%; + object-fit: cover; + } + + .placeholder-image { + overflow: hidden; + background-color: var(--placeholderBackgroundColor); + + &::before { + content: ''; + background-image: url('chrome://activity-stream/content/data/content/assets/pocket-swoosh.svg'); + background-repeat: no-repeat; + background-position: center; + transform: rotate(var(--placeholderBackgroundRotation)); + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + // We use margin-left over margin-inline-start on purpose. + // This is because we are using it to offset an image's content, + // and the image content is the same in ltr and rtl. + margin-left: var(--placeholderBackgroundOffsetx); // stylelint-disable-line property-disallowed-list + margin-top: var(--placeholderBackgroundOffsety); + background-size: var(--placeholderBackgroundScale); + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx new file mode 100644 index 0000000000..b75063940c --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx @@ -0,0 +1,70 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu"; +import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton"; +import { actionCreators as ac } from "common/Actions.sys.mjs"; +import React from "react"; + +export class DSLinkMenu extends React.PureComponent { + render() { + const { index, dispatch } = this.props; + let pocketMenuOptions = []; + let TOP_STORIES_CONTEXT_MENU_OPTIONS = [ + "OpenInNewWindow", + "OpenInPrivateWindow", + ]; + if (!this.props.isRecentSave) { + if (this.props.pocket_button_enabled) { + pocketMenuOptions = this.props.saveToPocketCard + ? ["CheckDeleteFromPocket"] + : ["CheckSavedToPocket"]; + } + TOP_STORIES_CONTEXT_MENU_OPTIONS = [ + "CheckBookmark", + "CheckArchiveFromPocket", + ...pocketMenuOptions, + "Separator", + "OpenInNewWindow", + "OpenInPrivateWindow", + "Separator", + "BlockUrl", + ...(this.props.showPrivacyInfo ? ["ShowPrivacyInfo"] : []), + ]; + } + const type = this.props.type || "DISCOVERY_STREAM"; + const title = this.props.title || this.props.source; + + return ( + <div className="context-menu-position-container"> + <ContextMenuButton + tooltip={"newtab-menu-content-tooltip"} + tooltipArgs={{ title }} + onUpdate={this.props.onMenuUpdate} + > + <LinkMenu + dispatch={dispatch} + index={index} + source={type.toUpperCase()} + onShow={this.props.onMenuShow} + options={TOP_STORIES_CONTEXT_MENU_OPTIONS} + shouldSendImpressionStats={true} + userEvent={ac.DiscoveryStreamUserEvent} + site={{ + referrer: "https://getpocket.com/recommendations", + title: this.props.title, + type: this.props.type, + url: this.props.url, + guid: this.props.id, + pocket_id: this.props.pocket_id, + shim: this.props.shim, + bookmarkGuid: this.props.bookmarkGuid, + flight_id: this.props.flightId, + }} + /> + </ContextMenuButton> + </div> + ); + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/_DSLinkMenu.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/_DSLinkMenu.scss new file mode 100644 index 0000000000..e85eab11e7 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/_DSLinkMenu.scss @@ -0,0 +1,28 @@ +.ds-card, +.ds-signup { + @include context-menu-button; + + .context-menu { + opacity: 0; + } + + &.active { + .context-menu { + opacity: 1; + } + } + + &.last-item { + @include context-menu-open-left; + + .context-menu { + opacity: 1; + } + } + + &:is(:hover, :focus, .active) { + @include context-menu-button-hover; + + outline: none; + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage.jsx new file mode 100644 index 0000000000..df9ad4f641 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage.jsx @@ -0,0 +1,34 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; +import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText"; + +export class DSMessage extends React.PureComponent { + render() { + return ( + <div className="ds-message"> + <header className="title"> + {this.props.icon && ( + <div + className="glyph" + style={{ backgroundImage: `url(${this.props.icon})` }} + /> + )} + {this.props.title && ( + <span className="title-text"> + <FluentOrText message={this.props.title} /> + </span> + )} + {this.props.link_text && this.props.link_url && ( + <SafeAnchor className="link" url={this.props.link_url}> + <FluentOrText message={this.props.link_text} /> + </SafeAnchor> + )} + </header> + </div> + ); + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/_DSMessage.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/_DSMessage.scss new file mode 100644 index 0000000000..bb9666ae38 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/_DSMessage.scss @@ -0,0 +1,37 @@ +.ds-message { + margin: 8px 0 0; + + .title { + display: flex; + align-items: center; + + .glyph { + width: 16px; + height: 16px; + margin: 0 6px 0 0; + -moz-context-properties: fill; + fill: var(--newtab-text-secondary-color); + background-position: center center; + background-size: 16px; + background-repeat: no-repeat; + } + + .title-text { + line-height: 20px; + font-size: 13px; + color: var(--newtab-text-secondary-color); + font-weight: 600; + padding-right: 12px; + } + + .link { + line-height: 20px; + font-size: 13px; + + &:hover, + &:focus { + text-decoration: underline; + } + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx new file mode 100644 index 0000000000..f342c9829b --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx @@ -0,0 +1,72 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { ModalOverlayWrapper } from "content-src/components/ModalOverlay/ModalOverlay"; + +export class DSPrivacyModal extends React.PureComponent { + constructor(props) { + super(props); + this.closeModal = this.closeModal.bind(this); + this.onLearnLinkClick = this.onLearnLinkClick.bind(this); + this.onManageLinkClick = this.onManageLinkClick.bind(this); + } + + onLearnLinkClick(event) { + this.props.dispatch( + ac.DiscoveryStreamUserEvent({ + event: "CLICK_PRIVACY_INFO", + source: "DS_PRIVACY_MODAL", + }) + ); + } + + onManageLinkClick(event) { + this.props.dispatch(ac.OnlyToMain({ type: at.SETTINGS_OPEN })); + } + + closeModal() { + this.props.dispatch({ + type: `HIDE_PRIVACY_INFO`, + data: {}, + }); + } + + render() { + return ( + <ModalOverlayWrapper + onClose={this.closeModal} + innerClassName="ds-privacy-modal" + > + <div className="privacy-notice"> + <h3 data-l10n-id="newtab-privacy-modal-header" /> + <p data-l10n-id="newtab-privacy-modal-paragraph-2" /> + <a + className="modal-link modal-link-privacy" + data-l10n-id="newtab-privacy-modal-link" + onClick={this.onLearnLinkClick} + href="https://help.getpocket.com/article/1142-firefox-new-tab-recommendations-faq" + /> + <button + className="modal-link modal-link-manage" + data-l10n-id="newtab-privacy-modal-button-manage" + onClick={this.onManageLinkClick} + /> + </div> + <section className="actions"> + <button + className="done" + type="submit" + onClick={this.closeModal} + data-l10n-id="newtab-privacy-modal-button-done" + /> + </section> + </ModalOverlayWrapper> + ); + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/_DSPrivacyModal.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/_DSPrivacyModal.scss new file mode 100644 index 0000000000..2077f35709 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/_DSPrivacyModal.scss @@ -0,0 +1,48 @@ +.ds-privacy-modal { + .modal-link { + display: flex; + align-items: center; + margin: 0 0 8px; + border: 0; + padding: 0; + color: var(--newtab-primary-action-background); + width: max-content; + + &:hover { + text-decoration: underline; + cursor: pointer; + } + + &::before { + -moz-context-properties: fill; + fill: var(--newtab-primary-action-background); + content: ''; + display: inline-block; + width: 16px; + height: 16px; + margin: 0; + margin-inline-end: 8px; + background-position: center center; + background-repeat: no-repeat; + background-size: 16px; + } + + &.modal-link-privacy::before { + background-image: url('chrome://global/skin/icons/info.svg'); + } + + &.modal-link-manage::before { + background-image: url('chrome://global/skin/icons/settings.svg'); + } + } + + p { + line-height: 24px; + } + + .privacy-notice { + max-width: 572px; + padding: 40px; + margin: auto; + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.jsx new file mode 100644 index 0000000000..b7e3205646 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.jsx @@ -0,0 +1,168 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { actionCreators as ac } from "common/Actions.sys.mjs"; +import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu"; +import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton"; +import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats"; +import React from "react"; +import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; + +export class DSSignup extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + active: false, + lastItem: false, + }; + this.onMenuButtonUpdate = this.onMenuButtonUpdate.bind(this); + this.onLinkClick = this.onLinkClick.bind(this); + this.onMenuShow = this.onMenuShow.bind(this); + } + + onMenuButtonUpdate(showContextMenu) { + if (!showContextMenu) { + this.setState({ + active: false, + lastItem: false, + }); + } + } + + nextAnimationFrame() { + return new Promise(resolve => + this.props.windowObj.requestAnimationFrame(resolve) + ); + } + + async onMenuShow() { + let { lastItem } = this.state; + // Wait for next frame before computing scrollMaxX to allow fluent menu strings to be visible + await this.nextAnimationFrame(); + if (this.props.windowObj.scrollMaxX > 0) { + lastItem = true; + } + this.setState({ + active: true, + lastItem, + }); + } + + onLinkClick() { + const { data } = this.props; + if (this.props.dispatch && data && data.spocs && data.spocs.length) { + const source = this.props.type.toUpperCase(); + // Grab the first item in the array as we only have 1 spoc position. + const [spoc] = data.spocs; + this.props.dispatch( + ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source, + action_position: 0, + }) + ); + + this.props.dispatch( + ac.ImpressionStats({ + source, + click: 0, + tiles: [ + { + id: spoc.id, + pos: 0, + ...(spoc.shim && spoc.shim.click + ? { shim: spoc.shim.click } + : {}), + }, + ], + }) + ); + } + } + + render() { + const { data, dispatch, type } = this.props; + if (!data || !data.spocs || !data.spocs[0]) { + return null; + } + // Grab the first item in the array as we only have 1 spoc position. + const [spoc] = data.spocs; + const { title, url, excerpt, flight_id, id, shim } = spoc; + + const SIGNUP_CONTEXT_MENU_OPTIONS = [ + "OpenInNewWindow", + "OpenInPrivateWindow", + "Separator", + "BlockUrl", + ...(flight_id ? ["ShowPrivacyInfo"] : []), + ]; + + const outerClassName = [ + "ds-signup", + this.state.active && "active", + this.state.lastItem && "last-item", + ] + .filter(v => v) + .join(" "); + + return ( + <div className={outerClassName}> + <div className="ds-signup-content"> + <span className="icon icon-small-spacer icon-mail"></span> + <span> + {title}{" "} + <SafeAnchor + className="ds-chevron-link" + dispatch={dispatch} + onLinkClick={this.onLinkClick} + url={url} + > + {excerpt} + </SafeAnchor> + </span> + <ImpressionStats + flightId={flight_id} + rows={[ + { + id, + pos: 0, + shim: shim && shim.impression, + }, + ]} + dispatch={dispatch} + source={type} + /> + </div> + <ContextMenuButton + tooltip={"newtab-menu-content-tooltip"} + tooltipArgs={{ title }} + onUpdate={this.onMenuButtonUpdate} + > + <LinkMenu + dispatch={dispatch} + index={0} + source={type.toUpperCase()} + onShow={this.onMenuShow} + options={SIGNUP_CONTEXT_MENU_OPTIONS} + shouldSendImpressionStats={true} + userEvent={ac.DiscoveryStreamUserEvent} + site={{ + referrer: "https://getpocket.com/recommendations", + title, + type, + url, + guid: id, + shim, + flight_id, + }} + /> + </ContextMenuButton> + </div> + ); + } +} + +DSSignup.defaultProps = { + windowObj: window, // Added to support unit tests +}; diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.scss new file mode 100644 index 0000000000..dcaf0e804a --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.scss @@ -0,0 +1,52 @@ +.ds-signup { + max-width: 300px; + margin: 0 auto; + padding: 8px; + position: relative; + text-align: center; + font-size: 17px; + font-weight: 600; + + &:hover { + background: var(--newtab-element-hover-color); + border-radius: 4px; + } + + .icon-mail { + height: 40px; + width: 40px; + margin-inline-end: 8px; + fill: var(--newtab-text-secondary-color); + background-size: 30px; + flex-shrink: 0; + } + + .ds-signup-content { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + + .ds-chevron-link { + margin-top: 4px; + box-shadow: none; + display: block; + white-space: nowrap; + } + } + + @media (min-width: $break-point-large) { + min-width: 756px; + width: max-content; + text-align: start; + + .ds-signup-content { + flex-direction: row; + + .ds-chevron-link { + margin-top: 0; + display: inline; + } + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo.jsx new file mode 100644 index 0000000000..02a3326eb7 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo.jsx @@ -0,0 +1,143 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { actionCreators as ac } from "common/Actions.sys.mjs"; +import { DSDismiss } from "content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss"; +import { DSImage } from "../DSImage/DSImage.jsx"; +import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats"; +import { LinkMenuOptions } from "content-src/lib/link-menu-options"; +import React from "react"; +import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; + +export class DSTextPromo extends React.PureComponent { + constructor(props) { + super(props); + this.onLinkClick = this.onLinkClick.bind(this); + this.onDismissClick = this.onDismissClick.bind(this); + } + + onLinkClick() { + const { data } = this.props; + if (this.props.dispatch && data && data.spocs && data.spocs.length) { + const source = this.props.type.toUpperCase(); + // Grab the first item in the array as we only have 1 spoc position. + const [spoc] = data.spocs; + this.props.dispatch( + ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source, + action_position: 0, + }) + ); + + this.props.dispatch( + ac.ImpressionStats({ + source, + click: 0, + tiles: [ + { + id: spoc.id, + pos: 0, + ...(spoc.shim && spoc.shim.click + ? { shim: spoc.shim.click } + : {}), + }, + ], + }) + ); + } + } + + onDismissClick() { + const { data } = this.props; + if (this.props.dispatch && data && data.spocs && data.spocs.length) { + const index = 0; + const source = this.props.type.toUpperCase(); + // Grab the first item in the array as we only have 1 spoc position. + const [spoc] = data.spocs; + const spocData = { + url: spoc.url, + guid: spoc.id, + shim: spoc.shim, + }; + const blockUrlOption = LinkMenuOptions.BlockUrl(spocData, index, source); + + const { action, impression, userEvent } = blockUrlOption; + + this.props.dispatch(action); + this.props.dispatch( + ac.DiscoveryStreamUserEvent({ + event: userEvent, + source, + action_position: index, + }) + ); + if (impression) { + this.props.dispatch(impression); + } + } + } + + render() { + const { data } = this.props; + if (!data || !data.spocs || !data.spocs[0]) { + return null; + } + // Grab the first item in the array as we only have 1 spoc position. + const [spoc] = data.spocs; + const { + image_src, + raw_image_src, + alt_text, + title, + url, + context, + cta, + flight_id, + id, + shim, + } = spoc; + + return ( + <DSDismiss + onDismissClick={this.onDismissClick} + extraClasses={`ds-dismiss-ds-text-promo`} + > + <div className="ds-text-promo"> + <DSImage + alt_text={alt_text} + source={image_src} + rawSource={raw_image_src} + /> + <div className="text"> + <h3> + {`${title}\u2003`} + <SafeAnchor + className="ds-chevron-link" + dispatch={this.props.dispatch} + onLinkClick={this.onLinkClick} + url={url} + > + {cta} + </SafeAnchor> + </h3> + <p className="subtitle">{context}</p> + </div> + <ImpressionStats + flightId={flight_id} + rows={[ + { + id, + pos: 0, + shim: shim && shim.impression, + }, + ]} + dispatch={this.props.dispatch} + source={this.props.type} + /> + </div> + </DSDismiss> + ); + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/_DSTextPromo.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/_DSTextPromo.scss new file mode 100644 index 0000000000..b0abea1213 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/_DSTextPromo.scss @@ -0,0 +1,92 @@ +.ds-dismiss-ds-text-promo { + max-width: 744px; + margin: auto; + overflow: hidden; + + &.hovering { + background: var(--newtab-element-hover-color); + } + + .ds-dismiss-button { + margin-inline: 0 18px; + margin-block: 18px 0; + } +} + +.ds-text-promo { + max-width: 640px; + margin: 0; + padding: 18px; + + @media(min-width: $break-point-medium) { + display: flex; + margin: 18px 24px; + padding: 0 32px 0 0; + } + + .ds-image { + width: 40px; + height: 40px; + flex-shrink: 0; + margin: 0 0 18px; + + @media(min-width: $break-point-medium) { + margin: 4px 12px 0 0; + } + + img { + border-radius: 4px; + } + } + + .text { + line-height: 24px; + } + + h3 { + color: var(--newtab-text-primary-color); + margin: 0; + font-weight: 600; + font-size: 15px; + } + + .subtitle { + font-size: 13px; + margin: 0; + color: var(--newtab-text-primary-color); + } +} + +.ds-chevron-link { + color: var(--newtab-primary-action-background); + display: inline-block; + outline: 0; + + &:hover { + text-decoration: underline; + } + + &:active { + color: var(--newtab-primary-element-active-color); + + &::after { + background-color: var(--newtab-primary-element-active-color); + } + } + + &:focus { + box-shadow: $shadow-secondary; + border-radius: 2px; + } + + &::after { + background-color: var(--newtab-primary-action-background); + content: ' '; + mask: url('chrome://global/skin/icons/arrow-right-12.svg') 0 -8px no-repeat; + margin: 0 0 0 4px; + width: 5px; + height: 8px; + text-decoration: none; + display: inline-block; + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/Highlights.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/Highlights.jsx new file mode 100644 index 0000000000..d0cc87cce3 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/Highlights.jsx @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { connect } from "react-redux"; +import React from "react"; +import { SectionIntl } from "content-src/components/Sections/Sections"; + +export class _Highlights extends React.PureComponent { + render() { + const section = this.props.Sections.find(s => s.id === "highlights"); + if (!section || !section.enabled) { + return null; + } + + return ( + <div className="ds-highlights sections-list"> + <SectionIntl {...section} isFixed={true} /> + </div> + ); + } +} + +export const Highlights = connect(state => ({ Sections: state.Sections }))( + _Highlights +); diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/_Highlights.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/_Highlights.scss new file mode 100644 index 0000000000..54b39524d8 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/_Highlights.scss @@ -0,0 +1,47 @@ +/* stylelint-disable max-nesting-depth */ + +.ds-highlights { + .section { + .section-list { + grid-gap: var(--gridRowGap); + grid-template-columns: repeat(1, 1fr); + + @media (min-width: $break-point-medium) { + grid-template-columns: repeat(2, 1fr); + } + + @media (min-width: $break-point-large) { + grid-template-columns: repeat(4, 1fr); + } + + .card-outer { + $line-height: 20px; + + height: 175px; + + .card-host-name { + font-size: 13px; + line-height: $line-height; + margin-bottom: 2px; + padding-bottom: 0; + text-transform: unset; + } + + .card-title { + font-size: 14px; + font-weight: 600; + line-height: $line-height; + max-height: $line-height; + } + + a { + text-decoration: none; + } + } + } + } + + .hide-for-narrow { + display: block; + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule.jsx new file mode 100644 index 0000000000..4cdfc7594f --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule.jsx @@ -0,0 +1,11 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; + +export class HorizontalRule extends React.PureComponent { + render() { + return <hr className="ds-hr" />; + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/_HorizontalRule.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/_HorizontalRule.scss new file mode 100644 index 0000000000..aa5d6ff9f3 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/_HorizontalRule.scss @@ -0,0 +1,7 @@ +.ds-hr { + @include ds-border-top { + border: 0; + }; + + height: 0; +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/Navigation.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/Navigation.jsx new file mode 100644 index 0000000000..1062c3cade --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/Navigation.jsx @@ -0,0 +1,112 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { actionCreators as ac } from "common/Actions.sys.mjs"; +import React from "react"; +import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; +import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText"; + +export class Topic extends React.PureComponent { + constructor(props) { + super(props); + + this.onLinkClick = this.onLinkClick.bind(this); + } + + onLinkClick(event) { + if (this.props.dispatch) { + this.props.dispatch( + ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source: "POPULAR_TOPICS", + action_position: 0, + value: { + topic: event.target.text.toLowerCase().replace(` `, `-`), + }, + }) + ); + } + } + + render() { + const { url, name } = this.props; + return ( + <SafeAnchor + onLinkClick={this.onLinkClick} + className={this.props.className} + url={url} + > + {name} + </SafeAnchor> + ); + } +} + +export class Navigation extends React.PureComponent { + render() { + let links = this.props.links || []; + const alignment = this.props.alignment || "centered"; + const header = this.props.header || {}; + const english = this.props.locale.startsWith("en-"); + const privacyNotice = this.props.privacyNoticeURL || {}; + const { newFooterSection } = this.props; + const className = `ds-navigation ds-navigation-${alignment} ${ + newFooterSection ? `ds-navigation-new-topics` : `` + }`; + let { title } = header; + if (newFooterSection) { + title = { id: "newtab-pocket-new-topics-title" }; + if (this.props.extraLinks) { + links = [ + ...links.slice(0, links.length - 1), + ...this.props.extraLinks, + links[links.length - 1], + ]; + } + } + + return ( + <div className={className}> + {title && english ? ( + <FluentOrText message={title}> + <span className="ds-navigation-header" /> + </FluentOrText> + ) : null} + + {english ? ( + <ul> + {links && + links.map(t => ( + <li key={t.name}> + <Topic + url={t.url} + name={t.name} + dispatch={this.props.dispatch} + /> + </li> + ))} + </ul> + ) : null} + + {!newFooterSection ? ( + <SafeAnchor className="ds-navigation-privacy" url={privacyNotice.url}> + <FluentOrText message={privacyNotice.title} /> + </SafeAnchor> + ) : null} + + {newFooterSection ? ( + <div className="ds-navigation-family"> + <span className="icon firefox-logo" /> + <span>|</span> + <span className="icon pocket-logo" /> + <span + className="ds-navigation-family-message" + data-l10n-id="newtab-pocket-pocket-firefox-family" + /> + </div> + ) : null} + </div> + ); + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/_Navigation.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/_Navigation.scss new file mode 100644 index 0000000000..0c7a158efb --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/_Navigation.scss @@ -0,0 +1,182 @@ +/* stylelint-disable max-nesting-depth */ + +.ds-navigation { + color: var(--newtab-text-primary-color); + font-size: 11.5px; + font-weight: 500; + line-height: 22px; + padding: 4px 0; + + @media (min-width: $break-point-widest) { + line-height: 32px; + font-size: 14px; + } + + &.ds-navigation-centered { + text-align: center; + } + + &.ds-navigation-right-aligned { + text-align: end; + } + + ul { + display: inline; + margin: 0; + padding: 0; + } + + ul li { + display: inline-block; + + &::after { + content: '·'; + padding: 6px; + } + + &:last-child::after { + content: none; + } + + a { + &:hover, + &:active { + text-decoration: none; + } + + &:active { + color: var(--newtab-primary-element-active-color); + } + } + } + + .ds-navigation-header { + padding-inline-end: 6px; + } + + .ds-navigation-privacy { + padding-inline-start: 6px; + float: inline-end; + + &:hover { + text-decoration: none; + } + } + + &.ds-navigation-new-topics { + display: block; + padding-top: 32px; + + .ds-navigation-header { + font-size: 14px; + line-height: 20px; + font-weight: 700; + display: inline-block; + margin-bottom: 8px; + } + + .ds-navigation-family { + text-align: center; + font-size: 14px; + line-height: 20px; + margin: 16px auto 28px; + + span { + margin: 0 6px; + } + + .firefox-logo, + .pocket-logo { + height: 20px; + width: 20px; + background-size: cover; + } + + .firefox-logo { + background-image: url('chrome://activity-stream/content/data/content/assets/firefox.svg'); + } + + .pocket-logo { + background-image: url('chrome://global/skin/icons/pocket.svg'); + fill: $pocket-icon-fill; + } + + .ds-navigation-family-message { + font-weight: 400; + display: block; + + @media (min-width: $break-point-medium) { + display: inline; + } + } + + @media (min-width: $break-point-medium) { + margin-top: 43px; + } + } + + ul { + display: grid; + grid-gap: 0 24px; + grid-auto-flow: column; + grid-template: repeat(8, 1fr) / repeat(1, 1fr); + + li { + border-top: $border-primary; + line-height: 24px; + font-size: 13px; + font-weight: 500; + + &::after { + content: ''; + padding: 0; + } + + &:nth-last-child(2), + &:nth-last-child(3) { + display: none; + } + + &:nth-last-child(1) { + border-bottom: $border-primary; + } + } + + @media (min-width: $break-point-medium) { + grid-template: repeat(3, 1fr) / repeat(2, 1fr); + + li { + &:nth-child(3) { + border-bottom: $border-primary; + } + } + } + + @media (min-width: $break-point-large) { + grid-template: repeat(2, 1fr) / repeat(3, 1fr); + + + li { + &:nth-child(odd) { + border-bottom: 0; + } + + &:nth-child(even) { + border-bottom: $border-primary; + } + } + } + + @media (min-width: $break-point-widest) { + grid-template: repeat(2, 1fr) / repeat(4, 1fr); + + li { + &:nth-last-child(2), + &:nth-last-child(3) { + display: block; + } + } + } + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/PrivacyLink.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/PrivacyLink.jsx new file mode 100644 index 0000000000..8f7d88be85 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/PrivacyLink.jsx @@ -0,0 +1,20 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; +import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText"; + +export class PrivacyLink extends React.PureComponent { + render() { + const { properties } = this.props; + return ( + <div className="ds-privacy-link"> + <SafeAnchor url={properties.url}> + <FluentOrText message={properties.title} /> + </SafeAnchor> + </div> + ); + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/_PrivacyLink.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/_PrivacyLink.scss new file mode 100644 index 0000000000..08ce093c27 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/_PrivacyLink.scss @@ -0,0 +1,10 @@ +.ds-privacy-link { + text-align: center; + font-size: 13px; + font-weight: 500; + line-height: 24px; + + a:hover { + text-decoration: none; + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.jsx new file mode 100644 index 0000000000..cfbc6fe6cb --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.jsx @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import React from "react"; + +export class SafeAnchor extends React.PureComponent { + constructor(props) { + super(props); + this.onClick = this.onClick.bind(this); + } + + onClick(event) { + // Use dispatch instead of normal link click behavior to include referrer + if (this.props.dispatch) { + event.preventDefault(); + const { altKey, button, ctrlKey, metaKey, shiftKey } = event; + this.props.dispatch( + ac.OnlyToMain({ + type: at.OPEN_LINK, + data: { + event: { altKey, button, ctrlKey, metaKey, shiftKey }, + referrer: "https://getpocket.com/recommendations", + // Use the anchor's url, which could have been cleaned up + url: event.currentTarget.href, + }, + }) + ); + } + + // Propagate event if there's a handler + if (this.props.onLinkClick) { + this.props.onLinkClick(event); + } + } + + safeURI(url) { + let protocol = null; + try { + protocol = new URL(url).protocol; + } catch (e) { + return ""; + } + + const isAllowed = ["http:", "https:"].includes(protocol); + if (!isAllowed) { + console.warn(`${url} is not allowed for anchor targets.`); // eslint-disable-line no-console + return ""; + } + return url; + } + + render() { + const { url, className } = this.props; + return ( + <a href={this.safeURI(url)} className={className} onClick={this.onClick}> + {this.props.children} + </a> + ); + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle.jsx new file mode 100644 index 0000000000..646dc2263e --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle.jsx @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; + +export class SectionTitle extends React.PureComponent { + render() { + const { + header: { title, subtitle }, + } = this.props; + return ( + <div className="ds-section-title"> + <div className="title">{title}</div> + {subtitle ? <div className="subtitle">{subtitle}</div> : null} + </div> + ); + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/_SectionTitle.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/_SectionTitle.scss new file mode 100644 index 0000000000..453001b1b7 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/_SectionTitle.scss @@ -0,0 +1,18 @@ +.ds-section-title { + text-align: center; + margin-top: 24px; + + .title { + color: var(--newtab-text-primary-color); + line-height: 48px; + font-size: 36px; + font-weight: 300; + } + + .subtitle { + line-height: 24px; + font-size: 14px; + color: var(--newtab-text-secondary-color); + margin-top: 4px; + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/_TopSites.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/_TopSites.scss new file mode 100644 index 0000000000..4e9d6c3383 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/_TopSites.scss @@ -0,0 +1,79 @@ +/* stylelint-disable max-nesting-depth */ + +.outer-wrapper { + .ds-top-sites { + .top-sites { + .top-site-outer { + .top-site-inner > a:is(.active, :focus) .tile { + @include ds-focus; + } + + .top-site-inner > a:is(:hover) .top-site-inner { + @include ds-fade-in(var(--newtab-background-color-secondary)); + } + } + + .top-sites-list { + margin: 0 -12px; + } + } + } +} + +// Size overrides for topsites in the 2/3 view. +.ds-column-5, +.ds-column-6, +.ds-column-7, +.ds-column-8 { + .ds-top-sites { + .top-site-outer { + padding: 0 10px; + } + + .top-sites-list { + margin: 0 -10px; + } + + .top-site-inner { + --leftPanelIconWidth: 84.67px; + + .tile { + width: var(--leftPanelIconWidth); + height: var(--leftPanelIconWidth); + } + + .title { + width: var(--leftPanelIconWidth); + } + } + } +} + +// Size overrides for topsites in the 1/3 view. +.ds-column-1, +.ds-column-2, +.ds-column-3, +.ds-column-4 { + .ds-top-sites { + .top-site-outer { + padding: 0 8px; + } + + .top-sites-list { + margin: 0 -8px; + } + + .top-site-inner { + --rightPanelIconWidth: 82.67px; + + .tile { + width: var(--rightPanelIconWidth); + height: var(--rightPanelIconWidth); + } + + .title { + width: var(--rightPanelIconWidth); + } + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/TopicsWidget.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/TopicsWidget.jsx new file mode 100644 index 0000000000..1fe2343b94 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/TopicsWidget.jsx @@ -0,0 +1,125 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +import { actionCreators as ac } from "common/Actions.sys.mjs"; +import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; +import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats"; +import { connect } from "react-redux"; + +export function _TopicsWidget(props) { + const { id, source, position, DiscoveryStream, dispatch } = props; + + const { utmCampaign, utmContent, utmSource } = DiscoveryStream.experimentData; + + let queryParams = `?utm_source=${utmSource}`; + if (utmCampaign && utmContent) { + queryParams += `&utm_content=${utmContent}&utm_campaign=${utmCampaign}`; + } + + const topics = [ + { label: "Technology", name: "technology" }, + { label: "Science", name: "science" }, + { label: "Self-Improvement", name: "self-improvement" }, + { label: "Travel", name: "travel" }, + { label: "Career", name: "career" }, + { label: "Entertainment", name: "entertainment" }, + { label: "Food", name: "food" }, + { label: "Health", name: "health" }, + { + label: "Must-Reads", + name: "must-reads", + url: `https://getpocket.com/collections${queryParams}`, + }, + ]; + + function onLinkClick(topic, positionInCard) { + if (dispatch) { + dispatch( + ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source, + action_position: position, + value: { + card_type: "topics_widget", + topic, + ...(positionInCard || positionInCard === 0 + ? { position_in_card: positionInCard } + : {}), + }, + }) + ); + dispatch( + ac.ImpressionStats({ + source, + click: 0, + window_inner_width: props.windowObj.innerWidth, + window_inner_height: props.windowObj.innerHeight, + tiles: [ + { + id, + pos: position, + }, + ], + }) + ); + } + } + + function mapTopicItem(topic, index) { + return ( + <li + key={topic.name} + className={topic.overflow ? "ds-topics-widget-list-overflow-item" : ""} + > + <SafeAnchor + url={ + topic.url || + `https://getpocket.com/explore/${topic.name}${queryParams}` + } + dispatch={dispatch} + onLinkClick={() => onLinkClick(topic.name, index)} + > + {topic.label} + </SafeAnchor> + </li> + ); + } + + return ( + <div className="ds-topics-widget"> + <header className="ds-topics-widget-header">Popular Topics</header> + <hr /> + <div className="ds-topics-widget-list-container"> + <ul>{topics.map(mapTopicItem)}</ul> + </div> + <SafeAnchor + className="ds-topics-widget-button button primary" + url={`https://getpocket.com/${queryParams}`} + dispatch={dispatch} + onLinkClick={() => onLinkClick("more-topics")} + > + More Topics + </SafeAnchor> + <ImpressionStats + dispatch={dispatch} + rows={[ + { + id, + pos: position, + }, + ]} + source={source} + /> + </div> + ); +} + +_TopicsWidget.defaultProps = { + windowObj: window, // Added to support unit tests +}; + +export const TopicsWidget = connect(state => ({ + DiscoveryStream: state.DiscoveryStream, +}))(_TopicsWidget); diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/_TopicsWidget.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/_TopicsWidget.scss new file mode 100644 index 0000000000..4f8b5740e2 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/_TopicsWidget.scss @@ -0,0 +1,90 @@ +/* stylelint-disable max-nesting-depth */ + +.ds-topics-widget { + display: flex; + position: relative; + flex-direction: column; + + .ds-topics-widget-header { + font-size: 18px; + line-height: 20px; + } + + hr { + background-color: color-mix(in srgb, var(--newtab-border-color) 52%, transparent); + height: 1px; + border: 0; + margin: 10px 0 0; + } + + .ds-topics-widget-list-container { + flex-grow: 1; + + ul { + margin: 14px 0 0; + padding: 0; + display: flex; + align-items: center; + grid-gap: 10px; + flex-wrap: wrap; + + li { + display: flex; + + a { + font-size: 14px; + line-height: 16px; + text-decoration: none; + padding: 8px 15px; + background: var(--newtab-background-color-secondary); + border: 1px solid color-mix(in srgb, var(--newtab-border-color) 52%, transparent); + color: var(--newtab-text-primary-color); + border-radius: 8px; + + &:hover { + background: var(--newtab-element-hover-color); + } + + &:focus { + outline: 0; + box-shadow: 0 0 0 3px var(--newtab-primary-action-background-dimmed), 0 0 0 1px var(--newtab-primary-action-background); + transition: box-shadow 150ms; + } + } + } + + .ds-topics-widget-list-overflow-item { + display: flex; + + @media (min-width: $break-point-medium) { + display: none; + } + + @media (min-width: $break-point-widest) { + display: flex; + } + } + } + } + + .ds-topics-widget-button { + margin: 14px 0 0; + font-size: 16px; + line-height: 24px; + text-align: center; + padding: 8px; + border-radius: 4px; + background-color: var(--newtab-primary-action-background-pocket); + border: 0; + + &:hover { + background: var(--newtab-primary-element-hover-pocket-color); + } + + &:focus { + outline: 0; + box-shadow: 0 0 0 3px var(--newtab-primary-action-background-pocket-dimmed), 0 0 0 1px var(--newtab-primary-action-background-pocket); + transition: box-shadow 150ms; + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx new file mode 100644 index 0000000000..1eb4863271 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx @@ -0,0 +1,251 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { TOP_SITES_SOURCE } from "../TopSites/TopSitesConstants"; +import React from "react"; + +const VISIBLE = "visible"; +const VISIBILITY_CHANGE_EVENT = "visibilitychange"; + +// Per analytical requirement, we set the minimal intersection ratio to +// 0.5, and an impression is identified when the wrapped item has at least +// 50% visibility. +// +// This constant is exported for unit test +export const INTERSECTION_RATIO = 0.5; + +/** + * Impression wrapper for Discovery Stream related React components. + * + * It makses use of the Intersection Observer API to detect the visibility, + * and relies on page visibility to ensure the impression is reported + * only when the component is visible on the page. + * + * Note: + * * This wrapper used to be used either at the individual card level, + * or by the card container components. + * It is now only used for individual card level. + * * Each impression will be sent only once as soon as the desired + * visibility is detected + * * Batching is not yet implemented, hence it might send multiple + * impression pings separately + */ +export class ImpressionStats extends React.PureComponent { + // This checks if the given cards are the same as those in the last impression ping. + // If so, it should not send the same impression ping again. + _needsImpressionStats(cards) { + if ( + !this.impressionCardGuids || + this.impressionCardGuids.length !== cards.length + ) { + return true; + } + + for (let i = 0; i < cards.length; i++) { + if (cards[i].id !== this.impressionCardGuids[i]) { + return true; + } + } + + return false; + } + + _dispatchImpressionStats() { + const { props } = this; + const cards = props.rows; + + if (this.props.flightId) { + this.props.dispatch( + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_SPOC_IMPRESSION, + data: { flightId: this.props.flightId }, + }) + ); + + // Record sponsored topsites impressions if the source is `TOP_SITES_SOURCE`. + if (this.props.source === TOP_SITES_SOURCE) { + for (const card of cards) { + this.props.dispatch( + ac.OnlyToMain({ + type: at.TOP_SITES_SPONSORED_IMPRESSION_STATS, + data: { + type: "impression", + tile_id: card.id, + source: "newtab", + advertiser: card.advertiser, + // Keep the 0-based position, can be adjusted by the telemetry + // sender if necessary. + position: card.pos, + }, + }) + ); + } + } + } + + if (this._needsImpressionStats(cards)) { + props.dispatch( + ac.DiscoveryStreamImpressionStats({ + source: props.source.toUpperCase(), + window_inner_width: window.innerWidth, + window_inner_height: window.innerHeight, + tiles: cards.map(link => ({ + id: link.id, + pos: link.pos, + type: this.props.flightId ? "spoc" : "organic", + ...(link.shim ? { shim: link.shim } : {}), + recommendation_id: link.recommendation_id, + })), + }) + ); + this.impressionCardGuids = cards.map(link => link.id); + } + } + + // This checks if the given cards are the same as those in the last loaded content ping. + // If so, it should not send the same loaded content ping again. + _needsLoadedContent(cards) { + if ( + !this.loadedContentGuids || + this.loadedContentGuids.length !== cards.length + ) { + return true; + } + + for (let i = 0; i < cards.length; i++) { + if (cards[i].id !== this.loadedContentGuids[i]) { + return true; + } + } + + return false; + } + + _dispatchLoadedContent() { + const { props } = this; + const cards = props.rows; + + if (this._needsLoadedContent(cards)) { + props.dispatch( + ac.DiscoveryStreamLoadedContent({ + source: props.source.toUpperCase(), + tiles: cards.map(link => ({ id: link.id, pos: link.pos })), + }) + ); + this.loadedContentGuids = cards.map(link => link.id); + } + } + + setImpressionObserverOrAddListener() { + const { props } = this; + + if (!props.dispatch) { + return; + } + + if (props.document.visibilityState === VISIBLE) { + // Send the loaded content ping once the page is visible. + this._dispatchLoadedContent(); + this.setImpressionObserver(); + } else { + // We should only ever send the latest impression stats ping, so remove any + // older listeners. + if (this._onVisibilityChange) { + props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + + this._onVisibilityChange = () => { + if (props.document.visibilityState === VISIBLE) { + // Send the loaded content ping once the page is visible. + this._dispatchLoadedContent(); + this.setImpressionObserver(); + props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + }; + props.document.addEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + /** + * Set an impression observer for the wrapped component. It makes use of + * the Intersection Observer API to detect if the wrapped component is + * visible with a desired ratio, and only sends impression if that's the case. + * + * See more details about Intersection Observer API at: + * https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API + */ + setImpressionObserver() { + const { props } = this; + + if (!props.rows.length) { + return; + } + + this._handleIntersect = entries => { + if ( + entries.some( + entry => + entry.isIntersecting && + entry.intersectionRatio >= INTERSECTION_RATIO + ) + ) { + this._dispatchImpressionStats(); + this.impressionObserver.unobserve(this.refs.impression); + } + }; + + const options = { threshold: INTERSECTION_RATIO }; + this.impressionObserver = new props.IntersectionObserver( + this._handleIntersect, + options + ); + this.impressionObserver.observe(this.refs.impression); + } + + componentDidMount() { + if (this.props.rows.length) { + this.setImpressionObserverOrAddListener(); + } + } + + componentWillUnmount() { + if (this._handleIntersect && this.impressionObserver) { + this.impressionObserver.unobserve(this.refs.impression); + } + if (this._onVisibilityChange) { + this.props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + render() { + return ( + <div ref={"impression"} className="impression-observer"> + {this.props.children} + </div> + ); + } +} + +ImpressionStats.defaultProps = { + IntersectionObserver: global.IntersectionObserver, + document: global.document, + rows: [], + source: "", +}; diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/_ImpressionStats.scss b/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/_ImpressionStats.scss new file mode 100644 index 0000000000..943e4e34a9 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/_ImpressionStats.scss @@ -0,0 +1,7 @@ +.impression-observer { + position: absolute; + top: 0; + width: 100%; + height: 100%; + pointer-events: none; +} diff --git a/browser/components/newtab/content-src/components/ErrorBoundary/ErrorBoundary.jsx b/browser/components/newtab/content-src/components/ErrorBoundary/ErrorBoundary.jsx new file mode 100644 index 0000000000..1834a0a521 --- /dev/null +++ b/browser/components/newtab/content-src/components/ErrorBoundary/ErrorBoundary.jsx @@ -0,0 +1,68 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { A11yLinkButton } from "content-src/components/A11yLinkButton/A11yLinkButton"; +import React from "react"; + +export class ErrorBoundaryFallback extends React.PureComponent { + constructor(props) { + super(props); + this.windowObj = this.props.windowObj || window; + this.onClick = this.onClick.bind(this); + } + + /** + * Since we only get here if part of the page has crashed, do a + * forced reload to give us the best chance at recovering. + */ + onClick() { + this.windowObj.location.reload(true); + } + + render() { + const defaultClass = "as-error-fallback"; + let className; + if ("className" in this.props) { + className = `${this.props.className} ${defaultClass}`; + } else { + className = defaultClass; + } + + // "A11yLinkButton" to force normal link styling stuff (eg cursor on hover) + return ( + <div className={className}> + <div data-l10n-id="newtab-error-fallback-info" /> + <span> + <A11yLinkButton + className="reload-button" + onClick={this.onClick} + data-l10n-id="newtab-error-fallback-refresh-link" + /> + </span> + </div> + ); + } +} +ErrorBoundaryFallback.defaultProps = { className: "as-error-fallback" }; + +export class ErrorBoundary extends React.PureComponent { + constructor(props) { + super(props); + this.state = { hasError: false }; + } + + componentDidCatch(error, info) { + this.setState({ hasError: true }); + } + + render() { + if (!this.state.hasError) { + return this.props.children; + } + + return <this.props.FallbackComponent className={this.props.className} />; + } +} + +ErrorBoundary.defaultProps = { FallbackComponent: ErrorBoundaryFallback }; diff --git a/browser/components/newtab/content-src/components/ErrorBoundary/_ErrorBoundary.scss b/browser/components/newtab/content-src/components/ErrorBoundary/_ErrorBoundary.scss new file mode 100644 index 0000000000..cc54f78a27 --- /dev/null +++ b/browser/components/newtab/content-src/components/ErrorBoundary/_ErrorBoundary.scss @@ -0,0 +1,21 @@ +.as-error-fallback { + align-items: center; + border-radius: $border-radius; + box-shadow: inset $inner-box-shadow; + color: var(--newtab-text-secondary-color); + display: flex; + flex-direction: column; + font-size: $error-fallback-font-size; + justify-content: center; + justify-items: center; + line-height: $error-fallback-line-height; + + &.borderless-error { + box-shadow: none; + } + + a { + color: var(--newtab-text-secondary-color); + text-decoration: underline; + } +} diff --git a/browser/components/newtab/content-src/components/FluentOrText/FluentOrText.jsx b/browser/components/newtab/content-src/components/FluentOrText/FluentOrText.jsx new file mode 100644 index 0000000000..583a5e4a01 --- /dev/null +++ b/browser/components/newtab/content-src/components/FluentOrText/FluentOrText.jsx @@ -0,0 +1,36 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; + +/** + * Set text on a child element/component depending on if the message is already + * translated plain text or a fluent id with optional args. + */ +export class FluentOrText extends React.PureComponent { + render() { + // Ensure we have a single child to attach attributes + const { children, message } = this.props; + const child = children ? React.Children.only(children) : <span />; + + // For a string message, just use it as the child's text + let grandChildren = message; + let extraProps; + + // Convert a message object to set desired fluent-dom attributes + if (typeof message === "object") { + const args = message.args || message.values; + extraProps = { + "data-l10n-args": args && JSON.stringify(args), + "data-l10n-id": message.id || message.string_id, + }; + + // Use original children potentially with data-l10n-name attributes + grandChildren = child.props.children; + } + + // Add the message to the child via fluent attributes or text node + return React.cloneElement(child, extraProps, grandChildren); + } +} diff --git a/browser/components/newtab/content-src/components/LinkMenu/LinkMenu.jsx b/browser/components/newtab/content-src/components/LinkMenu/LinkMenu.jsx new file mode 100644 index 0000000000..650a03eb95 --- /dev/null +++ b/browser/components/newtab/content-src/components/LinkMenu/LinkMenu.jsx @@ -0,0 +1,110 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { actionCreators as ac } from "common/Actions.sys.mjs"; +import { connect } from "react-redux"; +import { ContextMenu } from "content-src/components/ContextMenu/ContextMenu"; +import { LinkMenuOptions } from "content-src/lib/link-menu-options"; +import React from "react"; + +const DEFAULT_SITE_MENU_OPTIONS = [ + "CheckPinTopSite", + "EditTopSite", + "Separator", + "OpenInNewWindow", + "OpenInPrivateWindow", + "Separator", + "BlockUrl", +]; + +export class _LinkMenu extends React.PureComponent { + getOptions() { + const { props } = this; + const { + site, + index, + source, + isPrivateBrowsingEnabled, + siteInfo, + platform, + userEvent = ac.UserEvent, + } = props; + + // Handle special case of default site + const propOptions = + site.isDefault && !site.searchTopSite && !site.sponsored_position + ? DEFAULT_SITE_MENU_OPTIONS + : props.options; + + const options = propOptions + .map(o => + LinkMenuOptions[o]( + site, + index, + source, + isPrivateBrowsingEnabled, + siteInfo, + platform + ) + ) + .map(option => { + const { action, impression, id, type, userEvent: eventName } = option; + if (!type && id) { + option.onClick = (event = {}) => { + const { ctrlKey, metaKey, shiftKey, button } = event; + // Only send along event info if there's something non-default to send + if (ctrlKey || metaKey || shiftKey || button === 1) { + action.data = Object.assign( + { + event: { ctrlKey, metaKey, shiftKey, button }, + }, + action.data + ); + } + props.dispatch(action); + if (eventName) { + const userEventData = Object.assign( + { + event: eventName, + source, + action_position: index, + value: { card_type: site.flight_id ? "spoc" : "organic" }, + }, + siteInfo + ); + props.dispatch(userEvent(userEventData)); + } + if (impression && props.shouldSendImpressionStats) { + props.dispatch(impression); + } + }; + } + return option; + }); + + // This is for accessibility to support making each item tabbable. + // We want to know which item is the first and which item + // is the last, so we can close the context menu accordingly. + options[0].first = true; + options[options.length - 1].last = true; + return options; + } + + render() { + return ( + <ContextMenu + onUpdate={this.props.onUpdate} + onShow={this.props.onShow} + options={this.getOptions()} + keyboardAccess={this.props.keyboardAccess} + /> + ); + } +} + +const getState = state => ({ + isPrivateBrowsingEnabled: state.Prefs.values.isPrivateBrowsingEnabled, + platform: state.Prefs.values.platform, +}); +export const LinkMenu = connect(getState)(_LinkMenu); diff --git a/browser/components/newtab/content-src/components/ModalOverlay/ModalOverlay.jsx b/browser/components/newtab/content-src/components/ModalOverlay/ModalOverlay.jsx new file mode 100644 index 0000000000..fdfdf22db2 --- /dev/null +++ b/browser/components/newtab/content-src/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/components/ModalOverlay/_ModalOverlay.scss b/browser/components/newtab/content-src/components/ModalOverlay/_ModalOverlay.scss new file mode 100644 index 0000000000..3bc2dffca0 --- /dev/null +++ b/browser/components/newtab/content-src/components/ModalOverlay/_ModalOverlay.scss @@ -0,0 +1,103 @@ +// Variable for the about:welcome modal scrollbars +$modal-scrollbar-z-index: 1100; + +.activity-stream { + &.modal-open { + overflow: hidden; + } +} + +.modalOverlayOuter { + background: var(--newtab-overlay-color); + height: 100%; + position: fixed; + top: 0; + left: 0; + width: 100%; + display: none; + z-index: $modal-scrollbar-z-index; + overflow: auto; + + &.active { + display: flex; + } +} + +.modalOverlayInner { + min-width: min-content; + width: 100%; + max-width: 960px; + position: relative; + margin: auto; + background: var(--newtab-background-color-secondary); + box-shadow: $shadow-large; + border-radius: 4px; + display: none; + z-index: $modal-scrollbar-z-index; + + // modal takes over entire screen + @media(width <= 960px) { + height: 100%; + top: 0; + left: 0; + box-shadow: none; + border-radius: 0; + } + + &.active { + display: block; + } + + h2 { + color: var(--newtab-text-primary-color); + text-align: center; + font-weight: 200; + margin-top: 30px; + font-size: 28px; + line-height: 37px; + letter-spacing: -0.13px; + + @media(width <= 960px) { + margin-top: 100px; + } + + @media(width <= 850px) { + margin-top: 30px; + } + } + + .footer { + border-top: $border-secondary; + border-radius: 4px; + height: 70px; + width: 100%; + position: absolute; + bottom: 0; + text-align: center; + background-color: $white; + + // if modal is short enough, footer becomes sticky + @media(width <= 850px) and (height <= 730px) { + position: sticky; + } + + // if modal is narrow enough, footer becomes sticky + @media(width <= 650px) and (height <= 600px) { + position: sticky; + } + + .modalButton { + margin-top: 20px; + min-width: 150px; + height: 30px; + padding: 4px 30px 6px; + font-size: 15px; + + &:focus, + &.active, + &:hover { + @include fade-in-card; + } + } + } +} diff --git a/browser/components/newtab/content-src/components/MoreRecommendations/MoreRecommendations.jsx b/browser/components/newtab/content-src/components/MoreRecommendations/MoreRecommendations.jsx new file mode 100644 index 0000000000..f2c332e5bd --- /dev/null +++ b/browser/components/newtab/content-src/components/MoreRecommendations/MoreRecommendations.jsx @@ -0,0 +1,21 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; + +export class MoreRecommendations extends React.PureComponent { + render() { + const { read_more_endpoint } = this.props; + if (read_more_endpoint) { + return ( + <a + className="more-recommendations" + href={read_more_endpoint} + data-l10n-id="newtab-pocket-more-recommendations" + /> + ); + } + return null; + } +} diff --git a/browser/components/newtab/content-src/components/MoreRecommendations/_MoreRecommendations.scss b/browser/components/newtab/content-src/components/MoreRecommendations/_MoreRecommendations.scss new file mode 100644 index 0000000000..12a66b7c11 --- /dev/null +++ b/browser/components/newtab/content-src/components/MoreRecommendations/_MoreRecommendations.scss @@ -0,0 +1,24 @@ +@use 'sass:math'; + +.more-recommendations { + display: flex; + align-items: center; + white-space: nowrap; + line-height: math.div(16, 13); // (16 / 13) -> 16px computed + + &::after { + background: url('chrome://global/skin/icons/arrow-right-12.svg') no-repeat center center; + content: ''; + -moz-context-properties: fill; + display: inline-block; + fill: var(--newtab-primary-action-background); + height: 16px; + margin-inline-start: 5px; + vertical-align: top; + width: 12px; + } + + &:dir(rtl)::after { + background-image: url('chrome://global/skin/icons/arrow-left-12.svg'); + } +} diff --git a/browser/components/newtab/content-src/components/PocketLoggedInCta/PocketLoggedInCta.jsx b/browser/components/newtab/content-src/components/PocketLoggedInCta/PocketLoggedInCta.jsx new file mode 100644 index 0000000000..53c22f319c --- /dev/null +++ b/browser/components/newtab/content-src/components/PocketLoggedInCta/PocketLoggedInCta.jsx @@ -0,0 +1,42 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { connect } from "react-redux"; +import React from "react"; + +export class _PocketLoggedInCta extends React.PureComponent { + render() { + const { pocketCta } = this.props.Pocket; + return ( + <span className="pocket-logged-in-cta"> + <a + className="pocket-cta-button" + href={pocketCta.ctaUrl ? pocketCta.ctaUrl : "https://getpocket.com/"} + > + {pocketCta.ctaButton ? ( + pocketCta.ctaButton + ) : ( + <span data-l10n-id="newtab-pocket-cta-button" /> + )} + </a> + + <a + href={pocketCta.ctaUrl ? pocketCta.ctaUrl : "https://getpocket.com/"} + > + <span className="cta-text"> + {pocketCta.ctaText ? ( + pocketCta.ctaText + ) : ( + <span data-l10n-id="newtab-pocket-cta-text" /> + )} + </span> + </a> + </span> + ); + } +} + +export const PocketLoggedInCta = connect(state => ({ Pocket: state.Pocket }))( + _PocketLoggedInCta +); diff --git a/browser/components/newtab/content-src/components/PocketLoggedInCta/_PocketLoggedInCta.scss b/browser/components/newtab/content-src/components/PocketLoggedInCta/_PocketLoggedInCta.scss new file mode 100644 index 0000000000..e1eed58e9c --- /dev/null +++ b/browser/components/newtab/content-src/components/PocketLoggedInCta/_PocketLoggedInCta.scss @@ -0,0 +1,42 @@ +@use 'sass:math'; + +.pocket-logged-in-cta { + $max-button-width: 130px; + $min-button-height: 18px; + + font-size: 13px; + margin-inline-end: 20px; + display: flex; + align-items: flex-start; + + .pocket-cta-button { + white-space: nowrap; + background: var(--newtab-primary-action-background); + letter-spacing: -0.34px; + color: $white; + border-radius: 4px; + cursor: pointer; + max-width: $max-button-width; + // The button height is 2px taller than the rest of the cta text. + // So I move it up by 1px to align with the rest of the cta text. + margin-top: -1px; + min-height: $min-button-height; + padding: 0 8px; + display: inline-flex; + justify-content: center; + align-items: center; + font-size: 11px; + margin-inline-end: 10px; + } + + .cta-text { + font-weight: normal; + font-size: 13px; + line-height: math.div(16, 13); // (16 / 13) -> 16px computed + } + + .pocket-cta-button, + .cta-text { + vertical-align: top; + } +} diff --git a/browser/components/newtab/content-src/components/Search/Search.jsx b/browser/components/newtab/content-src/components/Search/Search.jsx new file mode 100644 index 0000000000..64308963c9 --- /dev/null +++ b/browser/components/newtab/content-src/components/Search/Search.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/. */ + +/* globals ContentSearchUIController, ContentSearchHandoffUIController */ + +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { connect } from "react-redux"; +import { IS_NEWTAB } from "content-src/lib/constants"; +import React from "react"; + +export class _Search extends React.PureComponent { + constructor(props) { + super(props); + this.onSearchClick = this.onSearchClick.bind(this); + this.onSearchHandoffClick = this.onSearchHandoffClick.bind(this); + this.onSearchHandoffPaste = this.onSearchHandoffPaste.bind(this); + this.onSearchHandoffDrop = this.onSearchHandoffDrop.bind(this); + this.onInputMount = this.onInputMount.bind(this); + this.onInputMountHandoff = this.onInputMountHandoff.bind(this); + this.onSearchHandoffButtonMount = + this.onSearchHandoffButtonMount.bind(this); + } + + handleEvent(event) { + // Also track search events with our own telemetry + if (event.detail.type === "Search") { + this.props.dispatch(ac.UserEvent({ event: "SEARCH" })); + } + } + + onSearchClick(event) { + window.gContentSearchController.search(event); + } + + doSearchHandoff(text) { + this.props.dispatch( + ac.OnlyToMain({ type: at.HANDOFF_SEARCH_TO_AWESOMEBAR, data: { text } }) + ); + this.props.dispatch({ type: at.FAKE_FOCUS_SEARCH }); + this.props.dispatch(ac.UserEvent({ event: "SEARCH_HANDOFF" })); + if (text) { + this.props.dispatch({ type: at.DISABLE_SEARCH }); + } + } + + onSearchHandoffClick(event) { + // When search hand-off is enabled, we render a big button that is styled to + // look like a search textbox. If the button is clicked, we style + // the button as if it was a focused search box and show a fake cursor but + // really focus the awesomebar without the focus styles ("hidden focus"). + event.preventDefault(); + this.doSearchHandoff(); + } + + onSearchHandoffPaste(event) { + event.preventDefault(); + this.doSearchHandoff(event.clipboardData.getData("Text")); + } + + onSearchHandoffDrop(event) { + event.preventDefault(); + let text = event.dataTransfer.getData("text"); + if (text) { + this.doSearchHandoff(text); + } + } + + componentWillUnmount() { + delete window.gContentSearchController; + } + + onInputMount(input) { + if (input) { + // The "healthReportKey" and needs to be "newtab" or "abouthome" so that + // BrowserUsageTelemetry.sys.mjs knows to handle events with this name, and + // can add the appropriate telemetry probes for search. Without the correct + // name, certain tests like browser_UsageTelemetry_content.js will fail + // (See github ticket #2348 for more details) + const healthReportKey = IS_NEWTAB ? "newtab" : "abouthome"; + + // The "searchSource" needs to be "newtab" or "homepage" and is sent with + // the search data and acts as context for the search request (See + // nsISearchEngine.getSubmission). It is necessary so that search engine + // plugins can correctly atribute referrals. (See github ticket #3321 for + // more details) + const searchSource = IS_NEWTAB ? "newtab" : "homepage"; + + // gContentSearchController needs to exist as a global so that tests for + // the existing about:home can find it; and so it allows these tests to pass. + // In the future, when activity stream is default about:home, this can be renamed + window.gContentSearchController = new ContentSearchUIController( + input, + input.parentNode, + healthReportKey, + searchSource + ); + addEventListener("ContentSearchClient", this); + } else { + window.gContentSearchController = null; + removeEventListener("ContentSearchClient", this); + } + } + + onInputMountHandoff(input) { + if (input) { + // The handoff UI controller helps us set the search icon and reacts to + // changes to default engine to keep everything in sync. + this._handoffSearchController = new ContentSearchHandoffUIController(); + } + } + + onSearchHandoffButtonMount(button) { + // Keep a reference to the button for use during "paste" event handling. + this._searchHandoffButton = button; + } + + /* + * Do not change the ID on the input field, as legacy newtab code + * specifically looks for the id 'newtab-search-text' on input fields + * in order to execute searches in various tests + */ + render() { + const wrapperClassName = [ + "search-wrapper", + this.props.disable && "search-disabled", + this.props.fakeFocus && "fake-focus", + ] + .filter(v => v) + .join(" "); + + return ( + <div className={wrapperClassName}> + {this.props.showLogo && ( + <div className="logo-and-wordmark"> + <div className="logo" /> + <div className="wordmark" /> + </div> + )} + {!this.props.handoffEnabled && ( + <div className="search-inner-wrapper"> + <input + id="newtab-search-text" + data-l10n-id="newtab-search-box-input" + maxLength="256" + ref={this.onInputMount} + type="search" + /> + <button + id="searchSubmit" + className="search-button" + data-l10n-id="newtab-search-box-search-button" + onClick={this.onSearchClick} + /> + </div> + )} + {this.props.handoffEnabled && ( + <div className="search-inner-wrapper"> + <button + className="search-handoff-button" + ref={this.onSearchHandoffButtonMount} + onClick={this.onSearchHandoffClick} + tabIndex="-1" + > + <div className="fake-textbox" /> + <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..a9af0ab1e0 --- /dev/null +++ b/browser/components/newtab/content-src/components/Search/_Search.scss @@ -0,0 +1,394 @@ +$search-height: 48px; +$search-height-new: 52px; +$search-icon-size: 24px; +$search-icon-padding: 16px; +$search-icon-width: 2 * $search-icon-padding + $search-icon-size - 4px; +$search-button-width: 48px; +$glyph-forward: url('chrome://browser/skin/forward.svg'); + +.search-wrapper { + padding: 34px 0 38px; + + .only-search & { + padding: 0 0 38px; + } + + .logo-and-wordmark { + $logo-size: 82px; + $wordmark-size: 134px; + + align-items: center; + display: flex; + justify-content: center; + margin-bottom: 48px; + + .logo { + display: inline-block; + height: $logo-size; + width: $logo-size; + background: image-set(url('chrome://branding/content/about-logo.png'), url('chrome://branding/content/about-logo@2x.png') 2x) no-repeat center; + background-size: $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-wordmark-color); + height: $logo-size; + margin-inline-start: 16px; + width: $wordmark-size; + } + + @media (max-width: $break-point-medium - 1) { + $logo-size-small: 64px; + $wordmark-small-size: 100px; + + .logo { + background-size: $logo-size-small; + height: $logo-size-small; + width: $logo-size-small; + } + + .wordmark { + background-size: $wordmark-small-size; + height: $logo-size-small; + width: $wordmark-small-size; + margin-inline-start: 12px; + } + } + } + + .search-inner-wrapper { + cursor: default; + display: flex; + min-height: $search-height-new; + margin: 0 auto; + position: relative; + width: $searchbar-width-small; + + @media (min-width: $break-point-medium) { + width: $searchbar-width-medium; + } + + @media (min-width: $break-point-large) { + width: $searchbar-width-large; + } + + @media (min-width: $break-point-widest) { + width: $searchbar-width-largest; + } + } + + .search-handoff-button, + input { + background: var(--newtab-background-color-secondary) var(--newtab-search-icon) $search-icon-padding center no-repeat; + background-size: $search-icon-size; + padding-inline-start: $search-icon-width; + padding-inline-end: 10px; + padding-block: 0; + width: 100%; + box-shadow: $shadow-card; + border: 1px solid transparent; + border-radius: 8px; + color: var(--newtab-text-primary-color); + -moz-context-properties: fill; + fill: var(--newtab-text-secondary-color); + + &:dir(rtl) { + background-position-x: right $search-icon-padding; + } + } + + .search-inner-wrapper:active input, + input:focus { + border: 1px solid var(--newtab-primary-action-background); + outline: 0; + box-shadow: $shadow-focus; + } + + .search-button { + background: $glyph-forward no-repeat center center; + background-size: 16px 16px; + border: 0; + border-radius: 0 $border-radius $border-radius 0; + -moz-context-properties: fill; + fill: var(--newtab-text-secondary-color); + height: 100%; + inset-inline-end: 0; + position: absolute; + width: $search-button-width; + + &:focus, + &:hover { + background-color: var(--newtab-element-hover-color); + cursor: pointer; + } + + &:focus { + outline: 0; + box-shadow: $shadow-focus; + border: 1px solid var(--newtab-primary-action-background); + border-radius: 0 $border-radius-new $border-radius-new 0; + } + + &:active { + background-color: var(--newtab-element-hover-color); + } + + &:dir(rtl) { + transform: scaleX(-1); + } + } + + &.fake-focus:not(.search.disabled) { + .search-handoff-button { + border: 1px solid var(--newtab-primary-action-background); + box-shadow: $shadow-focus; + } + } + + .search-handoff-button { + padding-inline-end: 15px; + color: var(--newtab-text-primary-color); + fill: var(--newtab-text-secondary-color); + -moz-context-properties: fill; + + .fake-caret { + top: 18px; + inset-inline-start: $search-icon-width; + + &:dir(rtl) { + background-position-x: right $search-icon-padding; + } + } + } + + &.visible-logo { + .logo-and-wordmark { + .wordmark { + fill: var(--newtab-wordmark-color); + } + } + } +} + +@media (height <= 700px) { + .search-wrapper { + padding: 0 0 30px; + } +} + +.search-handoff-button { + background: var(--newtab-background-color-secondary) var(--newtab-search-icon) $search-icon-padding center no-repeat; + background-size: $search-icon-size; + border: solid 1px transparent; + border-radius: 3px; + box-shadow: $shadow-secondary, 0 0 0 1px $black-15; + cursor: text; + font-size: 15px; + padding: 0; + padding-inline-end: 48px; + padding-inline-start: 46px; + opacity: 1; + width: 100%; + + &:dir(rtl) { + background-position-x: right $search-icon-padding; + } + + .fake-focus:not(.search-disabled) & { + border: $input-border-active; + box-shadow: var(--newtab-textbox-focus-boxshadow); + + .fake-caret { + display: block; + } + } + + .search-disabled & { + opacity: 0.5; + box-shadow: none; + } + + .fake-editable:focus { + outline: none; + caret-color: transparent; + } + + .fake-editable { + color: transparent; + height: 100%; + opacity: 0; + position: absolute; + inset: 0; + } + + .fake-textbox { + opacity: 0.54; + text-align: start; + } + + .fake-caret { + animation: caret-animation 1.3s steps(5, start) infinite; + background: var(--newtab-text-primary-color); + display: none; + inset-inline-start: 47px; + height: 17px; + position: absolute; + top: 16px; + width: 1px; + + @keyframes caret-animation { + to { + visibility: hidden; + } + } + } +} + +@media (height > 700px) { + body:not(.inline-onboarding) .fixed-search { + main { + padding-top: 124px; + } + + &.visible-logo { + main { + padding-top: 254px; + } + } + + .search-wrapper { + $search-height: 45px; + $search-icon-size: 24px; + $search-icon-padding: 16px; + $search-header-bar-height: 95px; + + border-bottom: solid 1px var(--newtab-border-color); + padding: 27px 0; + background-color: var(--newtab-overlay-color); + min-height: $search-header-bar-height; + left: 0; + position: fixed; + top: 0; + width: 100%; + z-index: 9; + + .search-inner-wrapper { + min-height: $search-height; + } + + input { + background-position-x: $search-icon-padding; + background-size: $search-icon-size; + + &:dir(rtl) { + background-position-x: right $search-icon-padding; + } + } + + .search-handoff-button .fake-caret { + top: 14px; + } + + .logo-and-wordmark { + display: none; + } + } + + .search-handoff-button { + background-position-x: $search-icon-padding; + background-size: $search-icon-size; + + &:dir(rtl) { + background-position-x: right $search-icon-padding; + } + + .fake-caret { + top: 10px; + } + } + } +} + +@at-root { + // Adjust the style of the contentSearchUI-generated table + .contentSearchSuggestionTable { + border: 0; + box-shadow: $context-menu-shadow; + transform: translateY($textbox-shadow-size); + background-color: var(--newtab-background-color); + + .contentSearchHeader { + color: var(--newtab-text-secondary-color); + background-color: var(--newtab-background-color-secondary); + } + + .contentSearchHeader, + .contentSearchSettingsButton { + border-color: var(--newtab-border-color); + } + + .contentSearchSuggestionsList { + color: var(--newtab-text-primary-color); + border: 0; + } + + .contentSearchOneOffsTable { + border-top: solid 1px var(--newtab-border-color); + background-color: var(--newtab-background-color); + } + + .contentSearchSearchWithHeaderSearchText { + color: var(--newtab-text-primary-color); + } + + .contentSearchSuggestionRow { + &.selected { + background: var(--newtab-element-hover-color); + color: var(--newtab-text-primary-color); + + &:active { + background: var(--newtab-element-active-color); + } + + .historyIcon { + fill: var(--newtab-text-secondary-color); + } + } + } + + .contentSearchOneOffItem { + // Make the border slightly shorter by offsetting from the top and bottom + $border-offset: 18%; + + background-image: none; + border-image: linear-gradient(transparent $border-offset, var(--newtab-border-color) $border-offset, var(--newtab-border-color) 100% - $border-offset, transparent 100% - $border-offset) 1; + border-inline-end: 1px solid; + position: relative; + + &.selected { + background: var(--newtab-element-hover-color); + } + + &:active { + background: var(--newtab-element-active-color); + } + } + + .contentSearchSettingsButton { + &:hover { + background: var(--newtab-element-hover-color); + color: var(--newtab-text-primary-color); + } + } + } + + .contentSearchHeaderRow > td > img, + .contentSearchSuggestionRow > td > .historyIcon { + margin-inline-start: 7px; + margin-inline-end: 15px; + } +} diff --git a/browser/components/newtab/content-src/components/Sections/Sections.jsx b/browser/components/newtab/content-src/components/Sections/Sections.jsx new file mode 100644 index 0000000000..e72e9145ad --- /dev/null +++ b/browser/components/newtab/content-src/components/Sections/Sections.jsx @@ -0,0 +1,378 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { Card, PlaceholderCard } from "content-src/components/Card/Card"; +import { CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection"; +import { ComponentPerfTimer } from "content-src/components/ComponentPerfTimer/ComponentPerfTimer"; +import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText"; +import { connect } from "react-redux"; +import { MoreRecommendations } from "content-src/components/MoreRecommendations/MoreRecommendations"; +import { PocketLoggedInCta } from "content-src/components/PocketLoggedInCta/PocketLoggedInCta"; +import React from "react"; +import { Topics } from "content-src/components/Topics/Topics"; +import { TopSites } from "content-src/components/TopSites/TopSites"; + +const VISIBLE = "visible"; +const VISIBILITY_CHANGE_EVENT = "visibilitychange"; +const CARDS_PER_ROW_DEFAULT = 3; +const CARDS_PER_ROW_COMPACT_WIDE = 4; + +export class Section extends React.PureComponent { + get numRows() { + const { rowsPref, maxRows, Prefs } = this.props; + return rowsPref ? Prefs.values[rowsPref] : maxRows; + } + + _dispatchImpressionStats() { + const { props } = this; + let cardsPerRow = CARDS_PER_ROW_DEFAULT; + if ( + props.compactCards && + global.matchMedia(`(min-width: 1072px)`).matches + ) { + // If the section has compact cards and the viewport is wide enough, we show + // 4 columns instead of 3. + // $break-point-widest = 1072px (from _variables.scss) + cardsPerRow = CARDS_PER_ROW_COMPACT_WIDE; + } + const maxCards = cardsPerRow * this.numRows; + const cards = props.rows.slice(0, maxCards); + + if (this.needsImpressionStats(cards)) { + props.dispatch( + ac.ImpressionStats({ + source: props.eventSource, + tiles: cards.map(link => ({ id: link.guid })), + }) + ); + this.impressionCardGuids = cards.map(link => link.guid); + } + } + + // This sends an event when a user sees a set of new content. If content + // changes while the page is hidden (i.e. preloaded or on a hidden tab), + // only send the event if the page becomes visible again. + sendImpressionStatsOrAddListener() { + const { props } = this; + + if (!props.shouldSendImpressionStats || !props.dispatch) { + return; + } + + if (props.document.visibilityState === VISIBLE) { + this._dispatchImpressionStats(); + } else { + // We should only ever send the latest impression stats ping, so remove any + // older listeners. + if (this._onVisibilityChange) { + props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + + // When the page becomes visible, send the impression stats ping if the section isn't collapsed. + this._onVisibilityChange = () => { + if (props.document.visibilityState === VISIBLE) { + if (!this.props.pref.collapsed) { + this._dispatchImpressionStats(); + } + props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + }; + props.document.addEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + componentWillMount() { + this.sendNewTabRehydrated(this.props.initialized); + } + + componentDidMount() { + if (this.props.rows.length && !this.props.pref.collapsed) { + this.sendImpressionStatsOrAddListener(); + } + } + + componentDidUpdate(prevProps) { + const { props } = this; + const isCollapsed = props.pref.collapsed; + const wasCollapsed = prevProps.pref.collapsed; + if ( + // Don't send impression stats for the empty state + props.rows.length && + // We only want to send impression stats if the content of the cards has changed + // and the section is not collapsed... + ((props.rows !== prevProps.rows && !isCollapsed) || + // or if we are expanding a section that was collapsed. + (wasCollapsed && !isCollapsed)) + ) { + this.sendImpressionStatsOrAddListener(); + } + } + + componentWillUpdate(nextProps) { + this.sendNewTabRehydrated(nextProps.initialized); + } + + componentWillUnmount() { + if (this._onVisibilityChange) { + this.props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + needsImpressionStats(cards) { + if ( + !this.impressionCardGuids || + this.impressionCardGuids.length !== cards.length + ) { + return true; + } + + for (let i = 0; i < cards.length; i++) { + if (cards[i].guid !== this.impressionCardGuids[i]) { + return true; + } + } + + return false; + } + + // The NEW_TAB_REHYDRATED event is used to inform feeds that their + // data has been consumed e.g. for counting the number of tabs that + // have rendered that data. + sendNewTabRehydrated(initialized) { + if (initialized && !this.renderNotified) { + this.props.dispatch( + ac.AlsoToMain({ type: at.NEW_TAB_REHYDRATED, data: {} }) + ); + this.renderNotified = true; + } + } + + render() { + const { + id, + eventSource, + title, + rows, + Pocket, + topics, + emptyState, + dispatch, + compactCards, + read_more_endpoint, + contextMenuOptions, + initialized, + learnMore, + pref, + privacyNoticeURL, + isFirst, + isLast, + } = this.props; + + const waitingForSpoc = + id === "topstories" && this.props.Pocket.waitingForSpoc; + const maxCardsPerRow = compactCards + ? CARDS_PER_ROW_COMPACT_WIDE + : CARDS_PER_ROW_DEFAULT; + const { numRows } = this; + const maxCards = maxCardsPerRow * numRows; + const maxCardsOnNarrow = CARDS_PER_ROW_DEFAULT * numRows; + + const { pocketCta, isUserLoggedIn } = Pocket || {}; + const { useCta } = pocketCta || {}; + + // Don't display anything until we have a definitve result from Pocket, + // to avoid a flash of logged out state while we render. + const isPocketLoggedInDefined = + isUserLoggedIn === true || isUserLoggedIn === false; + + const hasTopics = topics && !!topics.length; + + const shouldShowPocketCta = + id === "topstories" && useCta && isUserLoggedIn === false; + + // Show topics only for top stories and if it has loaded with topics. + // The classs .top-stories-bottom-container ensures content doesn't shift as things load. + const shouldShowTopics = + id === "topstories" && + hasTopics && + ((useCta && isUserLoggedIn === true) || + (!useCta && isPocketLoggedInDefined)); + + // We use topics to determine language support for read more. + const shouldShowReadMore = read_more_endpoint && hasTopics; + + const realRows = rows.slice(0, maxCards); + + // The empty state should only be shown after we have initialized and there is no content. + // Otherwise, we should show placeholders. + const shouldShowEmptyState = initialized && !rows.length; + + const cards = []; + if (!shouldShowEmptyState) { + for (let i = 0; i < maxCards; i++) { + const link = realRows[i]; + // On narrow viewports, we only show 3 cards per row. We'll mark the rest as + // .hide-for-narrow to hide in CSS via @media query. + const className = i >= maxCardsOnNarrow ? "hide-for-narrow" : ""; + let usePlaceholder = !link; + // If we are in the third card and waiting for spoc, + // use the placeholder. + if (!usePlaceholder && i === 2 && waitingForSpoc) { + usePlaceholder = true; + } + cards.push( + !usePlaceholder ? ( + <Card + key={i} + index={i} + className={className} + dispatch={dispatch} + link={link} + contextMenuOptions={contextMenuOptions} + eventSource={eventSource} + shouldSendImpressionStats={this.props.shouldSendImpressionStats} + isWebExtension={this.props.isWebExtension} + /> + ) : ( + <PlaceholderCard key={i} className={className} /> + ) + ); + } + } + + const sectionClassName = [ + "section", + compactCards ? "compact-cards" : "normal-cards", + ].join(" "); + + // <Section> <-- React component + // <section> <-- HTML5 element + return ( + <ComponentPerfTimer {...this.props}> + <CollapsibleSection + className={sectionClassName} + title={title} + id={id} + eventSource={eventSource} + collapsed={this.props.pref.collapsed} + showPrefName={(pref && pref.feed) || id} + privacyNoticeURL={privacyNoticeURL} + Prefs={this.props.Prefs} + isFixed={this.props.isFixed} + isFirst={isFirst} + isLast={isLast} + learnMore={learnMore} + dispatch={this.props.dispatch} + isWebExtension={this.props.isWebExtension} + > + {!shouldShowEmptyState && ( + <ul className="section-list" style={{ padding: 0 }}> + {cards} + </ul> + )} + {shouldShowEmptyState && ( + <div className="section-empty-state"> + <div className="empty-state"> + <FluentOrText message={emptyState.message}> + <p className="empty-state-message" /> + </FluentOrText> + </div> + </div> + )} + {id === "topstories" && ( + <div className="top-stories-bottom-container"> + {shouldShowTopics && ( + <div className="wrapper-topics"> + <Topics topics={this.props.topics} /> + </div> + )} + + {shouldShowPocketCta && ( + <div className="wrapper-cta"> + <PocketLoggedInCta /> + </div> + )} + + <div className="wrapper-more-recommendations"> + {shouldShowReadMore && ( + <MoreRecommendations + read_more_endpoint={read_more_endpoint} + /> + )} + </div> + </div> + )} + </CollapsibleSection> + </ComponentPerfTimer> + ); + } +} + +Section.defaultProps = { + document: global.document, + rows: [], + emptyState: {}, + pref: {}, + title: "", +}; + +export const SectionIntl = connect(state => ({ + Prefs: state.Prefs, + Pocket: state.Pocket, +}))(Section); + +export class _Sections extends React.PureComponent { + renderSections() { + const sections = []; + const enabledSections = this.props.Sections.filter( + section => section.enabled + ); + const { sectionOrder, "feeds.topsites": showTopSites } = + this.props.Prefs.values; + // Enabled sections doesn't include Top Sites, so we add it if enabled. + const expectedCount = enabledSections.length + ~~showTopSites; + + for (const sectionId of sectionOrder.split(",")) { + const commonProps = { + key: sectionId, + isFirst: sections.length === 0, + isLast: sections.length === expectedCount - 1, + }; + if (sectionId === "topsites" && showTopSites) { + sections.push(<TopSites {...commonProps} />); + } else { + const section = enabledSections.find(s => s.id === sectionId); + if (section) { + sections.push(<SectionIntl {...section} {...commonProps} />); + } + } + } + return sections; + } + + render() { + return <div className="sections-list">{this.renderSections()}</div>; + } +} + +export const Sections = connect(state => ({ + Sections: state.Sections, + Prefs: state.Prefs, +}))(_Sections); diff --git a/browser/components/newtab/content-src/components/Sections/_Sections.scss b/browser/components/newtab/content-src/components/Sections/_Sections.scss new file mode 100644 index 0000000000..e3fe15f762 --- /dev/null +++ b/browser/components/newtab/content-src/components/Sections/_Sections.scss @@ -0,0 +1,123 @@ +.sections-list { + .section-list { + display: grid; + grid-gap: $base-gutter; + grid-template-columns: repeat(auto-fit, $card-width); + margin: 0; + + @media (max-width: $break-point-medium) { + @include context-menu-open-left; + } + + @media (min-width: $break-point-medium) and (max-width: $break-point-large) { + :nth-child(2n) { + @include context-menu-open-left; + } + } + + @media (min-width: $break-point-large) and (max-width: $break-point-large + 2 * $card-width) { + :nth-child(3n) { + @include context-menu-open-left; + } + } + + @media (min-width: $break-point-widest) and (max-width: $break-point-widest + 2 * $card-width) { + // 3n for normal cards, 4n for compact cards + :nth-child(3n), + :nth-child(4n) { + @include context-menu-open-left; + } + } + } + + .section-empty-state { + border: $border-secondary; + border-radius: $border-radius; + display: flex; + height: $card-height; + width: 100%; + + .empty-state { + margin: auto; + max-width: 350px; + + .empty-state-message { + color: var(--newtab-text-primary-color); + font-size: 13px; + margin-bottom: 0; + text-align: center; + } + } + + @media (min-width: $break-point-widest) { + height: $card-height-large; + } + } +} + +.top-stories-bottom-container { + color: var(--newtab-text-primary-color); + font-size: 12px; + line-height: 1.6; + margin-top: $topic-margin-top; + display: flex; + justify-content: space-between; + + a { + color: var(--newtab-primary-action-background); + font-weight: bold; + + &.more-recommendations { + font-weight: normal; + font-size: 13px; + } + } + + .wrapper-topics, + .wrapper-cta + .wrapper-more-recommendations { + @media (max-width: $break-point-large - 1) { + display: none; + } + } + + @media (max-width: $break-point-medium - 1) { + .wrapper-cta { + text-align: center; + + .pocket-logged-in-cta { + display: block; + margin-inline-end: 0; + + .pocket-cta-button { + max-width: none; + display: block; + margin-inline-end: 0; + margin: 5px 0 10px; + } + } + } + + .wrapper-more-recommendations { + width: 100%; + + .more-recommendations { + justify-content: center; + + &::after { + display: none; + } + } + } + } +} + +@media (min-width: $break-point-widest) { + .sections-list { + // Compact cards stay the same size but normal cards get bigger. + .normal-cards { + .section-list { + grid-template-columns: repeat(auto-fit, $card-width-large); + } + } + } +} diff --git a/browser/components/newtab/content-src/components/TopSites/SearchShortcutsForm.jsx b/browser/components/newtab/content-src/components/TopSites/SearchShortcutsForm.jsx new file mode 100644 index 0000000000..4324c019f6 --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/SearchShortcutsForm.jsx @@ -0,0 +1,192 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import React from "react"; +import { TOP_SITES_SOURCE } from "./TopSitesConstants"; + +export class SelectableSearchShortcut extends React.PureComponent { + render() { + const { shortcut, selected } = this.props; + const imageStyle = { backgroundImage: `url("${shortcut.tippyTopIcon}")` }; + return ( + <div className="top-site-outer search-shortcut"> + <input + type="checkbox" + id={shortcut.keyword} + name={shortcut.keyword} + checked={selected} + onChange={this.props.onChange} + /> + <label htmlFor={shortcut.keyword}> + <div className="top-site-inner"> + <span> + <div className="tile"> + <div + className="top-site-icon rich-icon" + style={imageStyle} + data-fallback="@" + /> + <div className="top-site-icon search-topsite" /> + </div> + <div className="title"> + <span dir="auto">{shortcut.keyword}</span> + </div> + </span> + </div> + </label> + </div> + ); + } +} + +export class SearchShortcutsForm extends React.PureComponent { + constructor(props) { + super(props); + this.handleChange = this.handleChange.bind(this); + this.onCancelButtonClick = this.onCancelButtonClick.bind(this); + this.onSaveButtonClick = this.onSaveButtonClick.bind(this); + + // clone the shortcuts and add them to the state so we can add isSelected property + const shortcuts = []; + const { rows, searchShortcuts } = props.TopSites; + searchShortcuts.forEach(shortcut => { + shortcuts.push({ + ...shortcut, + isSelected: !!rows.find( + row => + row && + row.isPinned && + row.searchTopSite && + row.label === shortcut.keyword + ), + }); + }); + this.state = { shortcuts }; + } + + handleChange(event) { + const { target } = event; + const { name, checked } = target; + this.setState(prevState => { + const shortcuts = prevState.shortcuts.slice(); + let shortcut = shortcuts.find(({ keyword }) => keyword === name); + shortcut.isSelected = checked; + return { shortcuts }; + }); + } + + onCancelButtonClick(ev) { + ev.preventDefault(); + this.props.onClose(); + } + + onSaveButtonClick(ev) { + ev.preventDefault(); + + // Check if there were any changes and act accordingly + const { rows } = this.props.TopSites; + const pinQueue = []; + const unpinQueue = []; + this.state.shortcuts.forEach(shortcut => { + const alreadyPinned = rows.find( + row => + row && + row.isPinned && + row.searchTopSite && + row.label === shortcut.keyword + ); + if (shortcut.isSelected && !alreadyPinned) { + pinQueue.push(this._searchTopSite(shortcut)); + } else if (!shortcut.isSelected && alreadyPinned) { + unpinQueue.push({ + url: alreadyPinned.url, + searchVendor: shortcut.shortURL, + }); + } + }); + + // Tell the feed to do the work. + this.props.dispatch( + ac.OnlyToMain({ + type: at.UPDATE_PINNED_SEARCH_SHORTCUTS, + data: { + addedShortcuts: pinQueue, + deletedShortcuts: unpinQueue, + }, + }) + ); + + // Send the Telemetry pings. + pinQueue.forEach(shortcut => { + this.props.dispatch( + ac.UserEvent({ + source: TOP_SITES_SOURCE, + event: "SEARCH_EDIT_ADD", + value: { search_vendor: shortcut.searchVendor }, + }) + ); + }); + unpinQueue.forEach(shortcut => { + this.props.dispatch( + ac.UserEvent({ + source: TOP_SITES_SOURCE, + event: "SEARCH_EDIT_DELETE", + value: { search_vendor: shortcut.searchVendor }, + }) + ); + }); + + this.props.onClose(); + } + + _searchTopSite(shortcut) { + return { + url: shortcut.url, + searchTopSite: true, + label: shortcut.keyword, + searchVendor: shortcut.shortURL, + }; + } + + render() { + return ( + <form className="topsite-form"> + <div className="search-shortcuts-container"> + <h3 + className="section-title grey-title" + data-l10n-id="newtab-topsites-add-search-engine-header" + /> + <div> + {this.state.shortcuts.map(shortcut => ( + <SelectableSearchShortcut + key={shortcut.keyword} + shortcut={shortcut} + selected={shortcut.isSelected} + onChange={this.handleChange} + /> + ))} + </div> + </div> + <section className="actions"> + <button + className="cancel" + type="button" + onClick={this.onCancelButtonClick} + data-l10n-id="newtab-topsites-cancel-button" + /> + <button + className="done" + type="submit" + onClick={this.onSaveButtonClick} + data-l10n-id="newtab-topsites-save-button" + /> + </section> + </form> + ); + } +} diff --git a/browser/components/newtab/content-src/components/TopSites/TopSite.jsx b/browser/components/newtab/content-src/components/TopSites/TopSite.jsx new file mode 100644 index 0000000000..b7f0038558 --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/TopSite.jsx @@ -0,0 +1,889 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { + MIN_RICH_FAVICON_SIZE, + MIN_SMALL_FAVICON_SIZE, + TOP_SITES_CONTEXT_MENU_OPTIONS, + TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS, + TOP_SITES_SPONSORED_POSITION_CONTEXT_MENU_OPTIONS, + TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS, + TOP_SITES_SOURCE, +} from "./TopSitesConstants"; +import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu"; +import { ImpressionStats } from "../DiscoveryStreamImpressionStats/ImpressionStats"; +import React from "react"; +import { ScreenshotUtils } from "content-src/lib/screenshot-utils"; +import { TOP_SITES_MAX_SITES_PER_ROW } from "common/Reducers.sys.mjs"; +import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton"; +import { TopSiteImpressionWrapper } from "./TopSiteImpressionWrapper"; +import { connect } from "react-redux"; + +const SPOC_TYPE = "SPOC"; +const NEWTAB_SOURCE = "newtab"; + +// For cases if we want to know if this is sponsored by either sponsored_position or type. +// We have two sources for sponsored topsites, and +// sponsored_position is set by one sponsored source, and type is set by another. +// This is not called in all cases, sometimes we want to know if it's one source +// or the other. This function is only applicable in cases where we only care if it's either. +function isSponsored(link) { + return link?.sponsored_position || link?.type === SPOC_TYPE; +} + +export class TopSiteLink extends React.PureComponent { + constructor(props) { + super(props); + this.state = { screenshotImage: null }; + this.onDragEvent = this.onDragEvent.bind(this); + this.onKeyPress = this.onKeyPress.bind(this); + } + + /* + * Helper to determine whether the drop zone should allow a drop. We only allow + * dropping top sites for now. We don't allow dropping on sponsored top sites + * as their position is fixed. + */ + _allowDrop(e) { + return ( + (this.dragged || !isSponsored(this.props.link)) && + e.dataTransfer.types.includes("text/topsite-index") + ); + } + + onDragEvent(event) { + switch (event.type) { + case "click": + // Stop any link clicks if we started any dragging + if (this.dragged) { + event.preventDefault(); + } + break; + case "dragstart": + event.target.blur(); + if (isSponsored(this.props.link)) { + event.preventDefault(); + break; + } + this.dragged = true; + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/topsite-index", this.props.index); + this.props.onDragEvent( + event, + this.props.index, + this.props.link, + this.props.title + ); + break; + case "dragend": + this.props.onDragEvent(event); + break; + case "dragenter": + case "dragover": + case "drop": + if (this._allowDrop(event)) { + event.preventDefault(); + this.props.onDragEvent(event, this.props.index); + } + break; + case "mousedown": + // Block the scroll wheel from appearing for middle clicks on search top sites + if (event.button === 1 && this.props.link.searchTopSite) { + event.preventDefault(); + } + // Reset at the first mouse event of a potential drag + this.dragged = false; + break; + } + } + + /** + * Helper to obtain the next state based on nextProps and prevState. + * + * NOTE: Rename this method to getDerivedStateFromProps when we update React + * to >= 16.3. We will need to update tests as well. We cannot rename this + * method to getDerivedStateFromProps now because there is a mismatch in + * the React version that we are using for both testing and production. + * (i.e. react-test-render => "16.3.2", react => "16.2.0"). + * + * See https://github.com/airbnb/enzyme/blob/master/packages/enzyme-adapter-react-16/package.json#L43. + */ + static getNextStateFromProps(nextProps, prevState) { + const { screenshot } = nextProps.link; + const imageInState = ScreenshotUtils.isRemoteImageLocal( + prevState.screenshotImage, + screenshot + ); + if (imageInState) { + return null; + } + + // Since image was updated, attempt to revoke old image blob URL, if it exists. + ScreenshotUtils.maybeRevokeBlobObjectURL(prevState.screenshotImage); + + return { + screenshotImage: ScreenshotUtils.createLocalImageObject(screenshot), + }; + } + + // NOTE: Remove this function when we update React to >= 16.3 since React will + // call getDerivedStateFromProps automatically. We will also need to + // rename getNextStateFromProps to getDerivedStateFromProps. + componentWillMount() { + const nextState = TopSiteLink.getNextStateFromProps(this.props, this.state); + if (nextState) { + this.setState(nextState); + } + } + + // NOTE: Remove this function when we update React to >= 16.3 since React will + // call getDerivedStateFromProps automatically. We will also need to + // rename getNextStateFromProps to getDerivedStateFromProps. + componentWillReceiveProps(nextProps) { + const nextState = TopSiteLink.getNextStateFromProps(nextProps, this.state); + if (nextState) { + this.setState(nextState); + } + } + + componentWillUnmount() { + ScreenshotUtils.maybeRevokeBlobObjectURL(this.state.screenshotImage); + } + + onKeyPress(event) { + // If we have tabbed to a search shortcut top site, and we click 'enter', + // we should execute the onClick function. This needs to be added because + // search top sites are anchor tags without an href. See bug 1483135 + if (this.props.link.searchTopSite && event.key === "Enter") { + this.props.onClick(event); + } + } + + /* + * Takes the url as a string, runs it through a simple (non-secure) hash turning it into a random number + * Apply that random number to the color array. The same url will always generate the same color. + */ + generateColor() { + let { title, colors } = this.props; + if (!colors) { + return ""; + } + + let colorArray = colors.split(","); + + const hashStr = str => { + let hash = 0; + for (let i = 0; i < str.length; i++) { + let charCode = str.charCodeAt(i); + hash += charCode; + } + return hash; + }; + + let hash = hashStr(title); + let index = hash % colorArray.length; + return colorArray[index]; + } + + calculateStyle() { + const { defaultStyle, link } = this.props; + + const { tippyTopIcon, faviconSize } = link; + let imageClassName; + let imageStyle; + let showSmallFavicon = false; + let smallFaviconStyle; + let hasScreenshotImage = + this.state.screenshotImage && this.state.screenshotImage.url; + let selectedColor; + + if (defaultStyle) { + // force no styles (letter fallback) even if the link has imagery + selectedColor = this.generateColor(); + } else if (link.searchTopSite) { + imageClassName = "top-site-icon rich-icon"; + imageStyle = { + backgroundColor: link.backgroundColor, + backgroundImage: `url(${tippyTopIcon})`, + }; + smallFaviconStyle = { backgroundImage: `url(${tippyTopIcon})` }; + } else if (link.customScreenshotURL) { + // assume high quality custom screenshot and use rich icon styles and class names + imageClassName = "top-site-icon rich-icon"; + imageStyle = { + backgroundColor: link.backgroundColor, + backgroundImage: hasScreenshotImage + ? `url(${this.state.screenshotImage.url})` + : "", + }; + } else if ( + tippyTopIcon || + link.type === SPOC_TYPE || + faviconSize >= MIN_RICH_FAVICON_SIZE + ) { + // styles and class names for top sites with rich icons + imageClassName = "top-site-icon rich-icon"; + imageStyle = { + backgroundColor: link.backgroundColor, + backgroundImage: `url(${tippyTopIcon || link.favicon})`, + }; + } else if (faviconSize >= MIN_SMALL_FAVICON_SIZE) { + showSmallFavicon = true; + smallFaviconStyle = { backgroundImage: `url(${link.favicon})` }; + } else { + selectedColor = this.generateColor(); + imageClassName = ""; + } + + return { + showSmallFavicon, + smallFaviconStyle, + imageStyle, + imageClassName, + selectedColor, + }; + } + + render() { + const { children, className, isDraggable, link, onClick, title } = + this.props; + const topSiteOuterClassName = `top-site-outer${ + className ? ` ${className}` : "" + }${link.isDragged ? " dragged" : ""}${ + link.searchTopSite ? " search-shortcut" : "" + }`; + const [letterFallback] = title; + const { + showSmallFavicon, + smallFaviconStyle, + imageStyle, + imageClassName, + selectedColor, + } = this.calculateStyle(); + + let draggableProps = {}; + if (isDraggable) { + draggableProps = { + onClick: this.onDragEvent, + onDragEnd: this.onDragEvent, + onDragStart: this.onDragEvent, + onMouseDown: this.onDragEvent, + }; + } + + let impressionStats = null; + if (link.type === SPOC_TYPE) { + // Record impressions for Pocket tiles. + impressionStats = ( + <ImpressionStats + flightId={link.flightId} + rows={[ + { + id: link.id, + pos: link.pos, + shim: link.shim && link.shim.impression, + advertiser: title.toLocaleLowerCase(), + }, + ]} + dispatch={this.props.dispatch} + source={TOP_SITES_SOURCE} + /> + ); + } else if (isSponsored(link)) { + // Record impressions for non-Pocket sponsored tiles. + impressionStats = ( + <TopSiteImpressionWrapper + actionType={at.TOP_SITES_SPONSORED_IMPRESSION_STATS} + tile={{ + position: this.props.index, + tile_id: link.sponsored_tile_id || -1, + reporting_url: link.sponsored_impression_url, + advertiser: title.toLocaleLowerCase(), + source: NEWTAB_SOURCE, + }} + // For testing. + IntersectionObserver={this.props.IntersectionObserver} + document={this.props.document} + dispatch={this.props.dispatch} + /> + ); + } else { + // Record impressions for organic tiles. + impressionStats = ( + <TopSiteImpressionWrapper + actionType={at.TOP_SITES_ORGANIC_IMPRESSION_STATS} + tile={{ + position: this.props.index, + source: NEWTAB_SOURCE, + }} + // For testing. + IntersectionObserver={this.props.IntersectionObserver} + document={this.props.document} + dispatch={this.props.dispatch} + /> + ); + } + + return ( + <li + className={topSiteOuterClassName} + onDrop={this.onDragEvent} + onDragOver={this.onDragEvent} + onDragEnter={this.onDragEvent} + onDragLeave={this.onDragEvent} + {...draggableProps} + > + <div className="top-site-inner"> + {/* We don't yet support an accessible drag-and-drop implementation, see Bug 1552005 */} + {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} + <a + className="top-site-button" + href={link.searchTopSite ? undefined : link.url} + tabIndex="0" + onKeyPress={this.onKeyPress} + onClick={onClick} + draggable={true} + data-is-sponsored-link={!!link.sponsored_tile_id} + > + <div className="tile" aria-hidden={true}> + <div + className={ + selectedColor + ? "icon-wrapper letter-fallback" + : "icon-wrapper" + } + data-fallback={letterFallback} + style={selectedColor ? { backgroundColor: selectedColor } : {}} + > + <div className={imageClassName} style={imageStyle} /> + {showSmallFavicon && ( + <div + className="top-site-icon default-icon" + data-fallback={smallFaviconStyle ? "" : letterFallback} + style={smallFaviconStyle} + /> + )} + </div> + {link.searchTopSite && ( + <div className="top-site-icon search-topsite" /> + )} + </div> + <div + className={`title${link.isPinned ? " has-icon pinned" : ""}${ + link.type === SPOC_TYPE || link.show_sponsored_label + ? " sponsored" + : "" + }`} + > + <span dir="auto"> + {link.isPinned && <div className="icon icon-pin-small" />} + {title || <br />} + <span + className="sponsored-label" + data-l10n-id="newtab-topsite-sponsored" + /> + </span> + </div> + </a> + {children} + {impressionStats} + </div> + </li> + ); + } +} +TopSiteLink.defaultProps = { + title: "", + link: {}, + isDraggable: true, +}; + +export class TopSite extends React.PureComponent { + constructor(props) { + super(props); + this.state = { showContextMenu: false }; + this.onLinkClick = this.onLinkClick.bind(this); + this.onMenuUpdate = this.onMenuUpdate.bind(this); + } + + /** + * Report to telemetry additional information about the item. + */ + _getTelemetryInfo() { + const value = { icon_type: this.props.link.iconType }; + // Filter out "not_pinned" type for being the default + if (this.props.link.isPinned) { + value.card_type = "pinned"; + } + if (this.props.link.searchTopSite) { + // Set the card_type as "search" regardless of its pinning status + value.card_type = "search"; + value.search_vendor = this.props.link.hostname; + } + if (isSponsored(this.props.link)) { + value.card_type = "spoc"; + } + return { value }; + } + + userEvent(event) { + this.props.dispatch( + ac.UserEvent( + Object.assign( + { + event, + source: TOP_SITES_SOURCE, + action_position: this.props.index, + }, + this._getTelemetryInfo() + ) + ) + ); + } + + onLinkClick(event) { + this.userEvent("CLICK"); + + // Specially handle a top site link click for "typed" frecency bonus as + // specified as a property on the link. + event.preventDefault(); + const { altKey, button, ctrlKey, metaKey, shiftKey } = event; + if (!this.props.link.searchTopSite) { + this.props.dispatch( + ac.OnlyToMain({ + type: at.OPEN_LINK, + data: Object.assign(this.props.link, { + event: { altKey, button, ctrlKey, metaKey, shiftKey }, + }), + }) + ); + + if (this.props.link.type === SPOC_TYPE) { + // Record a Pocket-specific click. + this.props.dispatch( + ac.ImpressionStats({ + source: TOP_SITES_SOURCE, + click: 0, + tiles: [ + { + id: this.props.link.id, + pos: this.props.link.pos, + shim: this.props.link.shim && this.props.link.shim.click, + }, + ], + }) + ); + + // Record a click for a Pocket sponsored tile. + // This first event is for the shim property + // and is used by our ad service provider. + this.props.dispatch( + ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source: TOP_SITES_SOURCE, + action_position: this.props.link.pos, + value: { + card_type: "spoc", + tile_id: this.props.link.id, + shim: this.props.link.shim && this.props.link.shim.click, + }, + }) + ); + + // A second event is recoded for internal usage. + const title = this.props.link.label || this.props.link.hostname; + this.props.dispatch( + ac.OnlyToMain({ + type: at.TOP_SITES_SPONSORED_IMPRESSION_STATS, + data: { + type: "click", + position: this.props.link.pos, + tile_id: this.props.link.id, + advertiser: title.toLocaleLowerCase(), + source: NEWTAB_SOURCE, + }, + }) + ); + } else if (isSponsored(this.props.link)) { + // Record a click for a non-Pocket sponsored tile. + const title = this.props.link.label || this.props.link.hostname; + this.props.dispatch( + ac.OnlyToMain({ + type: at.TOP_SITES_SPONSORED_IMPRESSION_STATS, + data: { + type: "click", + position: this.props.index, + tile_id: this.props.link.sponsored_tile_id || -1, + reporting_url: this.props.link.sponsored_click_url, + advertiser: title.toLocaleLowerCase(), + source: NEWTAB_SOURCE, + }, + }) + ); + } else { + // Record a click for an organic tile. + this.props.dispatch( + ac.OnlyToMain({ + type: at.TOP_SITES_ORGANIC_IMPRESSION_STATS, + data: { + type: "click", + position: this.props.index, + source: NEWTAB_SOURCE, + }, + }) + ); + } + + if (this.props.link.sendAttributionRequest) { + this.props.dispatch( + ac.OnlyToMain({ + type: at.PARTNER_LINK_ATTRIBUTION, + data: { + targetURL: this.props.link.url, + source: "newtab", + }, + }) + ); + } + } else { + this.props.dispatch( + ac.OnlyToMain({ + type: at.FILL_SEARCH_TERM, + data: { label: this.props.link.label }, + }) + ); + } + } + + onMenuUpdate(isOpen) { + if (isOpen) { + this.props.onActivate(this.props.index); + } else { + this.props.onActivate(); + } + } + + render() { + const { props } = this; + const { link } = props; + const isContextMenuOpen = props.activeIndex === props.index; + const title = link.label || link.hostname; + let menuOptions; + if (link.sponsored_position) { + menuOptions = TOP_SITES_SPONSORED_POSITION_CONTEXT_MENU_OPTIONS; + } else if (link.searchTopSite) { + menuOptions = TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS; + } else if (link.type === SPOC_TYPE) { + menuOptions = TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS; + } else { + menuOptions = TOP_SITES_CONTEXT_MENU_OPTIONS; + } + + return ( + <TopSiteLink + {...props} + onClick={this.onLinkClick} + onDragEvent={this.props.onDragEvent} + className={`${props.className || ""}${ + isContextMenuOpen ? " active" : "" + }`} + title={title} + > + <div> + <ContextMenuButton + tooltip="newtab-menu-content-tooltip" + tooltipArgs={{ title }} + onUpdate={this.onMenuUpdate} + > + <LinkMenu + dispatch={props.dispatch} + index={props.index} + onUpdate={this.onMenuUpdate} + options={menuOptions} + site={link} + shouldSendImpressionStats={link.type === SPOC_TYPE} + siteInfo={this._getTelemetryInfo()} + source={TOP_SITES_SOURCE} + /> + </ContextMenuButton> + </div> + </TopSiteLink> + ); + } +} +TopSite.defaultProps = { + link: {}, + onActivate() {}, +}; + +export class TopSitePlaceholder extends React.PureComponent { + constructor(props) { + super(props); + this.onEditButtonClick = this.onEditButtonClick.bind(this); + } + + onEditButtonClick() { + this.props.dispatch({ + type: at.TOP_SITES_EDIT, + data: { index: this.props.index }, + }); + } + + render() { + return ( + <TopSiteLink + {...this.props} + className={`placeholder ${this.props.className || ""}`} + isDraggable={false} + > + <button + aria-haspopup="dialog" + className="context-menu-button edit-button icon" + data-l10n-id="newtab-menu-topsites-placeholder-tooltip" + onClick={this.onEditButtonClick} + /> + </TopSiteLink> + ); + } +} + +export class _TopSiteList extends React.PureComponent { + static get DEFAULT_STATE() { + return { + activeIndex: null, + draggedIndex: null, + draggedSite: null, + draggedTitle: null, + topSitesPreview: null, + }; + } + + constructor(props) { + super(props); + this.state = _TopSiteList.DEFAULT_STATE; + this.onDragEvent = this.onDragEvent.bind(this); + this.onActivate = this.onActivate.bind(this); + } + + componentWillReceiveProps(nextProps) { + if (this.state.draggedSite) { + const prevTopSites = this.props.TopSites && this.props.TopSites.rows; + const newTopSites = nextProps.TopSites && nextProps.TopSites.rows; + if ( + prevTopSites && + prevTopSites[this.state.draggedIndex] && + prevTopSites[this.state.draggedIndex].url === + this.state.draggedSite.url && + (!newTopSites[this.state.draggedIndex] || + newTopSites[this.state.draggedIndex].url !== + this.state.draggedSite.url) + ) { + // We got the new order from the redux store via props. We can clear state now. + this.setState(_TopSiteList.DEFAULT_STATE); + } + } + } + + userEvent(event, index) { + this.props.dispatch( + ac.UserEvent({ + event, + source: TOP_SITES_SOURCE, + action_position: index, + }) + ); + } + + onDragEvent(event, index, link, title) { + switch (event.type) { + case "dragstart": + this.dropped = false; + this.setState({ + draggedIndex: index, + draggedSite: link, + draggedTitle: title, + activeIndex: null, + }); + this.userEvent("DRAG", index); + break; + case "dragend": + if (!this.dropped) { + // If there was no drop event, reset the state to the default. + this.setState(_TopSiteList.DEFAULT_STATE); + } + break; + case "dragenter": + if (index === this.state.draggedIndex) { + this.setState({ topSitesPreview: null }); + } else { + this.setState({ + topSitesPreview: this._makeTopSitesPreview(index), + }); + } + break; + case "drop": + if (index !== this.state.draggedIndex) { + this.dropped = true; + this.props.dispatch( + ac.AlsoToMain({ + type: at.TOP_SITES_INSERT, + data: { + site: { + url: this.state.draggedSite.url, + label: this.state.draggedTitle, + customScreenshotURL: + this.state.draggedSite.customScreenshotURL, + // Only if the search topsites experiment is enabled + ...(this.state.draggedSite.searchTopSite && { + searchTopSite: true, + }), + }, + index, + draggedFromIndex: this.state.draggedIndex, + }, + }) + ); + this.userEvent("DROP", index); + } + break; + } + } + + _getTopSites() { + // Make a copy of the sites to truncate or extend to desired length + let topSites = this.props.TopSites.rows.slice(); + topSites.length = this.props.TopSitesRows * TOP_SITES_MAX_SITES_PER_ROW; + return topSites; + } + + /** + * Make a preview of the topsites that will be the result of dropping the currently + * dragged site at the specified index. + */ + _makeTopSitesPreview(index) { + const topSites = this._getTopSites(); + topSites[this.state.draggedIndex] = null; + const preview = topSites.map(site => + site && (site.isPinned || isSponsored(site)) ? site : null + ); + const unpinned = topSites.filter( + site => site && !site.isPinned && !isSponsored(site) + ); + const siteToInsert = Object.assign({}, this.state.draggedSite, { + isPinned: true, + isDragged: true, + }); + + if (!preview[index]) { + preview[index] = siteToInsert; + } else { + // Find the hole to shift the pinned site(s) towards. We shift towards the + // hole left by the site being dragged. + let holeIndex = index; + const indexStep = index > this.state.draggedIndex ? -1 : 1; + while (preview[holeIndex]) { + holeIndex += indexStep; + } + + // Shift towards the hole. + const shiftingStep = index > this.state.draggedIndex ? 1 : -1; + while ( + index > this.state.draggedIndex ? holeIndex < index : holeIndex > index + ) { + let nextIndex = holeIndex + shiftingStep; + while (isSponsored(preview[nextIndex])) { + nextIndex += shiftingStep; + } + preview[holeIndex] = preview[nextIndex]; + holeIndex = nextIndex; + } + preview[index] = siteToInsert; + } + + // Fill in the remaining holes with unpinned sites. + for (let i = 0; i < preview.length; i++) { + if (!preview[i]) { + preview[i] = unpinned.shift() || null; + } + } + + return preview; + } + + onActivate(index) { + this.setState({ activeIndex: index }); + } + + render() { + const { props } = this; + const topSites = this.state.topSitesPreview || this._getTopSites(); + const topSitesUI = []; + const commonProps = { + onDragEvent: this.onDragEvent, + dispatch: props.dispatch, + }; + // We assign a key to each placeholder slot. We need it to be independent + // of the slot index (i below) so that the keys used stay the same during + // drag and drop reordering and the underlying DOM nodes are reused. + // This mostly (only?) affects linux so be sure to test on linux before changing. + let holeIndex = 0; + + // On narrow viewports, we only show 6 sites per row. We'll mark the rest as + // .hide-for-narrow to hide in CSS via @media query. + const maxNarrowVisibleIndex = props.TopSitesRows * 6; + + for (let i = 0, l = topSites.length; i < l; i++) { + const link = + topSites[i] && + Object.assign({}, topSites[i], { + iconType: this.props.topSiteIconType(topSites[i]), + }); + + const slotProps = { + key: link ? link.url : holeIndex++, + index: i, + }; + if (i >= maxNarrowVisibleIndex) { + slotProps.className = "hide-for-narrow"; + } + + let topSiteLink; + // Use a placeholder if the link is empty or it's rendering a sponsored + // tile for the about:home startup cache. + if (!link || (props.App.isForStartupCache && isSponsored(link))) { + topSiteLink = <TopSitePlaceholder {...slotProps} {...commonProps} />; + } else { + topSiteLink = ( + <TopSite + link={link} + activeIndex={this.state.activeIndex} + onActivate={this.onActivate} + {...slotProps} + {...commonProps} + colors={props.colors} + /> + ); + } + + topSitesUI.push(topSiteLink); + } + return ( + <ul + className={`top-sites-list${ + this.state.draggedSite ? " dnd-active" : "" + }`} + > + {topSitesUI} + </ul> + ); + } +} + +export const TopSiteList = connect(state => ({ + App: state.App, +}))(_TopSiteList); diff --git a/browser/components/newtab/content-src/components/TopSites/TopSiteForm.jsx b/browser/components/newtab/content-src/components/TopSites/TopSiteForm.jsx new file mode 100644 index 0000000000..7dd61bdc93 --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/TopSiteForm.jsx @@ -0,0 +1,323 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { A11yLinkButton } from "content-src/components/A11yLinkButton/A11yLinkButton"; +import React from "react"; +import { TOP_SITES_SOURCE } from "./TopSitesConstants"; +import { TopSiteFormInput } from "./TopSiteFormInput"; +import { TopSiteLink } from "./TopSite"; + +export class TopSiteForm extends React.PureComponent { + constructor(props) { + super(props); + const { site } = props; + this.state = { + label: site ? site.label || site.hostname : "", + url: site ? site.url : "", + validationError: false, + customScreenshotUrl: site ? site.customScreenshotURL : "", + showCustomScreenshotForm: site ? site.customScreenshotURL : false, + }; + this.onClearScreenshotInput = this.onClearScreenshotInput.bind(this); + this.onLabelChange = this.onLabelChange.bind(this); + this.onUrlChange = this.onUrlChange.bind(this); + this.onCancelButtonClick = this.onCancelButtonClick.bind(this); + this.onClearUrlClick = this.onClearUrlClick.bind(this); + this.onDoneButtonClick = this.onDoneButtonClick.bind(this); + this.onCustomScreenshotUrlChange = + this.onCustomScreenshotUrlChange.bind(this); + this.onPreviewButtonClick = this.onPreviewButtonClick.bind(this); + this.onEnableScreenshotUrlForm = this.onEnableScreenshotUrlForm.bind(this); + this.validateUrl = this.validateUrl.bind(this); + } + + onLabelChange(event) { + this.setState({ label: event.target.value }); + } + + onUrlChange(event) { + this.setState({ + url: event.target.value, + validationError: false, + }); + } + + onClearUrlClick() { + this.setState({ + url: "", + validationError: false, + }); + } + + onEnableScreenshotUrlForm() { + this.setState({ showCustomScreenshotForm: true }); + } + + _updateCustomScreenshotInput(customScreenshotUrl) { + this.setState({ + customScreenshotUrl, + validationError: false, + }); + this.props.dispatch({ type: at.PREVIEW_REQUEST_CANCEL }); + } + + onCustomScreenshotUrlChange(event) { + this._updateCustomScreenshotInput(event.target.value); + } + + onClearScreenshotInput() { + this._updateCustomScreenshotInput(""); + } + + onCancelButtonClick(ev) { + ev.preventDefault(); + this.props.onClose(); + } + + onDoneButtonClick(ev) { + ev.preventDefault(); + + if (this.validateForm()) { + const site = { url: this.cleanUrl(this.state.url) }; + const { index } = this.props; + if (this.state.label !== "") { + site.label = this.state.label; + } + + if (this.state.customScreenshotUrl) { + site.customScreenshotURL = this.cleanUrl( + this.state.customScreenshotUrl + ); + } else if (this.props.site && this.props.site.customScreenshotURL) { + // Used to flag that previously cached screenshot should be removed + site.customScreenshotURL = null; + } + this.props.dispatch( + ac.AlsoToMain({ + type: at.TOP_SITES_PIN, + data: { site, index }, + }) + ); + this.props.dispatch( + ac.UserEvent({ + source: TOP_SITES_SOURCE, + event: "TOP_SITES_EDIT", + action_position: index, + }) + ); + + this.props.onClose(); + } + } + + onPreviewButtonClick(event) { + event.preventDefault(); + if (this.validateForm()) { + this.props.dispatch( + ac.AlsoToMain({ + type: at.PREVIEW_REQUEST, + data: { url: this.cleanUrl(this.state.customScreenshotUrl) }, + }) + ); + this.props.dispatch( + ac.UserEvent({ + source: TOP_SITES_SOURCE, + event: "PREVIEW_REQUEST", + }) + ); + } + } + + cleanUrl(url) { + // If we are missing a protocol, prepend http:// + if (!url.startsWith("http:") && !url.startsWith("https:")) { + return `http://${url}`; + } + return url; + } + + _tryParseUrl(url) { + try { + return new URL(url); + } catch (e) { + return null; + } + } + + validateUrl(url) { + const validProtocols = ["http:", "https:"]; + const urlObj = + this._tryParseUrl(url) || this._tryParseUrl(this.cleanUrl(url)); + + return urlObj && validProtocols.includes(urlObj.protocol); + } + + validateCustomScreenshotUrl() { + const { customScreenshotUrl } = this.state; + return !customScreenshotUrl || this.validateUrl(customScreenshotUrl); + } + + validateForm() { + const validate = + this.validateUrl(this.state.url) && this.validateCustomScreenshotUrl(); + + if (!validate) { + this.setState({ validationError: true }); + } + + return validate; + } + + _renderCustomScreenshotInput() { + const { customScreenshotUrl } = this.state; + const requestFailed = this.props.previewResponse === ""; + const validationError = + (this.state.validationError && !this.validateCustomScreenshotUrl()) || + requestFailed; + // Set focus on error if the url field is valid or when the input is first rendered and is empty + const shouldFocus = + (validationError && this.validateUrl(this.state.url)) || + !customScreenshotUrl; + const isLoading = + this.props.previewResponse === null && + customScreenshotUrl && + this.props.previewUrl === this.cleanUrl(customScreenshotUrl); + + if (!this.state.showCustomScreenshotForm) { + return ( + <A11yLinkButton + onClick={this.onEnableScreenshotUrlForm} + className="enable-custom-image-input" + data-l10n-id="newtab-topsites-use-image-link" + /> + ); + } + return ( + <div className="custom-image-input-container"> + <TopSiteFormInput + errorMessageId={ + requestFailed + ? "newtab-topsites-image-validation" + : "newtab-topsites-url-validation" + } + loading={isLoading} + onChange={this.onCustomScreenshotUrlChange} + onClear={this.onClearScreenshotInput} + shouldFocus={shouldFocus} + typeUrl={true} + value={customScreenshotUrl} + validationError={validationError} + titleId="newtab-topsites-image-url-label" + placeholderId="newtab-topsites-url-input" + /> + </div> + ); + } + + render() { + const { customScreenshotUrl } = this.state; + const requestFailed = this.props.previewResponse === ""; + // For UI purposes, editing without an existing link is "add" + const showAsAdd = !this.props.site; + const previous = + (this.props.site && this.props.site.customScreenshotURL) || ""; + const changed = + customScreenshotUrl && this.cleanUrl(customScreenshotUrl) !== previous; + // Preview mode if changes were made to the custom screenshot URL and no preview was received yet + // or the request failed + const previewMode = changed && !this.props.previewResponse; + const previewLink = Object.assign({}, this.props.site); + if (this.props.previewResponse) { + previewLink.screenshot = this.props.previewResponse; + previewLink.customScreenshotURL = this.props.previewUrl; + } + // Handles the form submit so an enter press performs the correct action + const onSubmit = previewMode + ? this.onPreviewButtonClick + : this.onDoneButtonClick; + + const addTopsitesHeaderL10nId = "newtab-topsites-add-shortcut-header"; + const editTopsitesHeaderL10nId = "newtab-topsites-edit-shortcut-header"; + return ( + <form className="topsite-form" onSubmit={onSubmit}> + <div className="form-input-container"> + <h3 + className="section-title grey-title" + data-l10n-id={ + showAsAdd ? addTopsitesHeaderL10nId : editTopsitesHeaderL10nId + } + /> + <div className="fields-and-preview"> + <div className="form-wrapper"> + <TopSiteFormInput + onChange={this.onLabelChange} + value={this.state.label} + titleId="newtab-topsites-title-label" + placeholderId="newtab-topsites-title-input" + autoFocusOnOpen={true} + /> + <TopSiteFormInput + onChange={this.onUrlChange} + shouldFocus={ + this.state.validationError && + !this.validateUrl(this.state.url) + } + value={this.state.url} + onClear={this.onClearUrlClick} + validationError={ + this.state.validationError && + !this.validateUrl(this.state.url) + } + titleId="newtab-topsites-url-label" + typeUrl={true} + placeholderId="newtab-topsites-url-input" + errorMessageId="newtab-topsites-url-validation" + /> + {this._renderCustomScreenshotInput()} + </div> + <TopSiteLink + link={previewLink} + defaultStyle={requestFailed} + title={this.state.label} + /> + </div> + </div> + <section className="actions"> + <button + className="cancel" + type="button" + onClick={this.onCancelButtonClick} + data-l10n-id="newtab-topsites-cancel-button" + /> + {previewMode ? ( + <button + className="done preview" + type="submit" + data-l10n-id="newtab-topsites-preview-button" + /> + ) : ( + <button + className="done" + type="submit" + data-l10n-id={ + showAsAdd + ? "newtab-topsites-add-button" + : "newtab-topsites-save-button" + } + /> + )} + </section> + </form> + ); + } +} + +TopSiteForm.defaultProps = { + site: null, + index: -1, +}; diff --git a/browser/components/newtab/content-src/components/TopSites/TopSiteFormInput.jsx b/browser/components/newtab/content-src/components/TopSites/TopSiteFormInput.jsx new file mode 100644 index 0000000000..c680edc7e4 --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/TopSiteFormInput.jsx @@ -0,0 +1,111 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; + +export class TopSiteFormInput extends React.PureComponent { + constructor(props) { + super(props); + this.state = { validationError: this.props.validationError }; + this.onChange = this.onChange.bind(this); + this.onMount = this.onMount.bind(this); + this.onClearIconPress = this.onClearIconPress.bind(this); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.shouldFocus && !this.props.shouldFocus) { + this.input.focus(); + } + if (nextProps.validationError && !this.props.validationError) { + this.setState({ validationError: true }); + } + // If the component is in an error state but the value was cleared by the parent + if (this.state.validationError && !nextProps.value) { + this.setState({ validationError: false }); + } + } + + onClearIconPress(event) { + // If there is input in the URL or custom image URL fields, + // and we hit 'enter' while tabbed over the clear icon, + // we should execute the function to clear the field. + if (event.key === "Enter") { + this.props.onClear(); + } + } + + onChange(ev) { + if (this.state.validationError) { + this.setState({ validationError: false }); + } + this.props.onChange(ev); + } + + onMount(input) { + this.input = input; + } + + renderLoadingOrCloseButton() { + const showClearButton = this.props.value && this.props.onClear; + + if (this.props.loading) { + return ( + <div className="loading-container"> + <div className="loading-animation" /> + </div> + ); + } else if (showClearButton) { + return ( + <button + type="button" + className="icon icon-clear-input icon-button-style" + onClick={this.props.onClear} + onKeyPress={this.onClearIconPress} + /> + ); + } + return null; + } + + render() { + const { typeUrl } = this.props; + const { validationError } = this.state; + + return ( + <label> + <span data-l10n-id={this.props.titleId} /> + <div + className={`field ${typeUrl ? "url" : ""}${ + validationError ? " invalid" : "" + }`} + > + <input + type="text" + value={this.props.value} + ref={this.onMount} + onChange={this.onChange} + data-l10n-id={this.props.placeholderId} + // Set focus on error if the url field is valid or when the input is first rendered and is empty + // eslint-disable-next-line jsx-a11y/no-autofocus + autoFocus={this.props.autoFocusOnOpen} + disabled={this.props.loading} + /> + {this.renderLoadingOrCloseButton()} + {validationError && ( + <aside + className="error-tooltip" + data-l10n-id={this.props.errorMessageId} + /> + )} + </div> + </label> + ); + } +} + +TopSiteFormInput.defaultProps = { + showClearButton: false, + value: "", + validationError: false, +}; diff --git a/browser/components/newtab/content-src/components/TopSites/TopSiteImpressionWrapper.jsx b/browser/components/newtab/content-src/components/TopSites/TopSiteImpressionWrapper.jsx new file mode 100644 index 0000000000..580809dd57 --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/TopSiteImpressionWrapper.jsx @@ -0,0 +1,149 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { actionCreators as ac } from "common/Actions.sys.mjs"; +import React from "react"; + +const VISIBLE = "visible"; +const VISIBILITY_CHANGE_EVENT = "visibilitychange"; + +// Per analytical requirement, we set the minimal intersection ratio to +// 0.5, and an impression is identified when the wrapped item has at least +// 50% visibility. +// +// This constant is exported for unit test +export const INTERSECTION_RATIO = 0.5; + +/** + * Impression wrapper for a TopSite tile. + * + * It makses use of the Intersection Observer API to detect the visibility, + * and relies on page visibility to ensure the impression is reported + * only when the component is visible on the page. + */ +export class TopSiteImpressionWrapper extends React.PureComponent { + _dispatchImpressionStats() { + const { actionType, tile } = this.props; + if (!actionType) { + return; + } + + this.props.dispatch( + ac.OnlyToMain({ + type: actionType, + data: { + type: "impression", + ...tile, + }, + }) + ); + } + + setImpressionObserverOrAddListener() { + const { props } = this; + + if (!props.dispatch) { + return; + } + + if (props.document.visibilityState === VISIBLE) { + this.setImpressionObserver(); + } else { + // We should only ever send the latest impression stats ping, so remove any + // older listeners. + if (this._onVisibilityChange) { + props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + + this._onVisibilityChange = () => { + if (props.document.visibilityState === VISIBLE) { + this.setImpressionObserver(); + props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + }; + props.document.addEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + /** + * Set an impression observer for the wrapped component. It makes use of + * the Intersection Observer API to detect if the wrapped component is + * visible with a desired ratio, and only sends impression if that's the case. + * + * See more details about Intersection Observer API at: + * https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API + */ + setImpressionObserver() { + const { props } = this; + + if (!props.tile) { + return; + } + + this._handleIntersect = entries => { + if ( + entries.some( + entry => + entry.isIntersecting && + entry.intersectionRatio >= INTERSECTION_RATIO + ) + ) { + this._dispatchImpressionStats(); + this.impressionObserver.unobserve(this.refs.topsite_impression_wrapper); + } + }; + + const options = { threshold: INTERSECTION_RATIO }; + this.impressionObserver = new props.IntersectionObserver( + this._handleIntersect, + options + ); + this.impressionObserver.observe(this.refs.topsite_impression_wrapper); + } + + componentDidMount() { + if (this.props.tile) { + this.setImpressionObserverOrAddListener(); + } + } + + componentWillUnmount() { + if (this._handleIntersect && this.impressionObserver) { + this.impressionObserver.unobserve(this.refs.topsite_impression_wrapper); + } + if (this._onVisibilityChange) { + this.props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + render() { + return ( + <div + ref={"topsite_impression_wrapper"} + className="topsite-impression-observer" + > + {this.props.children} + </div> + ); + } +} + +TopSiteImpressionWrapper.defaultProps = { + IntersectionObserver: global.IntersectionObserver, + document: global.document, + actionType: null, + tile: null, +}; diff --git a/browser/components/newtab/content-src/components/TopSites/TopSites.jsx b/browser/components/newtab/content-src/components/TopSites/TopSites.jsx new file mode 100644 index 0000000000..c69156c514 --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/TopSites.jsx @@ -0,0 +1,213 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { MIN_RICH_FAVICON_SIZE, TOP_SITES_SOURCE } from "./TopSitesConstants"; +import { CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection"; +import { ComponentPerfTimer } from "content-src/components/ComponentPerfTimer/ComponentPerfTimer"; +import { connect } from "react-redux"; +import { ModalOverlayWrapper } from "content-src/components/ModalOverlay/ModalOverlay"; +import React from "react"; +import { SearchShortcutsForm } from "./SearchShortcutsForm"; +import { TOP_SITES_MAX_SITES_PER_ROW } from "common/Reducers.sys.mjs"; +import { TopSiteForm } from "./TopSiteForm"; +import { TopSiteList } from "./TopSite"; + +function topSiteIconType(link) { + if (link.customScreenshotURL) { + return "custom_screenshot"; + } + if (link.tippyTopIcon || link.faviconRef === "tippytop") { + return "tippytop"; + } + if (link.faviconSize >= MIN_RICH_FAVICON_SIZE) { + return "rich_icon"; + } + if (link.screenshot) { + return "screenshot"; + } + return "no_image"; +} + +/** + * Iterates through TopSites and counts types of images. + * @param acc Accumulator for reducer. + * @param topsite Entry in TopSites. + */ +function countTopSitesIconsTypes(topSites) { + const countTopSitesTypes = (acc, link) => { + acc[topSiteIconType(link)]++; + return acc; + }; + + return topSites.reduce(countTopSitesTypes, { + custom_screenshot: 0, + screenshot: 0, + tippytop: 0, + rich_icon: 0, + no_image: 0, + }); +} + +export class _TopSites extends React.PureComponent { + constructor(props) { + super(props); + this.onEditFormClose = this.onEditFormClose.bind(this); + this.onSearchShortcutsFormClose = + this.onSearchShortcutsFormClose.bind(this); + } + + /** + * Dispatch session statistics about the quality of TopSites icons and pinned count. + */ + _dispatchTopSitesStats() { + const topSites = this._getVisibleTopSites().filter( + topSite => topSite !== null && topSite !== undefined + ); + const topSitesIconsStats = countTopSitesIconsTypes(topSites); + const topSitesPinned = topSites.filter(site => !!site.isPinned).length; + const searchShortcuts = topSites.filter( + site => !!site.searchTopSite + ).length; + // Dispatch telemetry event with the count of TopSites images types. + this.props.dispatch( + ac.AlsoToMain({ + type: at.SAVE_SESSION_PERF_DATA, + data: { + topsites_icon_stats: topSitesIconsStats, + topsites_pinned: topSitesPinned, + topsites_search_shortcuts: searchShortcuts, + }, + }) + ); + } + + /** + * Return the TopSites that are visible based on prefs and window width. + */ + _getVisibleTopSites() { + // We hide 2 sites per row when not in the wide layout. + let sitesPerRow = TOP_SITES_MAX_SITES_PER_ROW; + // $break-point-widest = 1072px (from _variables.scss) + if (!global.matchMedia(`(min-width: 1072px)`).matches) { + sitesPerRow -= 2; + } + return this.props.TopSites.rows.slice( + 0, + this.props.TopSitesRows * sitesPerRow + ); + } + + componentDidUpdate() { + this._dispatchTopSitesStats(); + } + + componentDidMount() { + this._dispatchTopSitesStats(); + } + + onEditFormClose() { + this.props.dispatch( + ac.UserEvent({ + source: TOP_SITES_SOURCE, + event: "TOP_SITES_EDIT_CLOSE", + }) + ); + this.props.dispatch({ type: at.TOP_SITES_CANCEL_EDIT }); + } + + onSearchShortcutsFormClose() { + this.props.dispatch( + ac.UserEvent({ + source: TOP_SITES_SOURCE, + event: "SEARCH_EDIT_CLOSE", + }) + ); + this.props.dispatch({ type: at.TOP_SITES_CLOSE_SEARCH_SHORTCUTS_MODAL }); + } + + render() { + const { props } = this; + const { editForm, showSearchShortcutsForm } = props.TopSites; + const extraMenuOptions = ["AddTopSite"]; + const colors = props.Prefs.values["newNewtabExperience.colors"]; + + if (props.Prefs.values["improvesearch.topSiteSearchShortcuts"]) { + extraMenuOptions.push("AddSearchShortcut"); + } + + return ( + <ComponentPerfTimer + id="topsites" + initialized={props.TopSites.initialized} + dispatch={props.dispatch} + > + <CollapsibleSection + className="top-sites" + id="topsites" + title={props.title || { id: "newtab-section-header-topsites" }} + hideTitle={true} + extraMenuOptions={extraMenuOptions} + showPrefName="feeds.topsites" + eventSource={TOP_SITES_SOURCE} + collapsed={false} + isFixed={props.isFixed} + isFirst={props.isFirst} + isLast={props.isLast} + dispatch={props.dispatch} + > + <TopSiteList + TopSites={props.TopSites} + TopSitesRows={props.TopSitesRows} + dispatch={props.dispatch} + topSiteIconType={topSiteIconType} + colors={colors} + /> + <div className="edit-topsites-wrapper"> + {editForm && ( + <div className="edit-topsites"> + <ModalOverlayWrapper + unstyled={true} + onClose={this.onEditFormClose} + innerClassName="modal" + > + <TopSiteForm + site={props.TopSites.rows[editForm.index]} + onClose={this.onEditFormClose} + dispatch={this.props.dispatch} + {...editForm} + /> + </ModalOverlayWrapper> + </div> + )} + {showSearchShortcutsForm && ( + <div className="edit-search-shortcuts"> + <ModalOverlayWrapper + unstyled={true} + onClose={this.onSearchShortcutsFormClose} + innerClassName="modal" + > + <SearchShortcutsForm + TopSites={props.TopSites} + onClose={this.onSearchShortcutsFormClose} + dispatch={this.props.dispatch} + /> + </ModalOverlayWrapper> + </div> + )} + </div> + </CollapsibleSection> + </ComponentPerfTimer> + ); + } +} + +export const TopSites = connect((state, props) => ({ + TopSites: state.TopSites, + Prefs: state.Prefs, + TopSitesRows: state.Prefs.values.topSitesRows, +}))(_TopSites); diff --git a/browser/components/newtab/content-src/components/TopSites/TopSitesConstants.js b/browser/components/newtab/content-src/components/TopSites/TopSitesConstants.js new file mode 100644 index 0000000000..f488896238 --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/TopSitesConstants.js @@ -0,0 +1,39 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export const TOP_SITES_SOURCE = "TOP_SITES"; +export const TOP_SITES_CONTEXT_MENU_OPTIONS = [ + "CheckPinTopSite", + "EditTopSite", + "Separator", + "OpenInNewWindow", + "OpenInPrivateWindow", + "Separator", + "BlockUrl", + "DeleteUrl", +]; +export const TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS = [ + "OpenInNewWindow", + "OpenInPrivateWindow", + "Separator", + "BlockUrl", + "ShowPrivacyInfo", +]; +export const TOP_SITES_SPONSORED_POSITION_CONTEXT_MENU_OPTIONS = [ + "OpenInNewWindow", + "OpenInPrivateWindow", + "Separator", + "BlockUrl", + "AboutSponsored", +]; +// the special top site for search shortcut experiment can only have the option to unpin (which removes) the topsite +export const TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS = [ + "CheckPinTopSite", + "Separator", + "BlockUrl", +]; +// minimum size necessary to show a rich icon instead of a screenshot +export const MIN_RICH_FAVICON_SIZE = 96; +// minimum size necessary to show any icon +export const MIN_SMALL_FAVICON_SIZE = 16; diff --git a/browser/components/newtab/content-src/components/TopSites/_TopSites.scss b/browser/components/newtab/content-src/components/TopSites/_TopSites.scss new file mode 100644 index 0000000000..ff2e2df826 --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/_TopSites.scss @@ -0,0 +1,631 @@ +@use 'sass:math'; + +/* stylelint-disable max-nesting-depth */ + +$top-sites-size: $grid-unit-small; +$top-sites-border-radius: 8px; +$top-sites-icon-border-radius: 4px; +$rich-icon-size: 96px; +$default-icon-wrapper-size: 32px; +$default-icon-size: 32px; +$default-icon-offset: 6px; +$half-base-gutter: math.div($base-gutter, 2); +$hover-transition-duration: 150ms; +$letter-fallback-color: $white; + +.top-sites-list { + list-style: none; + margin: 0 (-$half-base-gutter); + padding: 0; + + a { + text-decoration: none; + } + + &:not(.dnd-active) { + .top-site-outer:is(.active, :focus, :hover) { + background: var(--newtab-element-hover-color); + } + } + + // Two columns + @media (max-width: $break-point-medium) { + > :nth-child(2n+1) { + @include context-menu-open-middle; + } + + > :nth-child(2n) { + @include context-menu-open-left; + } + } + + // Four columns + @media (min-width: $break-point-medium) and (max-width: $break-point-large) { + :nth-child(4n) { + @include context-menu-open-left; + } + } + + @media (min-width: $break-point-medium) and (max-width: $break-point-medium + $card-width) { + :nth-child(4n+3) { + @include context-menu-open-left; + } + } + + // Six columns + @media (min-width: $break-point-large) and (max-width: $break-point-large + 2 * $card-width) { + :nth-child(6n) { + @include context-menu-open-left; + } + } + + @media (min-width: $break-point-large) and (max-width: $break-point-large + $card-width) { + :nth-child(6n+5) { + @include context-menu-open-left; + } + } + + // Eight columns + @media (min-width: $break-point-widest) and (max-width: $break-point-widest + 2 * $card-width) { + :nth-child(8n) { + @include context-menu-open-left; + } + } + + @media (min-width: $break-point-widest) and (max-width: $break-point-widest + $card-width) { + :nth-child(8n+7) { + @include context-menu-open-left; + } + } + + .hide-for-narrow { + display: none; + } + + @media (min-width: $break-point-medium) { + .hide-for-narrow { + display: inline-block; + } + } + + @media (min-width: $break-point-large) { + .hide-for-narrow { + display: none; + } + } + + @media (min-width: $break-point-widest) { + .hide-for-narrow { + display: inline-block; + } + } +} + +// container for drop zone +.top-site-outer { + width: 120px; + border-radius: 8px; + display: inline-block; + margin-block-end: 16px; + + // container for context menu + .top-site-inner { + position: relative; + + > a { + padding: 20px $half-base-gutter 4px; + color: inherit; + display: block; + outline: none; + } + } + + &:is(:hover) { + .context-menu-button { + opacity: 1; + } + } + + .context-menu-button { + background-image: url('chrome://global/skin/icons/more.svg'); + border: 0; + border-radius: 4px; + cursor: pointer; + fill: var(--newtab-text-primary-color); + -moz-context-properties: fill; + height: 20px; + width: 20px; + inset-inline-end: 3px; + opacity: 0; + position: absolute; + top: -20px; + transition: opacity 200ms; + + &:is(:active, :focus) { + outline: 0; + opacity: 1; + background-color: var(--newtab-element-hover-color); + fill: var(--newtab-primary-action-background); + } + } + + .tile { + border-radius: $top-sites-border-radius; + box-shadow: $shadow-card; + background-color: var(--newtab-background-color-secondary); + justify-content: center; + margin: 0 auto; + height: $top-sites-size; + width: $top-sites-size; + cursor: pointer; + position: relative; + + // For letter fallback + align-items: center; + color: var(--newtab-text-secondary-color); + display: flex; + font-size: 32px; + font-weight: 200; + text-transform: uppercase; + + .icon-wrapper { + border-radius: 4px; + width: 48px; + height: 48px; + overflow: hidden; + position: relative; + display: flex; + align-items: center; + justify-content: center; + + &.letter-fallback::before { + content: attr(data-fallback); + text-transform: uppercase; + display: flex; + align-items: center; + justify-content: center; + font-size: 64px; + font-weight: 800; + transform: rotate(-10deg); + top: 6px; + position: relative; + color: $letter-fallback-color; + } + } + } + + // Some common styles for all icons (rich and default) in top sites + .top-site-icon { + background-color: var(--newtab-background-color-secondary); + background-position: center center; + background-repeat: no-repeat; + border-radius: $top-sites-icon-border-radius; + position: absolute; + } + + .rich-icon { + background-size: cover; + height: 100%; + inset-inline-start: 0; + top: 0; + width: 100%; + } + + .default-icon, + .search-topsite { + background-size: $default-icon-size; + height: $default-icon-wrapper-size; + width: $default-icon-wrapper-size; + + // for corner letter fallback + align-items: center; + display: flex; + font-size: 20px; + justify-content: center; + + &[data-fallback]::before { + content: attr(data-fallback); + } + } + + .search-topsite { + background-image: url('chrome://global/skin/icons/search-glass.svg'); + background-size: 16px; + background-color: var(--newtab-primary-action-background); + border-radius: $default-icon-wrapper-size; + -moz-context-properties: fill; + fill: var(--newtab-primary-element-text-color); + box-shadow: $shadow-card; + transition-duration: $hover-transition-duration; + transition-property: background-size, bottom, inset-inline-end, height, width; + height: 32px; + width: 32px; + bottom: -$default-icon-offset; + inset-inline-end: -$default-icon-offset; + } + + &.placeholder { + .tile { + box-shadow: $inner-box-shadow; + } + } + + .title { + color: var(--newtab-text-primary-color); + padding-top: 8px; + font: caption; + text-align: center; + position: relative; + + .icon { + margin-inline-end: 2px; + fill: var(--newtab-text-primary-color); + } + + span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .sponsored-label { + color: var(--newtab-text-secondary-color); + font-size: 0.9em; + } + + &:not(.sponsored) .sponsored-label { + visibility: hidden; + } + } + + // We want all search shortcuts to have a white background in case they have transparency. + &.search-shortcut { + .rich-icon { + background-color: $white; + } + } + + .edit-button { + background-image: url('chrome://global/skin/icons/edit.svg'); + } + + &.dragged { + .tile { + *, + &::before { + display: none; + } + } + + .title { + visibility: hidden; + } + } +} + +.edit-topsites-wrapper { + .top-site-inner > .top-site-button > .tile { + border: 1px solid var(--newtab-border-color); + } + + .modal { + box-shadow: $shadow-secondary; + left: 0; + margin: 0 auto; + max-height: calc(100% - 40px); + position: fixed; + right: 0; + top: 40px; + width: $wrapper-default-width; + + @media (min-width: $break-point-medium) { + width: $wrapper-max-width-medium; + } + + @media (min-width: $break-point-large) { + width: $wrapper-max-width-large; + } + } +} + +.topsite-form { + $form-width: 300px; + $form-spacing: 32px; + + .section-title { + font-size: 16px; + margin: 0 0 16px; + } + + .form-input-container { + max-width: $form-width + 3 * $form-spacing + $rich-icon-size; + margin: 0 auto; + padding: $form-spacing; + + .top-site-outer { + pointer-events: none; + } + } + + .search-shortcuts-container { + max-width: 700px; + margin: 0 auto; + padding: $form-spacing; + + > div { + margin-inline-end: -39px; + } + + .top-site-outer { + margin-inline-start: 0; + margin-inline-end: 39px; + } + } + + .top-site-outer { + padding: 0; + margin: 24px 0 0; + margin-inline-start: $form-spacing; + } + + .fields-and-preview { + display: flex; + } + + label { + font-size: $section-title-font-size; + } + + .form-wrapper { + width: 100%; + + .field { + position: relative; + + .icon-clear-input { + position: absolute; + transform: translateY(-50%); + top: 50%; + inset-inline-end: 8px; + } + } + + .url { + input:dir(ltr) { + padding-right: 32px; + } + + input:dir(rtl) { + padding-left: 32px; + + &:not(:placeholder-shown) { + direction: ltr; + text-align: right; + } + } + } + + .enable-custom-image-input { + display: inline-block; + font-size: 13px; + margin-top: 4px; + cursor: pointer; + } + + .custom-image-input-container { + margin-top: 4px; + + .loading-container { + width: 16px; + height: 16px; + overflow: hidden; + position: absolute; + transform: translateY(-50%); + top: 50%; + inset-inline-end: 8px; + } + + // This animation is derived from Firefox's tab loading animation + // See https://searchfox.org/mozilla-central/rev/b29daa46443b30612415c35be0a3c9c13b9dc5f6/browser/themes/shared/tabs.inc.css#208-216 + .loading-animation { + @keyframes tab-throbber-animation { + 100% { transform: translateX(-960px); } + } + + @keyframes tab-throbber-animation-rtl { + 100% { transform: translateX(960px); } + } + + width: 960px; + height: 16px; + -moz-context-properties: fill; + fill: var(--newtab-primary-action-background); + background-image: url('chrome://browser/skin/tabbrowser/loading.svg'); + animation: tab-throbber-animation 1.05s steps(60) infinite; + + &:dir(rtl) { + animation-name: tab-throbber-animation-rtl; + } + } + } + + input { + &[type='text'] { + background-color: var(--newtab-background-color-secondary); + border: $input-border; + margin: 8px 0; + padding: 0 8px; + height: 32px; + width: 100%; + font-size: 15px; + + &[disabled] { + border: $input-border; + box-shadow: none; + opacity: 0.4; + } + } + } + + .invalid { + input { + &[type='text'] { + border: $input-error-border; + box-shadow: $input-error-boxshadow; + } + } + } + + .error-tooltip { + animation: fade-up-tt 450ms; + background: var(--newtab-status-error); + border-radius: 2px; + color: $white; + inset-inline-start: 3px; + padding: 5px 12px; + position: absolute; + top: 44px; + z-index: 1; + + // tooltip caret + &::before { + background: var(--newtab-status-error); + bottom: -8px; + content: '.'; + height: 16px; + inset-inline-start: 12px; + position: absolute; + text-indent: -999px; + top: -7px; + transform: rotate(45deg); + white-space: nowrap; + width: 16px; + z-index: -1; + } + } + } + + .actions { + justify-content: flex-end; + + button { + margin-inline-start: 10px; + margin-inline-end: 0; + } + } + + @media (max-width: $break-point-medium) { + .fields-and-preview { + flex-direction: column; + + .top-site-outer { + margin-inline-start: 0; + } + } + } + + // prevent text selection of keyword label when clicking to select + .title { + user-select: none; + } + + // CSS styled checkbox + [type='checkbox']:not(:checked), + [type='checkbox']:checked { + inset-inline-start: -9999px; + position: absolute; + } + + [type='checkbox']:not(:checked) + label, + [type='checkbox']:checked + label { + cursor: pointer; + display: block; + position: relative; + } + + $checkbox-offset: -8px; + + [type='checkbox']:not(:checked) + label::before, + [type='checkbox']:checked + label::before { + background: var(--newtab-background-color); + border: $input-border; + border-radius: $border-radius; + content: ''; + height: 21px; + left: $checkbox-offset; + position: absolute; + top: $checkbox-offset; + width: 21px; + z-index: 1; + + [dir='rtl'] & { + left: auto; + right: $checkbox-offset; + } + } + + // checkmark + [type='checkbox']:not(:checked) + label::after, + [type='checkbox']:checked + label::after { + background: url('chrome://global/skin/icons/check.svg') no-repeat center center; + content: ''; + height: 21px; + left: $checkbox-offset; + position: absolute; + top: $checkbox-offset; + width: 21px; + -moz-context-properties: fill; + fill: var(--newtab-primary-action-background); + z-index: 2; + + [dir='rtl'] & { + left: auto; + right: $checkbox-offset; + } + } + + // when selected, highlight the tile + [type='checkbox']:checked + label { + .tile { + box-shadow: $shadow-focus; + } + } + + // checkmark changes + [type='checkbox']:not(:checked) + label::after { + opacity: 0; + } + + [type='checkbox']:checked + label::after { + opacity: 1; + } + + // accessibility + [type='checkbox']:checked:focus + label::before, + [type='checkbox']:not(:checked):focus + label::before { + border: 1px dotted var(--newtab-primary-action-background); + } +} + +// used for tooltips below form element +@keyframes fade-up-tt { + 0% { + opacity: 0; + transform: translateY(15px); + } + + 100% { + opacity: 1; + transform: translateY(0); + } +} + +// used for TopSites impression wrapper +.topsite-impression-observer { + position: absolute; + top: 0; + width: 100%; + height: 100%; + pointer-events: none; +} diff --git a/browser/components/newtab/content-src/components/Topics/Topics.jsx b/browser/components/newtab/content-src/components/Topics/Topics.jsx new file mode 100644 index 0000000000..ef59094c65 --- /dev/null +++ b/browser/components/newtab/content-src/components/Topics/Topics.jsx @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; + +export class Topic extends React.PureComponent { + render() { + const { url, name } = this.props; + return ( + <li> + <a key={name} href={url}> + {name} + </a> + </li> + ); + } +} + +export class Topics extends React.PureComponent { + render() { + const { topics } = this.props; + return ( + <span className="topics"> + <span data-l10n-id="newtab-pocket-read-more" /> + <ul> + {topics && + topics.map(t => <Topic key={t.name} url={t.url} name={t.name} />)} + </ul> + </span> + ); + } +} diff --git a/browser/components/newtab/content-src/components/Topics/_Topics.scss b/browser/components/newtab/content-src/components/Topics/_Topics.scss new file mode 100644 index 0000000000..205f42e600 --- /dev/null +++ b/browser/components/newtab/content-src/components/Topics/_Topics.scss @@ -0,0 +1,24 @@ +.topics { + ul { + margin: 0; + padding: 0; + + @media (min-width: $break-point-large) { + display: inline; + padding-inline-start: 12px; + } + } + + ul li { + display: inline-block; + + &::after { + content: '•'; + padding: 8px; + } + + &:last-child::after { + content: none; + } + } +} diff --git a/browser/components/newtab/content-src/lib/constants.js b/browser/components/newtab/content-src/lib/constants.js new file mode 100644 index 0000000000..2c96160b4b --- /dev/null +++ b/browser/components/newtab/content-src/lib/constants.js @@ -0,0 +1,38 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export const IS_NEWTAB = + global.document && global.document.documentURI === "about:newtab"; +export const NEWTAB_DARK_THEME = { + ntp_background: { + r: 42, + g: 42, + b: 46, + a: 1, + }, + ntp_card_background: { + r: 66, + g: 65, + b: 77, + a: 1, + }, + ntp_text: { + r: 249, + g: 249, + b: 250, + a: 1, + }, + sidebar: { + r: 56, + g: 56, + b: 61, + a: 1, + }, + sidebar_text: { + r: 249, + g: 249, + b: 250, + a: 1, + }, +}; diff --git a/browser/components/newtab/content-src/lib/detect-user-session-start.js b/browser/components/newtab/content-src/lib/detect-user-session-start.js new file mode 100644 index 0000000000..43aa388967 --- /dev/null +++ b/browser/components/newtab/content-src/lib/detect-user-session-start.js @@ -0,0 +1,82 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { perfService as perfSvc } from "content-src/lib/perf-service"; + +const VISIBLE = "visible"; +const VISIBILITY_CHANGE_EVENT = "visibilitychange"; + +export class DetectUserSessionStart { + constructor(store, options = {}) { + this._store = store; + // Overrides for testing + this.document = options.document || global.document; + this._perfService = options.perfService || perfSvc; + this._onVisibilityChange = this._onVisibilityChange.bind(this); + } + + /** + * sendEventOrAddListener - Notify immediately if the page is already visible, + * or else set up a listener for when visibility changes. + * This is needed for accurate session tracking for telemetry, + * because tabs are pre-loaded. + */ + sendEventOrAddListener() { + if (this.document.visibilityState === VISIBLE) { + // If the document is already visible, to the user, send a notification + // immediately that a session has started. + this._sendEvent(); + } else { + // If the document is not visible, listen for when it does become visible. + this.document.addEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + /** + * _sendEvent - Sends a message to the main process to indicate the current + * tab is now visible to the user, includes the + * visibility_event_rcvd_ts time in ms from the UNIX epoch. + */ + _sendEvent() { + this._perfService.mark("visibility_event_rcvd_ts"); + + try { + let visibility_event_rcvd_ts = + this._perfService.getMostRecentAbsMarkStartByName( + "visibility_event_rcvd_ts" + ); + + this._store.dispatch( + ac.AlsoToMain({ + type: at.SAVE_SESSION_PERF_DATA, + data: { visibility_event_rcvd_ts }, + }) + ); + } catch (ex) { + // If this failed, it's likely because the `privacy.resistFingerprinting` + // pref is true. We should at least not blow up. + } + } + + /** + * _onVisibilityChange - If the visibility has changed to visible, sends a notification + * and removes the event listener. This should only be called once per tab. + */ + _onVisibilityChange() { + if (this.document.visibilityState === VISIBLE) { + this._sendEvent(); + this.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } +} diff --git a/browser/components/newtab/content-src/lib/init-store.js b/browser/components/newtab/content-src/lib/init-store.js new file mode 100644 index 0000000000..20fcedc6c0 --- /dev/null +++ b/browser/components/newtab/content-src/lib/init-store.js @@ -0,0 +1,140 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint-env mozilla/remote-page */ + +import { + actionCreators as ac, + actionTypes as at, + actionUtils as au, +} from "common/Actions.sys.mjs"; +import { applyMiddleware, combineReducers, createStore } from "redux"; + +export const MERGE_STORE_ACTION = "NEW_TAB_INITIAL_STATE"; +export const OUTGOING_MESSAGE_NAME = "ActivityStream:ContentToMain"; +export const INCOMING_MESSAGE_NAME = "ActivityStream:MainToContent"; + +/** + * 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); + }; +}; + +/** + * 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(rehydrationMiddleware, messageMiddleware) + ); + + if (global.RPMAddMessageListener) { + global.RPMAddMessageListener(INCOMING_MESSAGE_NAME, msg => { + try { + store.dispatch(msg.data); + } catch (ex) { + console.error("Content msg:", msg, "Dispatch error: ", ex); + dump( + `Content msg: ${JSON.stringify(msg)}\nDispatch error: ${ex}\n${ + ex.stack + }` + ); + } + }); + } + + return store; +} diff --git a/browser/components/newtab/content-src/lib/link-menu-options.js b/browser/components/newtab/content-src/lib/link-menu-options.js new file mode 100644 index 0000000000..caac738170 --- /dev/null +++ b/browser/components/newtab/content-src/lib/link-menu-options.js @@ -0,0 +1,309 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; + +const _OpenInPrivateWindow = site => ({ + id: "newtab-menu-open-new-private-window", + icon: "new-window-private", + action: ac.OnlyToMain({ + type: at.OPEN_PRIVATE_WINDOW, + data: { url: site.url, referrer: site.referrer }, + }), + userEvent: "OPEN_PRIVATE_WINDOW", +}); + +/** + * List of functions that return items that can be included as menu options in a + * LinkMenu. All functions take the site as the first parameter, and optionally + * the index of the site. + */ +export const LinkMenuOptions = { + Separator: () => ({ type: "separator" }), + EmptyItem: () => ({ type: "empty" }), + ShowPrivacyInfo: site => ({ + id: "newtab-menu-show-privacy-info", + icon: "info", + action: { + type: at.SHOW_PRIVACY_INFO, + }, + userEvent: "SHOW_PRIVACY_INFO", + }), + AboutSponsored: site => ({ + id: "newtab-menu-show-privacy-info", + icon: "info", + action: ac.AlsoToMain({ + type: at.ABOUT_SPONSORED_TOP_SITES, + data: { + advertiser_name: (site.label || site.hostname).toLocaleLowerCase(), + position: site.sponsored_position, + tile_id: site.sponsored_tile_id, + }, + }), + userEvent: "TOPSITE_SPONSOR_INFO", + }), + RemoveBookmark: site => ({ + id: "newtab-menu-remove-bookmark", + icon: "bookmark-added", + action: ac.AlsoToMain({ + type: at.DELETE_BOOKMARK_BY_ID, + data: site.bookmarkGuid, + }), + userEvent: "BOOKMARK_DELETE", + }), + AddBookmark: site => ({ + id: "newtab-menu-bookmark", + icon: "bookmark-hollow", + action: ac.AlsoToMain({ + type: at.BOOKMARK_URL, + data: { url: site.url, title: site.title, type: site.type }, + }), + userEvent: "BOOKMARK_ADD", + }), + OpenInNewWindow: site => ({ + id: "newtab-menu-open-new-window", + icon: "new-window", + action: ac.AlsoToMain({ + type: at.OPEN_NEW_WINDOW, + data: { + referrer: site.referrer, + typedBonus: site.typedBonus, + url: site.url, + sponsored_tile_id: site.sponsored_tile_id, + }, + }), + userEvent: "OPEN_NEW_WINDOW", + }), + // This blocks the url for regular stories, + // but also sends a message to DiscoveryStream with flight_id. + // If DiscoveryStream sees this message for a flight_id + // it also blocks it on the flight_id. + BlockUrl: (site, index, eventSource) => { + return LinkMenuOptions.BlockUrls([site], index, eventSource); + }, + // Same as BlockUrl, cept can work on an array of sites. + BlockUrls: (tiles, pos, eventSource) => ({ + id: "newtab-menu-dismiss", + icon: "dismiss", + action: ac.AlsoToMain({ + type: at.BLOCK_URL, + data: tiles.map(site => ({ + url: site.original_url || site.open_url || site.url, + // pocket_id is only for pocket stories being in highlights, and then dismissed. + pocket_id: site.pocket_id, + // used by PlacesFeed and TopSitesFeed for sponsored top sites blocking. + isSponsoredTopSite: site.sponsored_position, + ...(site.flight_id ? { flight_id: site.flight_id } : {}), + // If not sponsored, hostname could be anything (Cat3 Data!). + // So only put in advertiser_name for sponsored topsites. + ...(site.sponsored_position + ? { + advertiser_name: ( + site.label || site.hostname + )?.toLocaleLowerCase(), + } + : {}), + position: pos, + ...(site.sponsored_tile_id ? { tile_id: site.sponsored_tile_id } : {}), + is_pocket_card: site.type === "CardGrid", + })), + }), + impression: ac.ImpressionStats({ + source: eventSource, + block: 0, + tiles: tiles.map((site, index) => ({ + id: site.guid, + pos: pos + index, + ...(site.shim && site.shim.delete ? { shim: site.shim.delete } : {}), + })), + }), + userEvent: "BLOCK", + }), + + // This is an option for web extentions which will result in remove items from + // memory and notify the web extenion, rather than using the built-in block list. + WebExtDismiss: (site, index, eventSource) => ({ + id: "menu_action_webext_dismiss", + string_id: "newtab-menu-dismiss", + icon: "dismiss", + action: ac.WebExtEvent(at.WEBEXT_DISMISS, { + source: eventSource, + url: site.url, + action_position: index, + }), + }), + DeleteUrl: (site, index, eventSource, isEnabled, siteInfo) => ({ + id: "newtab-menu-delete-history", + icon: "delete", + action: { + type: at.DIALOG_OPEN, + data: { + onConfirm: [ + ac.AlsoToMain({ + type: at.DELETE_HISTORY_URL, + data: { + url: site.url, + pocket_id: site.pocket_id, + forceBlock: site.bookmarkGuid, + }, + }), + ac.UserEvent( + Object.assign( + { event: "DELETE", source: eventSource, action_position: index }, + siteInfo + ) + ), + ], + eventSource, + body_string_id: [ + "newtab-confirm-delete-history-p1", + "newtab-confirm-delete-history-p2", + ], + confirm_button_string_id: "newtab-topsites-delete-history-button", + cancel_button_string_id: "newtab-topsites-cancel-button", + icon: "modal-delete", + }, + }, + userEvent: "DIALOG_OPEN", + }), + ShowFile: site => ({ + id: "newtab-menu-show-file", + icon: "search", + action: ac.OnlyToMain({ + type: at.SHOW_DOWNLOAD_FILE, + data: { url: site.url }, + }), + }), + OpenFile: site => ({ + id: "newtab-menu-open-file", + icon: "open-file", + action: ac.OnlyToMain({ + type: at.OPEN_DOWNLOAD_FILE, + data: { url: site.url }, + }), + }), + CopyDownloadLink: site => ({ + id: "newtab-menu-copy-download-link", + icon: "copy", + action: ac.OnlyToMain({ + type: at.COPY_DOWNLOAD_LINK, + data: { url: site.url }, + }), + }), + GoToDownloadPage: site => ({ + id: "newtab-menu-go-to-download-page", + icon: "download", + action: ac.OnlyToMain({ + type: at.OPEN_LINK, + data: { url: site.referrer }, + }), + disabled: !site.referrer, + }), + RemoveDownload: site => ({ + id: "newtab-menu-remove-download", + icon: "delete", + action: ac.OnlyToMain({ + type: at.REMOVE_DOWNLOAD_FILE, + data: { url: site.url }, + }), + }), + PinTopSite: (site, index) => ({ + id: "newtab-menu-pin", + icon: "pin", + action: ac.AlsoToMain({ + type: at.TOP_SITES_PIN, + data: { + site, + index, + }, + }), + userEvent: "PIN", + }), + UnpinTopSite: site => ({ + id: "newtab-menu-unpin", + icon: "unpin", + action: ac.AlsoToMain({ + type: at.TOP_SITES_UNPIN, + data: { site: { url: site.url } }, + }), + userEvent: "UNPIN", + }), + SaveToPocket: (site, index, eventSource = "CARDGRID") => ({ + id: "newtab-menu-save-to-pocket", + icon: "pocket-save", + action: ac.AlsoToMain({ + type: at.SAVE_TO_POCKET, + data: { + site: { url: site.url, title: site.title }, + }, + }), + impression: ac.ImpressionStats({ + source: eventSource, + pocket: 0, + tiles: [ + { + id: site.guid, + pos: index, + ...(site.shim && site.shim.save ? { shim: site.shim.save } : {}), + }, + ], + }), + userEvent: "SAVE_TO_POCKET", + }), + DeleteFromPocket: site => ({ + id: "newtab-menu-delete-pocket", + icon: "pocket-delete", + action: ac.AlsoToMain({ + type: at.DELETE_FROM_POCKET, + data: { pocket_id: site.pocket_id }, + }), + userEvent: "DELETE_FROM_POCKET", + }), + ArchiveFromPocket: site => ({ + id: "newtab-menu-archive-pocket", + icon: "pocket-archive", + action: ac.AlsoToMain({ + type: at.ARCHIVE_FROM_POCKET, + data: { pocket_id: site.pocket_id }, + }), + userEvent: "ARCHIVE_FROM_POCKET", + }), + EditTopSite: (site, index) => ({ + id: "newtab-menu-edit-topsites", + icon: "edit", + action: { + type: at.TOP_SITES_EDIT, + data: { index }, + }, + }), + CheckBookmark: site => + site.bookmarkGuid + ? LinkMenuOptions.RemoveBookmark(site) + : LinkMenuOptions.AddBookmark(site), + CheckPinTopSite: (site, index) => + site.isPinned + ? LinkMenuOptions.UnpinTopSite(site) + : LinkMenuOptions.PinTopSite(site, index), + CheckSavedToPocket: (site, index, source) => + site.pocket_id + ? LinkMenuOptions.DeleteFromPocket(site) + : LinkMenuOptions.SaveToPocket(site, index, source), + CheckBookmarkOrArchive: site => + site.pocket_id + ? LinkMenuOptions.ArchiveFromPocket(site) + : LinkMenuOptions.CheckBookmark(site), + CheckArchiveFromPocket: site => + site.pocket_id + ? LinkMenuOptions.ArchiveFromPocket(site) + : LinkMenuOptions.EmptyItem(), + CheckDeleteFromPocket: site => + site.pocket_id + ? LinkMenuOptions.DeleteFromPocket(site) + : LinkMenuOptions.EmptyItem(), + OpenInPrivateWindow: (site, index, eventSource, isEnabled) => + isEnabled ? _OpenInPrivateWindow(site) : LinkMenuOptions.EmptyItem(), +}; diff --git a/browser/components/newtab/content-src/lib/perf-service.js b/browser/components/newtab/content-src/lib/perf-service.js new file mode 100644 index 0000000000..6ea99ce877 --- /dev/null +++ b/browser/components/newtab/content-src/lib/perf-service.js @@ -0,0 +1,104 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +let usablePerfObj = window.performance; + +export function _PerfService(options) { + // For testing, so that we can use a fake Window.performance object with + // known state. + if (options && options.performanceObj) { + this._perf = options.performanceObj; + } else { + this._perf = usablePerfObj; + } +} + +_PerfService.prototype = { + /** + * Calls the underlying mark() method on the appropriate Window.performance + * object to add a mark with the given name to the appropriate performance + * timeline. + * + * @param {String} name the name to give the current mark + * @return {void} + */ + mark: function mark(str) { + this._perf.mark(str); + }, + + /** + * Calls the underlying getEntriesByName on the appropriate Window.performance + * object. + * + * @param {String} name + * @param {String} type eg "mark" + * @return {Array} Performance* objects + */ + getEntriesByName: function getEntriesByName(name, type) { + return this._perf.getEntriesByName(name, type); + }, + + /** + * The timeOrigin property from the appropriate performance object. + * Used to ensure that timestamps from the add-on code and the content code + * are comparable. + * + * @note If this is called from a context without a window + * (eg a JSM in chrome), it will return the timeOrigin of the XUL hidden + * window, which appears to be the first created window (and thus + * timeOrigin) in the browser. Note also, however, there is also a private + * hidden window, presumably for private browsing, which appears to be + * created dynamically later. Exactly how/when that shows up needs to be + * investigated. + * + * @return {Number} A double of milliseconds with a precision of 0.5us. + */ + get timeOrigin() { + return this._perf.timeOrigin; + }, + + /** + * Returns the "absolute" version of performance.now(), i.e. one that + * should ([bug 1401406](https://bugzilla.mozilla.org/show_bug.cgi?id=1401406) + * be comparable across both chrome and content. + * + * @return {Number} + */ + absNow: function absNow() { + return this.timeOrigin + this._perf.now(); + }, + + /** + * This returns the absolute startTime from the most recent performance.mark() + * with the given name. + * + * @param {String} name the name to lookup the start time for + * + * @return {Number} the returned start time, as a DOMHighResTimeStamp + * + * @throws {Error} "No Marks with the name ..." if none are available + * + * @note Always surround calls to this by try/catch. Otherwise your code + * may fail when the `privacy.resistFingerprinting` pref is true. When + * this pref is set, all attempts to get marks will likely fail, which will + * cause this method to throw. + * + * See [bug 1369303](https://bugzilla.mozilla.org/show_bug.cgi?id=1369303) + * for more info. + */ + getMostRecentAbsMarkStartByName(name) { + let entries = this.getEntriesByName(name, "mark"); + + if (!entries.length) { + throw new Error(`No marks with the name ${name}`); + } + + let mostRecentEntry = entries[entries.length - 1]; + return this._perf.timeOrigin + mostRecentEntry.startTime; + }, +}; + +export const perfService = new _PerfService(); diff --git a/browser/components/newtab/content-src/lib/screenshot-utils.js b/browser/components/newtab/content-src/lib/screenshot-utils.js new file mode 100644 index 0000000000..7ea93f12ae --- /dev/null +++ b/browser/components/newtab/content-src/lib/screenshot-utils.js @@ -0,0 +1,61 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * List of helper functions for screenshot-based images. + * + * There are two kinds of images: + * 1. Remote Image: This is the image from the main process and it refers to + * the image in the React props. This can either be an object with the `data` + * and `path` properties, if it is a blob, or a string, if it is a normal image. + * 2. Local Image: This is the image object in the content process and it refers + * to the image *object* in the React component's state. All local image + * objects have the `url` property, and an additional property `path`, if they + * are blobs. + */ +export const ScreenshotUtils = { + isBlob(isLocal, image) { + return !!( + image && + image.path && + ((!isLocal && image.data) || (isLocal && image.url)) + ); + }, + + // This should always be called with a remote image and not a local image. + createLocalImageObject(remoteImage) { + if (!remoteImage) { + return null; + } + if (this.isBlob(false, remoteImage)) { + return { + url: global.URL.createObjectURL(remoteImage.data), + path: remoteImage.path, + }; + } + return { url: remoteImage }; + }, + + // Revokes the object URL of the image if the local image is a blob. + // This should always be called with a local image and not a remote image. + maybeRevokeBlobObjectURL(localImage) { + if (this.isBlob(true, localImage)) { + global.URL.revokeObjectURL(localImage.url); + } + }, + + // Checks if remoteImage and localImage are the same. + isRemoteImageLocal(localImage, remoteImage) { + // Both remoteImage and localImage are present. + if (remoteImage && localImage) { + return this.isBlob(false, remoteImage) + ? localImage.path === remoteImage.path + : localImage.url === remoteImage; + } + + // This will only handle the remaining three possible outcomes. + // (i.e. everything except when both image and localImage are present) + return !remoteImage && !localImage; + }, +}; diff --git a/browser/components/newtab/content-src/lib/selectLayoutRender.js b/browser/components/newtab/content-src/lib/selectLayoutRender.js new file mode 100644 index 0000000000..aa3eb927d2 --- /dev/null +++ b/browser/components/newtab/content-src/lib/selectLayoutRender.js @@ -0,0 +1,255 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export const selectLayoutRender = ({ state = {}, prefs = {}, locale = "" }) => { + const { layout, feeds, spocs } = state; + let spocIndexPlacementMap = {}; + + /* This function fills spoc positions on a per placement basis with available spocs. + * It does this by looping through each position for a placement and replacing a rec with a spoc. + * If it runs out of spocs or positions, it stops. + * If it sees the same placement again, it remembers the previous spoc index, and continues. + * If it sees a blocked spoc, it skips that position leaving in a regular story. + */ + function fillSpocPositionsForPlacement( + data, + spocsConfig, + spocsData, + placementName + ) { + if ( + !spocIndexPlacementMap[placementName] && + spocIndexPlacementMap[placementName] !== 0 + ) { + spocIndexPlacementMap[placementName] = 0; + } + const results = [...data]; + for (let position of spocsConfig.positions) { + const spoc = spocsData[spocIndexPlacementMap[placementName]]; + // If there are no spocs left, we can stop filling positions. + if (!spoc) { + break; + } + + // A placement could be used in two sections. + // In these cases, we want to maintain the index of the previous section. + // If we didn't do this, it might duplicate spocs. + spocIndexPlacementMap[placementName]++; + + // A spoc that's blocked is removed from the source for subsequent newtab loads. + // If we have a spoc in the source that's blocked, it means it was *just* blocked, + // and in this case, we skip this position, and show a regular spoc instead. + if (!spocs.blocked.includes(spoc.url)) { + results.splice(position.index, 0, spoc); + } + } + + return results; + } + + const positions = {}; + const DS_COMPONENTS = [ + "Message", + "TextPromo", + "SectionTitle", + "Signup", + "Navigation", + "CardGrid", + "CollectionCardGrid", + "HorizontalRule", + "PrivacyLink", + ]; + + const filterArray = []; + + if (!prefs["feeds.topsites"]) { + filterArray.push("TopSites"); + } + + const pocketEnabled = + prefs["feeds.section.topstories"] && prefs["feeds.system.topstories"]; + if (!pocketEnabled) { + filterArray.push(...DS_COMPONENTS); + } + + const placeholderComponent = component => { + if (!component.feed) { + // TODO we now need a placeholder for topsites and textPromo. + return { + ...component, + data: { + spocs: [], + }, + }; + } + const data = { + recommendations: [], + }; + + let items = 0; + if (component.properties && component.properties.items) { + items = component.properties.items; + } + for (let i = 0; i < items; i++) { + data.recommendations.push({ placeholder: true }); + } + + return { ...component, data }; + }; + + // TODO update devtools to show placements + const handleSpocs = (data, component) => { + let result = [...data]; + // Do we ever expect to possibly have a spoc. + if ( + component.spocs && + component.spocs.positions && + component.spocs.positions.length + ) { + const placement = component.placement || {}; + const placementName = placement.name || "spocs"; + const spocsData = spocs.data[placementName]; + // We expect a spoc, spocs are loaded, and the server returned spocs. + if ( + spocs.loaded && + spocsData && + spocsData.items && + spocsData.items.length + ) { + result = fillSpocPositionsForPlacement( + result, + component.spocs, + spocsData.items, + placementName + ); + } + } + return result; + }; + + const handleComponent = component => { + if ( + component.spocs && + component.spocs.positions && + component.spocs.positions.length + ) { + const placement = component.placement || {}; + const placementName = placement.name || "spocs"; + const spocsData = spocs.data[placementName]; + if ( + spocs.loaded && + spocsData && + spocsData.items && + spocsData.items.length + ) { + return { + ...component, + data: { + spocs: spocsData.items + .filter(spoc => spoc && !spocs.blocked.includes(spoc.url)) + .map((spoc, index) => ({ + ...spoc, + pos: index, + })), + }, + }; + } + } + return { + ...component, + data: { + spocs: [], + }, + }; + }; + + const handleComponentWithFeed = component => { + positions[component.type] = positions[component.type] || 0; + let data = { + recommendations: [], + }; + + const feed = feeds.data[component.feed.url]; + if (feed && feed.data) { + data = { + ...feed.data, + recommendations: [...(feed.data.recommendations || [])], + }; + } + + if (component && component.properties && component.properties.offset) { + data = { + ...data, + recommendations: data.recommendations.slice( + component.properties.offset + ), + }; + } + + data = { + ...data, + recommendations: handleSpocs(data.recommendations, component), + }; + + let items = 0; + if (component.properties && component.properties.items) { + items = Math.min(component.properties.items, data.recommendations.length); + } + + // loop through a component items + // Store the items position sequentially for multiple components of the same type. + // Example: A second card grid starts pos offset from the last card grid. + for (let i = 0; i < items; i++) { + data.recommendations[i] = { + ...data.recommendations[i], + pos: positions[component.type]++, + }; + } + + return { ...component, data }; + }; + + const renderLayout = () => { + const renderedLayoutArray = []; + for (const row of layout.filter( + r => r.components.filter(c => !filterArray.includes(c.type)).length + )) { + let components = []; + renderedLayoutArray.push({ + ...row, + components, + }); + for (const component of row.components.filter( + c => !filterArray.includes(c.type) + )) { + const spocsConfig = component.spocs; + if (spocsConfig || component.feed) { + // TODO make sure this still works for different loading cases. + if ( + (component.feed && !feeds.data[component.feed.url]) || + (spocsConfig && + spocsConfig.positions && + spocsConfig.positions.length && + !spocs.loaded) + ) { + components.push(placeholderComponent(component)); + return renderedLayoutArray; + } + if (component.feed) { + components.push(handleComponentWithFeed(component)); + } else { + components.push(handleComponent(component)); + } + } else { + components.push(component); + } + } + } + return renderedLayoutArray; + }; + + const layoutRender = renderLayout(); + + return { layoutRender }; +}; diff --git a/browser/components/newtab/content-src/styles/_activity-stream.scss b/browser/components/newtab/content-src/styles/_activity-stream.scss new file mode 100644 index 0000000000..8bd3a7a397 --- /dev/null +++ b/browser/components/newtab/content-src/styles/_activity-stream.scss @@ -0,0 +1,172 @@ +@import './normalize'; +@import './variables'; +@import './theme'; +@import './icons'; +@import './mixins'; + +html { + height: 100%; +} + +body, +#root { + min-height: 100vh; +} + +#root { + position: relative; +} + +body { + background-color: var(--newtab-background-color); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Ubuntu, 'Helvetica Neue', sans-serif; + font-size: 16px; +} + +.no-scroll { + overflow: hidden; +} + +h1, +h2 { + font-weight: normal; +} + +.inner-border { + border: $border-secondary; + border-radius: $border-radius; + height: 100%; + left: 0; + pointer-events: none; + position: absolute; + top: 0; + width: 100%; + z-index: 100; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.show-on-init { + opacity: 0; + transition: opacity 0.2s ease-in; + + &.on { + animation: fadeIn 0.2s; + opacity: 1; + } +} + +.actions { + border-top: $border-secondary; + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: flex-start; + margin: 0; + padding: 15px 25px 0; +} + +// Default button (grey) +.button, +.actions button { + background-color: var(--newtab-button-secondary-color); + border: $border-primary; + border-radius: 4px; + color: inherit; + cursor: pointer; + margin-bottom: 15px; + padding: 10px 30px; + white-space: nowrap; + + &:hover:not(.dismiss), + &:focus:not(.dismiss) { + box-shadow: $shadow-primary; + transition: box-shadow 150ms; + } + + &.dismiss { + background-color: transparent; + border: 0; + padding: 0; + text-decoration: underline; + } + + // Blue button + &.primary, + &.done { + background-color: var(--newtab-primary-action-background); + border: solid 1px var(--newtab-primary-action-background); + color: var(--newtab-primary-element-text-color); + margin-inline-start: auto; + } +} + +input { + &[type='text'], + &[type='search'] { + border-radius: $border-radius; + } +} + +// These styles are needed for -webkit-line-clamp to work correctly, so reuse +// this class name while separately setting a clamp value via CSS or JS. +.clamp { + -webkit-box-orient: vertical; + display: -webkit-box; + overflow: hidden; + word-break: break-word; +} + +// Components +// stylelint-disable no-invalid-position-at-import-rule +@import '../components/A11yLinkButton/A11yLinkButton'; +@import '../components/Base/Base'; +@import '../components/ErrorBoundary/ErrorBoundary'; +@import '../components/TopSites/TopSites'; +@import '../components/Sections/Sections'; +@import '../components/Topics/Topics'; +@import '../components/Search/Search'; +@import '../components/ContextMenu/ContextMenu'; +@import '../components/ConfirmDialog/ConfirmDialog'; +@import '../components/CustomizeMenu/CustomizeMenu'; +@import '../components/Card/Card'; +@import '../components/CollapsibleSection/CollapsibleSection'; +@import '../components/DiscoveryStreamAdmin/DiscoveryStreamAdmin'; +@import '../components/PocketLoggedInCta/PocketLoggedInCta'; +@import '../components/MoreRecommendations/MoreRecommendations'; +@import '../components/DiscoveryStreamBase/DiscoveryStreamBase'; +@import '../components/ModalOverlay/ModalOverlay'; + +// Discovery Stream Components +@import '../components/DiscoveryStreamComponents/CardGrid/CardGrid'; +@import '../components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid'; +@import '../components/DiscoveryStreamComponents/Highlights/Highlights'; +@import '../components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule'; +@import '../components/DiscoveryStreamComponents/Navigation/Navigation'; +@import '../components/DiscoveryStreamComponents/SectionTitle/SectionTitle'; +@import '../components/DiscoveryStreamComponents/TopSites/TopSites'; +@import '../components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu'; +@import '../components/DiscoveryStreamComponents/DSCard/DSCard'; +@import '../components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter'; +@import '../components/DiscoveryStreamComponents/DSImage/DSImage'; +@import '../components/DiscoveryStreamComponents/DSDismiss/DSDismiss'; +@import '../components/DiscoveryStreamComponents/DSMessage/DSMessage'; +@import '../components/DiscoveryStreamImpressionStats/ImpressionStats'; +@import '../components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState'; +@import '../components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo'; +@import '../components/DiscoveryStreamComponents/DSSignup/DSSignup'; +@import '../components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal'; +@import '../components/DiscoveryStreamComponents/PrivacyLink/PrivacyLink'; +@import '../components/DiscoveryStreamComponents/TopicsWidget/TopicsWidget'; + +// AS Router +@import '../../../asrouter/content-src/components/Button/Button'; +// stylelint-enable no-invalid-position-at-import-rule diff --git a/browser/components/newtab/content-src/styles/_icons.scss b/browser/components/newtab/content-src/styles/_icons.scss new file mode 100644 index 0000000000..4074f0a6a6 --- /dev/null +++ b/browser/components/newtab/content-src/styles/_icons.scss @@ -0,0 +1,211 @@ +.icon { + background-position: center center; + background-repeat: no-repeat; + background-size: $icon-size; + -moz-context-properties: fill; + display: inline-block; + color: var(--newtab-text-primary-color); + fill: currentColor; + height: $icon-size; + vertical-align: middle; + width: $icon-size; + + // helper classes + &.icon-spacer { + margin-inline-end: 8px; + } + + &.icon-small-spacer { + margin-inline-end: 6px; + } + + &.icon-button-style { + fill: var(--newtab-text-secondary-color); + border: 0; + + &:focus, + &:hover { + fill: var(--newtab-text-primary-color); + } + } + + // icon images + &.icon-bookmark-added { + background-image: url('chrome://browser/skin/bookmark.svg'); + } + + &.icon-bookmark-hollow { + background-image: url('chrome://browser/skin/bookmark-hollow.svg'); + } + + &.icon-clear-input { + background-image: url('chrome://global/skin/icons/close-fill.svg'); + } + + &.icon-delete { + background-image: url('chrome://global/skin/icons/delete.svg'); + } + + &.icon-search { + background-image: url('chrome://global/skin/icons/search-glass.svg'); + } + + &.icon-modal-delete { + flex-shrink: 0; + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-modal-delete-20.svg'); + background-size: $larger-icon-size; + height: $larger-icon-size; + width: $larger-icon-size; + } + + &.icon-mail { + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-mail-16.svg'); + } + + &.icon-dismiss { + background-image: url('chrome://global/skin/icons/close.svg'); + } + + &.icon-info { + background-image: url('chrome://global/skin/icons/info.svg'); + } + + &.icon-new-window { + @include flip-icon; + + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-newWindow-16.svg'); + } + + &.icon-new-window-private { + background-image: url('chrome://browser/skin/privateBrowsing.svg'); + } + + &.icon-settings { + background-image: url('chrome://global/skin/icons/settings.svg'); + } + + &.icon-pin { + @include flip-icon; + + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-pin-16.svg'); + } + + &.icon-unpin { + @include flip-icon; + + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-unpin-16.svg'); + } + + &.icon-edit { + background-image: url('chrome://global/skin/icons/edit.svg'); + } + + &.icon-pocket { + background-image: url('chrome://global/skin/icons/pocket.svg'); + } + + &.icon-pocket-save { + background-image: url('chrome://global/skin/icons/pocket.svg'); + fill: $white; + } + + &.icon-pocket-delete { + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-pocket-delete-16.svg'); + } + + &.icon-pocket-archive { + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-pocket-archive-16.svg'); + } + + &.icon-history-item { + background-image: url('chrome://browser/skin/history.svg'); + } + + &.icon-trending { + background-image: url('chrome://browser/skin/trending.svg'); + transform: translateY(2px); // trending bolt is visually top heavy + } + + &.icon-now { + background-image: url('chrome://browser/skin/history.svg'); + } + + &.icon-topsites { + background-image: url('chrome://browser/skin/topsites.svg'); + } + + &.icon-pin-small { + @include flip-icon; + + background-image: url('chrome://browser/skin/pin-12.svg'); + background-size: $smaller-icon-size; + height: $smaller-icon-size; + width: $smaller-icon-size; + } + + &.icon-check { + background-image: url('chrome://global/skin/icons/check.svg'); + } + + &.icon-download { + background-image: url('chrome://browser/skin/downloads/downloads.svg'); + } + + &.icon-copy { + background-image: url('chrome://global/skin/icons/edit-copy.svg'); + } + + &.icon-open-file { + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-open-file-16.svg'); + } + + &.icon-webextension { + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-webextension-16.svg'); + } + + &.icon-highlights { + background-image: url('chrome://global/skin/icons/highlights.svg'); + } + + &.icon-arrowhead-down { + background-image: url('chrome://global/skin/icons/arrow-down.svg'); + } + + &.icon-arrowhead-down-small { + background-image: url('chrome://global/skin/icons/arrow-down-12.svg'); + background-size: $smaller-icon-size; + height: $smaller-icon-size; + width: $smaller-icon-size; + } + + &.icon-arrowhead-forward-small { + background-image: url('chrome://global/skin/icons/arrow-right-12.svg'); + background-size: $smaller-icon-size; + height: $smaller-icon-size; + width: $smaller-icon-size; + + &:dir(rtl) { + background-image: url('chrome://global/skin/icons/arrow-left-12.svg'); + } + } + + &.icon-arrowhead-up { + background-image: url('chrome://global/skin/icons/arrow-up.svg'); + } + + &.icon-add { + background-image: url('chrome://global/skin/icons/plus.svg'); + } + + &.icon-minimize { + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-minimize-16.svg'); + } + + &.icon-maximize { + background-image: url('chrome://activity-stream/content/data/content/assets/glyph-maximize-16.svg'); + } + + &.icon-arrow { + background-image: url('chrome://global/skin/icons/arrow-right-12.svg'); + } +} diff --git a/browser/components/newtab/content-src/styles/_mixins.scss b/browser/components/newtab/content-src/styles/_mixins.scss new file mode 100644 index 0000000000..d2b2748a60 --- /dev/null +++ b/browser/components/newtab/content-src/styles/_mixins.scss @@ -0,0 +1,50 @@ +// Shared styling of article images shown as background +@mixin image-as-background { + background-color: var(--newtab-element-secondary-color); + background-position: center; + background-repeat: no-repeat; + background-size: cover; + border-radius: 4px; + box-shadow: $shadow-image-inset; +} + +// Note: lineHeight and fontSize should be unitless but can be derived from pixel values +// Bug 1550624 to clean up / remove this mixin to avoid duplicate styles +@mixin limit-visible-lines($line-count, $line-height, $font-size) { + font-size: $font-size * 1px; + -webkit-line-clamp: $line-count; + line-height: $line-height * 1px; +} + +@mixin dark-theme-only { + [lwt-newtab-brighttext] & { + @content; + } +} + +@mixin ds-border-top { + @content; + + @include dark-theme-only { + border-top: 1px solid $grey-60; + } + + border-top: 1px solid $grey-30; +} + +@mixin ds-border-bottom { + @content; + + @include dark-theme-only { + border-bottom: 1px solid $grey-60; + } + + border-bottom: 1px solid $grey-30; +} + +@mixin ds-fade-in($halo-color: $blue-50-30) { + box-shadow: 0 0 0 5px $halo-color; + transition: box-shadow 150ms; + border-radius: 4px; + outline: none; +} diff --git a/browser/components/newtab/content-src/styles/_normalize.scss b/browser/components/newtab/content-src/styles/_normalize.scss new file mode 100644 index 0000000000..a47166ef7b --- /dev/null +++ b/browser/components/newtab/content-src/styles/_normalize.scss @@ -0,0 +1,29 @@ +html { + box-sizing: border-box; +} + +*, +*::before, +*::after { + box-sizing: inherit; +} + +*::-moz-focus-inner { + border: 0; +} + +body { + margin: 0; +} + +button, +input { + background-color: inherit; + color: inherit; + font-family: inherit; + font-size: inherit; +} + +[hidden] { + display: none !important; // stylelint-disable-line declaration-no-important +} diff --git a/browser/components/newtab/content-src/styles/_theme.scss b/browser/components/newtab/content-src/styles/_theme.scss new file mode 100644 index 0000000000..6b097ae93e --- /dev/null +++ b/browser/components/newtab/content-src/styles/_theme.scss @@ -0,0 +1,97 @@ +@function textbox-shadow($color) { + @return 0 0 0 1px $color, 0 0 0 $textbox-shadow-size rgba($color, 0.3); +} + +@mixin textbox-focus($color) { + --newtab-textbox-focus-color: #{$color}; + --newtab-textbox-focus-boxshadow: #{textbox-shadow($color)}; +} + +// scss variables related to the theme. +$border-primary: 1px solid var(--newtab-border-color); +$border-secondary: 1px solid var(--newtab-border-color); +$inner-box-shadow: 0 0 0 1px var(--newtab-inner-box-shadow-color); +$input-border: 1px solid var(--newtab-border-color); +$input-border-active: 1px solid var(--newtab-textbox-focus-color); +$input-error-border: 1px solid var(--newtab-status-error); +$input-error-boxshadow: #{textbox-shadow(var(--newtab-status-error))}; +$shadow-primary: 0 0 0 5px var(--newtab-element-secondary-color); +$shadow-secondary: 0 1px 4px 0 $grey-90-20; +$shadow-large: 0 2px 14px 0 $black-20; +$shadow-focus: 0 0 0 2px var(--newtab-primary-action-background-dimmed); +$shadow-card: 0 2px 6px rgba(0, 0, 0, 15%); +$shadow-image-inset: inset 0 0 0 0.5px $black-15; + +// Default theme +:root { + // General styles + --newtab-background-color: #{$in-content-page-background}; + --newtab-background-color-secondary: #{$newtab-background-secondary}; + --newtab-text-primary-color: #{$in-content-page-color}; + --newtab-primary-action-background: #{$primary-blue}; + // A button colour closer to the Pocket brand for usage in the Pocket section. + --newtab-primary-action-background-pocket: #{$primary-green}; + --newtab-text-secondary-color: color-mix(in srgb, var(--newtab-text-primary-color) 70%, transparent); + + // --newtab-element-*-color is used when an element needs to be set off from + // the primary background color. + --newtab-element-hover-color: color-mix(in srgb, var(--newtab-background-color) 90%, #{$black}); + --newtab-element-active-color: color-mix(in srgb, var(--newtab-background-color) 80%, #{$black}); + + // --newtab-element-secondary*-color is used when an element needs to be set + // off from the secondary background color. + --newtab-element-secondary-color: color-mix(in srgb, currentColor 5%, transparent); + --newtab-element-secondary-hover-color: color-mix(in srgb, currentColor 12%, transparent); + --newtab-element-secondary-active-color: color-mix(in srgb, currentColor 25%, transparent); + + --newtab-primary-element-hover-color: color-mix(in srgb, var(--newtab-primary-action-background) 90%, #{$black}); + --newtab-primary-element-hover-pocket-color: color-mix(in srgb, var(--newtab-primary-action-background-pocket) 90%, #{$black}); + --newtab-primary-element-active-color: color-mix(in srgb, var(--newtab-primary-action-background) 80%, #{$black}); + --newtab-primary-element-text-color: #{$white}; + // --newtab-primary-action-background-dimmed is used for soft focus borders. + --newtab-primary-action-background-dimmed: color-mix(in srgb, var(--newtab-primary-action-background) 25%, transparent); + --newtab-primary-action-background-pocket-dimmed: color-mix(in srgb, var(--newtab-primary-action-background-pocket) 25%, transparent); + --newtab-border-color: color-mix(in srgb, var(--newtab-background-color) 75%, #{$black}); + --newtab-wordmark-color: #{$newtab-wordmark-default-color}; + --newtab-status-success: #{$status-green}; + --newtab-status-error: #{$red-60}; + --newtab-inner-box-shadow-color: #{$black-10}; + --newtab-overlay-color: color-mix(in srgb, var(--newtab-background-color) 85%, transparent); + + @include textbox-focus(var(--newtab-primary-action-background)); + + // Buttons + --newtab-button-secondary-color: inherit; + + &[lwt-newtab-brighttext] { + // General styles + --newtab-background-color: #{$in-content-page-background-dark}; + --newtab-background-color-secondary: #{$newtab-background-secondary-dark}; + --newtab-text-primary-color: #{$in-content-page-color-dark}; + + --newtab-primary-action-background: #{$primary-blue-dark}; + --newtab-primary-action-background-pocket: #{$primary-blue-dark}; + --newtab-primary-action-background-pocket-dimmed: color-mix(in srgb, var(--newtab-primary-action-background) 25%, transparent); + + --newtab-primary-element-hover-color: color-mix(in srgb, var(--newtab-primary-action-background) 55%, #{$white}); + --newtab-primary-element-hover-pocket-color: color-mix(in srgb, var(--newtab-primary-action-background-pocket) 55%, #{$white}); + + --newtab-element-hover-color: color-mix(in srgb, var(--newtab-background-color) 80%, #{$white}); + --newtab-element-active-color: color-mix(in srgb, var(--newtab-background-color) 60%, #{$white}); + + --newtab-element-secondary-color: color-mix(in srgb, currentColor 10%, transparent); + --newtab-element-secondary-hover-color: color-mix(in srgb, currentColor 17%, transparent); + --newtab-element-secondary-active-color: color-mix(in srgb, currentColor 30%, transparent); + + --newtab-border-color: color-mix(in srgb, var(--newtab-background-color) 75%, #{$white}); + --newtab-primary-element-text-color: #{$primary-text-color-dark}; + --newtab-wordmark-color: #{$newtab-wordmark-darktheme-color}; + --newtab-status-success: #{$status-dark-green}; + } +} + +@media (prefers-contrast) { + :root { + --newtab-text-secondary-color: var(--newtab-text-primary-color); + } +} diff --git a/browser/components/newtab/content-src/styles/_variables.scss b/browser/components/newtab/content-src/styles/_variables.scss new file mode 100644 index 0000000000..9fd0083841 --- /dev/null +++ b/browser/components/newtab/content-src/styles/_variables.scss @@ -0,0 +1,215 @@ +@use 'sass:math'; + +$primary-blue: rgb(0, 97, 224); +$primary-green: rgb(0, 128, 120); +$primary-blue-dark: rgb(0, 221, 255); +$primary-text-color-dark: rgb(43, 42, 51); + +// -------------------------------------------------------------------------- // +// Photon colors from http://design.firefox.com/photon/visuals/color.html + +$blue-40: #45A1FF; +$blue-50: #0A84FF; +$grey-30: #D7D7DB; +$grey-60: #4A4A4F; +$grey-90: #0C0C0D; +$red-60: #D70022; +$yellow-50: #FFE900; + +$grey-90-10: rgba($grey-90, 0.1); +$grey-90-20: rgba($grey-90, 0.2); + +$blue-40-40: rgba($blue-40, 0.4); +$blue-50-30: rgba($blue-50, 0.3); + +$black: #000; +$black-10: rgba($black, 0.1); +$black-15: rgba($black, 0.15); +$black-20: rgba($black, 0.2); +$black-30: rgba($black, 0.3); +$black-40: rgba($black, 0.4); + +// -------------------------------------------------------------------------- // +// Other colors + +$white: #FFF; +$status-green: #058B00; +$status-dark-green: #7C6; +$bookmark-icon-fill: #0A84FF; +$download-icon-fill: #12BC00; +$pocket-icon-fill: #EF4056; +$email-input-invalid: rgba($red-60, 0.3); + +$newtab-wordmark-default-color: #20123A; +$newtab-wordmark-darktheme-color: rgb(251, 251, 254); + +$in-content-page-color: rgb(21, 20, 26); +$in-content-page-color-dark: rgb(251, 251, 254); + +// -------------------------------------------------------------------------- // +// These colors should match the colors in the default themes +// (toolkit/mozapps/extensions/...). Note that they could get replaced by other +// themes. The color set in --tabpanel-background-color (tabs.inc.css) should +// match these colors here to avoid flashing. + +$in-content-page-background: #F9F9FB; +$in-content-page-background-dark: #2B2A33; + +$newtab-background-secondary: #FFF; +$newtab-background-secondary-dark: rgb(66, 65, 77); + +// Photon transitions from http://design.firefox.com/photon/motion/duration-and-easing.html +$photon-easing: cubic-bezier(0.07, 0.95, 0, 1); + +$border-radius: 3px; +$border-radius-new: 8px; + +// Grid related styles +$base-gutter: 32px; +$section-horizontal-padding: 25px; +$section-vertical-padding: 10px; +$section-spacing: 40px - $section-vertical-padding * 2; +$grid-unit: 96px; // 1 top site +// New Tab Experience grid unit needs to be smaller, but for now we are changing +// this UI with a pref, so requires duplication. +$grid-unit-small: 80px; // 1 top site + +$icon-size: 16px; +$smaller-icon-size: 12px; +$larger-icon-size: 32px; + +$searchbar-width-small: ($grid-unit * 2 + $base-gutter * 1) - 24px; +$searchbar-width-medium: ($grid-unit * 4 + $base-gutter * 3) - 120px; +$searchbar-width-large: ($grid-unit * 6 + $base-gutter * 5) - 136px; +$searchbar-width-largest: ($grid-unit * 6 + $base-gutter * 5) - 16px; + +$wrapper-default-width: $grid-unit * 2 + $base-gutter * 1 + $section-horizontal-padding * 2; // 2 top sites +$wrapper-max-width-medium: $grid-unit * 4 + $base-gutter * 3 + $section-horizontal-padding * 2; // 4 top sites +$wrapper-max-width-large: $grid-unit * 6 + $base-gutter * 5 + $section-horizontal-padding * 2; // 6 top sites +$wrapper-max-width-widest: $grid-unit * 8 + $base-gutter * 7 + $section-horizontal-padding * 2; // 8 top sites +// For the breakpoints, we need to add space for the scrollbar to avoid weird +// layout issues when the scrollbar is visible. 16px is wide enough to cover all +// OSes and keeps it simpler than a per-OS value. +$scrollbar-width: 16px; + +// Breakpoints +// If updating these breakpoints, don't forget to update uses of DSImage, which +// might choose the right image src to use depending on the viewport size. +$break-point-medium: $wrapper-max-width-medium + $base-gutter * 2 + $scrollbar-width; // 610px +$break-point-large: $wrapper-max-width-large + $base-gutter * 2 + $scrollbar-width; // 866px +$break-point-widest: $wrapper-max-width-widest + $base-gutter * 2 + $scrollbar-width; // 1122px + +$section-title-font-size: 17px; + +$card-width: $grid-unit-small * 2 + $base-gutter; + +$card-height: 266px; +$card-preview-image-height: 122px; +$card-title-margin: 2px; +$card-text-line-height: 19px; + +// Larger cards for wider screens: +$card-width-large: 309px; +$card-height-large: 370px; +$card-preview-image-height-large: 155px; + +// Compact cards for Highlights +$card-height-compact: 160px; +$card-preview-image-height-compact: 108px; + +$topic-margin-top: 12px; + +$context-menu-button-size: 27px; +$context-menu-button-boxshadow: 0 2px $grey-90-10; +$context-menu-shadow: 0 5px 10px $black-30, 0 0 0 1px $black-20; +$context-menu-font-size: 14px; +$context-menu-border-radius: 5px; +$context-menu-outer-padding: 5px; +$context-menu-item-padding: 3px 12px; + +$error-fallback-font-size: 12px; +$error-fallback-line-height: 1.5; + +$image-path: 'chrome://activity-stream/content/data/content/assets/'; + +$textbox-shadow-size: 4px; + +$customize-menu-slide-bezier: cubic-bezier(0.46, 0.03, 0.52, 0.96); +$customize-menu-expand-bezier: cubic-bezier(0.82, 0.085, 0.395, 0.895); +$customize-menu-border-tint: 1px solid rgba(0, 0, 0, 15%); + +@mixin fade-in { + box-shadow: inset $inner-box-shadow, $shadow-primary; + transition: box-shadow 150ms; +} + +@mixin fade-in-card { + box-shadow: $shadow-primary; + transition: box-shadow 150ms; +} + +@mixin ds-focus { + border: 0; + outline: 0; + box-shadow: 0 0 0 3px var(--newtab-primary-action-background-dimmed), 0 0 0 1px var(--newtab-primary-action-background); +} + +@mixin context-menu-button { + .context-menu-button { + background-clip: padding-box; + background-color: var(--newtab-background-color-secondary); + background-image: url('chrome://global/skin/icons/more.svg'); + background-position: 55%; + border: $border-primary; + border-radius: 100%; + box-shadow: $context-menu-button-boxshadow; + cursor: pointer; + fill: var(--newtab-text-primary-color); + height: $context-menu-button-size; + inset-inline-end: math.div(-$context-menu-button-size, 2); + opacity: 0; + position: absolute; + top: math.div(-$context-menu-button-size, 2); + transform: scale(0.25); + transition-duration: 150ms; + transition-property: transform, opacity; + width: $context-menu-button-size; + + &:is(:active, :focus) { + opacity: 1; + transform: scale(1); + } + } +} + +@mixin context-menu-button-hover { + .context-menu-button { + opacity: 1; + transform: scale(1); + transition-delay: 333ms; + } +} + +@mixin context-menu-open-middle { + .context-menu { + margin-inline-end: auto; + margin-inline-start: auto; + inset-inline-end: auto; + inset-inline-start: -$base-gutter; + } +} + +@mixin context-menu-open-left { + .context-menu { + margin-inline-end: 5px; + margin-inline-start: auto; + inset-inline-end: 0; + inset-inline-start: auto; + } +} + +@mixin flip-icon { + &:dir(rtl) { + transform: scaleX(-1); + } +} diff --git a/browser/components/newtab/content-src/styles/activity-stream-linux.scss b/browser/components/newtab/content-src/styles/activity-stream-linux.scss new file mode 100644 index 0000000000..89128d9d04 --- /dev/null +++ b/browser/components/newtab/content-src/styles/activity-stream-linux.scss @@ -0,0 +1,11 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* This is the linux variant */ + +$os-infopanel-arrow-height: 10px; +$os-infopanel-arrow-offset-end: 6px; +$os-infopanel-arrow-width: 20px; + +@import './activity-stream'; diff --git a/browser/components/newtab/content-src/styles/activity-stream-mac.scss b/browser/components/newtab/content-src/styles/activity-stream-mac.scss new file mode 100644 index 0000000000..d95665f36f --- /dev/null +++ b/browser/components/newtab/content-src/styles/activity-stream-mac.scss @@ -0,0 +1,16 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* This is the mac variant */ + +$os-infopanel-arrow-height: 10px; +$os-infopanel-arrow-offset-end: 7px; +$os-infopanel-arrow-width: 18px; + +[lwt-newtab-brighttext] { + -moz-osx-font-smoothing: grayscale; +} + +// stylelint-disable-next-line no-invalid-position-at-import-rule +@import './activity-stream'; diff --git a/browser/components/newtab/content-src/styles/activity-stream-windows.scss b/browser/components/newtab/content-src/styles/activity-stream-windows.scss new file mode 100644 index 0000000000..9252bdd0f6 --- /dev/null +++ b/browser/components/newtab/content-src/styles/activity-stream-windows.scss @@ -0,0 +1,11 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* This is the windows variant */ + +$os-infopanel-arrow-height: 10px; +$os-infopanel-arrow-offset-end: 6px; +$os-infopanel-arrow-width: 20px; + +@import './activity-stream'; |