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