/* 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 (
  • {hasImage && (
    )}
    {link.type === "download" && (
    )} {link.hostname && (
    {link.hostname.slice(0, 100)} {link.type === "download" && ` \u2014 ${link.description}`}
    )}

    {link.title}

    {link.description}

    {icon && !link.context && ( )} {link.icon && link.context && ( )} {fluentID && !link.context && (
    )} {link.context && (
    {link.context}
    )}
    {!props.placeholder && ( )}
  • ); } } _Card.defaultProps = { link: {} }; export const Card = connect(state => ({ platform: state.Prefs.values.platform, }))(_Card); export const PlaceholderCard = props => ( );