/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.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 (