From 6bf0a5cb5034a7e684dcc3500e841785237ce2dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 19:32:43 +0200 Subject: Adding upstream version 1:115.7.0. Signed-off-by: Daniel Baumann --- .../content-src/components/TopSites/TopSite.jsx | 873 +++++++++++++++++++++ 1 file changed, 873 insertions(+) create mode 100644 browser/components/newtab/content-src/components/TopSites/TopSite.jsx (limited to 'browser/components/newtab/content-src/components/TopSites/TopSite.jsx') diff --git a/browser/components/newtab/content-src/components/TopSites/TopSite.jsx b/browser/components/newtab/content-src/components/TopSites/TopSite.jsx new file mode 100644 index 0000000000..90641008be --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/TopSite.jsx @@ -0,0 +1,873 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { + MIN_RICH_FAVICON_SIZE, + MIN_SMALL_FAVICON_SIZE, + TOP_SITES_CONTEXT_MENU_OPTIONS, + TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS, + TOP_SITES_SPONSORED_POSITION_CONTEXT_MENU_OPTIONS, + TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS, + TOP_SITES_SOURCE, +} from "./TopSitesConstants"; +import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu"; +import { ImpressionStats } from "../DiscoveryStreamImpressionStats/ImpressionStats"; +import React from "react"; +import { ScreenshotUtils } from "content-src/lib/screenshot-utils"; +import { TOP_SITES_MAX_SITES_PER_ROW } from "common/Reducers.sys.mjs"; +import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton"; +import { TopSiteImpressionWrapper } from "./TopSiteImpressionWrapper"; +import { connect } from "react-redux"; + +const SPOC_TYPE = "SPOC"; +const NEWTAB_SOURCE = "newtab"; + +// For cases if we want to know if this is sponsored by either sponsored_position or type. +// We have two sources for sponsored topsites, and +// sponsored_position is set by one sponsored source, and type is set by another. +// This is not called in all cases, sometimes we want to know if it's one source +// or the other. This function is only applicable in cases where we only care if it's either. +function isSponsored(link) { + return link?.sponsored_position || link?.type === SPOC_TYPE; +} + +export class TopSiteLink extends React.PureComponent { + constructor(props) { + super(props); + this.state = { screenshotImage: null }; + this.onDragEvent = this.onDragEvent.bind(this); + this.onKeyPress = this.onKeyPress.bind(this); + } + + /* + * Helper to determine whether the drop zone should allow a drop. We only allow + * dropping top sites for now. We don't allow dropping on sponsored top sites + * as their position is fixed. + */ + _allowDrop(e) { + return ( + (this.dragged || !isSponsored(this.props.link)) && + e.dataTransfer.types.includes("text/topsite-index") + ); + } + + onDragEvent(event) { + switch (event.type) { + case "click": + // Stop any link clicks if we started any dragging + if (this.dragged) { + event.preventDefault(); + } + break; + case "dragstart": + event.target.blur(); + if (isSponsored(this.props.link)) { + event.preventDefault(); + break; + } + this.dragged = true; + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/topsite-index", this.props.index); + this.props.onDragEvent( + event, + this.props.index, + this.props.link, + this.props.title + ); + break; + case "dragend": + this.props.onDragEvent(event); + break; + case "dragenter": + case "dragover": + case "drop": + if (this._allowDrop(event)) { + event.preventDefault(); + this.props.onDragEvent(event, this.props.index); + } + break; + case "mousedown": + // Block the scroll wheel from appearing for middle clicks on search top sites + if (event.button === 1 && this.props.link.searchTopSite) { + event.preventDefault(); + } + // Reset at the first mouse event of a potential drag + this.dragged = false; + break; + } + } + + /** + * Helper to obtain the next state based on nextProps and prevState. + * + * NOTE: Rename this method to getDerivedStateFromProps when we update React + * to >= 16.3. We will need to update tests as well. We cannot rename this + * method to getDerivedStateFromProps now because there is a mismatch in + * the React version that we are using for both testing and production. + * (i.e. react-test-render => "16.3.2", react => "16.2.0"). + * + * See https://github.com/airbnb/enzyme/blob/master/packages/enzyme-adapter-react-16/package.json#L43. + */ + static getNextStateFromProps(nextProps, prevState) { + const { screenshot } = nextProps.link; + const imageInState = ScreenshotUtils.isRemoteImageLocal( + prevState.screenshotImage, + screenshot + ); + if (imageInState) { + return null; + } + + // Since image was updated, attempt to revoke old image blob URL, if it exists. + ScreenshotUtils.maybeRevokeBlobObjectURL(prevState.screenshotImage); + + return { + screenshotImage: ScreenshotUtils.createLocalImageObject(screenshot), + }; + } + + // NOTE: Remove this function when we update React to >= 16.3 since React will + // call getDerivedStateFromProps automatically. We will also need to + // rename getNextStateFromProps to getDerivedStateFromProps. + componentWillMount() { + const nextState = TopSiteLink.getNextStateFromProps(this.props, this.state); + if (nextState) { + this.setState(nextState); + } + } + + // NOTE: Remove this function when we update React to >= 16.3 since React will + // call getDerivedStateFromProps automatically. We will also need to + // rename getNextStateFromProps to getDerivedStateFromProps. + componentWillReceiveProps(nextProps) { + const nextState = TopSiteLink.getNextStateFromProps(nextProps, this.state); + if (nextState) { + this.setState(nextState); + } + } + + componentWillUnmount() { + ScreenshotUtils.maybeRevokeBlobObjectURL(this.state.screenshotImage); + } + + onKeyPress(event) { + // If we have tabbed to a search shortcut top site, and we click 'enter', + // we should execute the onClick function. This needs to be added because + // search top sites are anchor tags without an href. See bug 1483135 + if (this.props.link.searchTopSite && event.key === "Enter") { + this.props.onClick(event); + } + } + + /* + * Takes the url as a string, runs it through a simple (non-secure) hash turning it into a random number + * Apply that random number to the color array. The same url will always generate the same color. + */ + generateColor() { + let { title, colors } = this.props; + if (!colors) { + return ""; + } + + let colorArray = colors.split(","); + + const hashStr = str => { + let hash = 0; + for (let i = 0; i < str.length; i++) { + let charCode = str.charCodeAt(i); + hash += charCode; + } + return hash; + }; + + let hash = hashStr(title); + let index = hash % colorArray.length; + return colorArray[index]; + } + + calculateStyle() { + const { defaultStyle, link } = this.props; + + const { tippyTopIcon, faviconSize } = link; + let imageClassName; + let imageStyle; + let showSmallFavicon = false; + let smallFaviconStyle; + let hasScreenshotImage = + this.state.screenshotImage && this.state.screenshotImage.url; + let selectedColor; + + if (defaultStyle) { + // force no styles (letter fallback) even if the link has imagery + selectedColor = this.generateColor(); + } else if (link.searchTopSite) { + imageClassName = "top-site-icon rich-icon"; + imageStyle = { + backgroundColor: link.backgroundColor, + backgroundImage: `url(${tippyTopIcon})`, + }; + smallFaviconStyle = { backgroundImage: `url(${tippyTopIcon})` }; + } else if (link.customScreenshotURL) { + // assume high quality custom screenshot and use rich icon styles and class names + imageClassName = "top-site-icon rich-icon"; + imageStyle = { + backgroundColor: link.backgroundColor, + backgroundImage: hasScreenshotImage + ? `url(${this.state.screenshotImage.url})` + : "", + }; + } else if ( + tippyTopIcon || + link.type === SPOC_TYPE || + faviconSize >= MIN_RICH_FAVICON_SIZE + ) { + // styles and class names for top sites with rich icons + imageClassName = "top-site-icon rich-icon"; + imageStyle = { + backgroundColor: link.backgroundColor, + backgroundImage: `url(${tippyTopIcon || link.favicon})`, + }; + } else if (faviconSize >= MIN_SMALL_FAVICON_SIZE) { + showSmallFavicon = true; + smallFaviconStyle = { backgroundImage: `url(${link.favicon})` }; + } else { + selectedColor = this.generateColor(); + imageClassName = ""; + } + + return { + showSmallFavicon, + smallFaviconStyle, + imageStyle, + imageClassName, + selectedColor, + }; + } + + render() { + const { children, className, isDraggable, link, onClick, title } = + this.props; + const topSiteOuterClassName = `top-site-outer${ + className ? ` ${className}` : "" + }${link.isDragged ? " dragged" : ""}${ + link.searchTopSite ? " search-shortcut" : "" + }`; + const [letterFallback] = title; + const { + showSmallFavicon, + smallFaviconStyle, + imageStyle, + imageClassName, + selectedColor, + } = this.calculateStyle(); + + let draggableProps = {}; + if (isDraggable) { + draggableProps = { + onClick: this.onDragEvent, + onDragEnd: this.onDragEvent, + onDragStart: this.onDragEvent, + onMouseDown: this.onDragEvent, + }; + } + + let impressionStats = null; + if (link.type === SPOC_TYPE) { + // Record impressions for Pocket tiles. + impressionStats = ( + + ); + } else if (isSponsored(link)) { + // Record impressions for non-Pocket sponsored tiles. + impressionStats = ( + + ); + } else { + // Record impressions for organic tiles. + impressionStats = ( + + ); + } + + return ( +
  • +
    + {/* We don't yet support an accessible drag-and-drop implementation, see Bug 1552005 */} + {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} + +
  • + ); + } +} +TopSiteLink.defaultProps = { + title: "", + link: {}, + isDraggable: true, +}; + +export class TopSite extends React.PureComponent { + constructor(props) { + super(props); + this.state = { showContextMenu: false }; + this.onLinkClick = this.onLinkClick.bind(this); + this.onMenuUpdate = this.onMenuUpdate.bind(this); + } + + /** + * Report to telemetry additional information about the item. + */ + _getTelemetryInfo() { + const value = { icon_type: this.props.link.iconType }; + // Filter out "not_pinned" type for being the default + if (this.props.link.isPinned) { + value.card_type = "pinned"; + } + if (this.props.link.searchTopSite) { + // Set the card_type as "search" regardless of its pinning status + value.card_type = "search"; + value.search_vendor = this.props.link.hostname; + } + if (isSponsored(this.props.link)) { + value.card_type = "spoc"; + } + return { value }; + } + + userEvent(event) { + this.props.dispatch( + ac.UserEvent( + Object.assign( + { + event, + source: TOP_SITES_SOURCE, + action_position: this.props.index, + }, + this._getTelemetryInfo() + ) + ) + ); + } + + onLinkClick(event) { + this.userEvent("CLICK"); + + // Specially handle a top site link click for "typed" frecency bonus as + // specified as a property on the link. + event.preventDefault(); + const { altKey, button, ctrlKey, metaKey, shiftKey } = event; + if (!this.props.link.searchTopSite) { + this.props.dispatch( + ac.OnlyToMain({ + type: at.OPEN_LINK, + data: Object.assign(this.props.link, { + event: { altKey, button, ctrlKey, metaKey, shiftKey }, + }), + }) + ); + + if (this.props.link.type === SPOC_TYPE) { + // Record a Pocket-specific click. + this.props.dispatch( + ac.ImpressionStats({ + source: TOP_SITES_SOURCE, + click: 0, + tiles: [ + { + id: this.props.link.id, + pos: this.props.link.pos, + shim: this.props.link.shim && this.props.link.shim.click, + }, + ], + }) + ); + + // Record a click for a Pocket sponsored tile. + const title = this.props.link.label || this.props.link.hostname; + this.props.dispatch( + ac.OnlyToMain({ + type: at.TOP_SITES_SPONSORED_IMPRESSION_STATS, + data: { + type: "click", + position: this.props.link.pos, + tile_id: this.props.link.id, + advertiser: title.toLocaleLowerCase(), + source: NEWTAB_SOURCE, + }, + }) + ); + } else if (isSponsored(this.props.link)) { + // Record a click for a non-Pocket sponsored tile. + const title = this.props.link.label || this.props.link.hostname; + this.props.dispatch( + ac.OnlyToMain({ + type: at.TOP_SITES_SPONSORED_IMPRESSION_STATS, + data: { + type: "click", + position: this.props.index, + tile_id: this.props.link.sponsored_tile_id || -1, + reporting_url: this.props.link.sponsored_click_url, + advertiser: title.toLocaleLowerCase(), + source: NEWTAB_SOURCE, + }, + }) + ); + } else { + // Record a click for an organic tile. + this.props.dispatch( + ac.OnlyToMain({ + type: at.TOP_SITES_ORGANIC_IMPRESSION_STATS, + data: { + type: "click", + position: this.props.index, + source: NEWTAB_SOURCE, + }, + }) + ); + } + + if (this.props.link.sendAttributionRequest) { + this.props.dispatch( + ac.OnlyToMain({ + type: at.PARTNER_LINK_ATTRIBUTION, + data: { + targetURL: this.props.link.url, + source: "newtab", + }, + }) + ); + } + } else { + this.props.dispatch( + ac.OnlyToMain({ + type: at.FILL_SEARCH_TERM, + data: { label: this.props.link.label }, + }) + ); + } + } + + onMenuUpdate(isOpen) { + if (isOpen) { + this.props.onActivate(this.props.index); + } else { + this.props.onActivate(); + } + } + + render() { + const { props } = this; + const { link } = props; + const isContextMenuOpen = props.activeIndex === props.index; + const title = link.label || link.hostname; + let menuOptions; + if (link.sponsored_position) { + menuOptions = TOP_SITES_SPONSORED_POSITION_CONTEXT_MENU_OPTIONS; + } else if (link.searchTopSite) { + menuOptions = TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS; + } else if (link.type === SPOC_TYPE) { + menuOptions = TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS; + } else { + menuOptions = TOP_SITES_CONTEXT_MENU_OPTIONS; + } + + return ( + +
    + + + +
    +
    + ); + } +} +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 ( + +