358 lines
11 KiB
JavaScript
358 lines
11 KiB
JavaScript
/* 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.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));
|
|
}
|
|
|
|
// 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} />
|
|
);
|