Bug 1405038 - Export 7470a9c6 - fix(card): Fade from gradient to loaded image only after loading (#3611)
MozReview-Commit-ID: J9p2zI1J1H3
--- a/browser/extensions/activity-stream/data/content/activity-stream.bundle.js
+++ b/browser/extensions/activity-stream/data/content/activity-stream.bundle.js
@@ -2694,33 +2694,72 @@ module.exports._unconnectedSection = Sec
/***/ (function(module, exports, __webpack_require__) {
const React = __webpack_require__(1);
const LinkMenu = __webpack_require__(9);
const { FormattedMessage } = __webpack_require__(2);
const cardContextTypes = __webpack_require__(26);
const { actionCreators: ac, actionTypes: at } = __webpack_require__(0);
+// 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.
*/
class Card extends React.PureComponent {
constructor(props) {
super(props);
- this.state = { showContextMenu: false, activeCard: null };
+ this.state = {
+ activeCard: null,
+ imageLoaded: false,
+ showContextMenu: false
+ };
this.onMenuButtonClick = this.onMenuButtonClick.bind(this);
this.onMenuUpdate = this.onMenuUpdate.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 { image } = this.props.link;
+ if (!this.state.imageLoaded && image) {
+ // Initialize a promise to share a load across multiple card updates
+ if (!gImageLoading.has(image)) {
+ const loaderPromise = new Promise((resolve, reject) => {
+ const loader = new Image();
+ loader.addEventListener("load", resolve);
+ loader.addEventListener("error", reject);
+ loader.src = image;
+ });
+
+ // Save and remove the promise only while it's pending
+ gImageLoading.set(image, loaderPromise);
+ loaderPromise.catch(ex => ex).then(() => gImageLoading.delete(image)).catch();
+ }
+
+ // Wait for the image whether just started loading or reused promise
+ await gImageLoading.get(image);
+
+ // Only update state if we're still waiting to load the original image
+ if (this.props.link.image === image && !this.state.imageLoaded) {
+ this.setState({ imageLoaded: true });
+ }
+ }
+ }
+
onMenuButtonClick(event) {
event.preventDefault();
this.setState({
activeCard: this.props.index,
showContextMenu: true
});
}
onLinkClick(event) {
@@ -2742,16 +2781,28 @@ class Card extends React.PureComponent {
incognito: true,
tiles: [{ id: this.props.link.guid, pos: this.props.index }]
}));
}
}
onMenuUpdate(showContextMenu) {
this.setState({ showContextMenu });
}
+ componentDidMount() {
+ this.maybeLoadImage();
+ }
+ componentDidUpdate() {
+ this.maybeLoadImage();
+ }
+ componentWillReceiveProps(nextProps) {
+ // Clear the image state if changing images
+ if (nextProps.link.image !== this.props.link.image) {
+ this.setState({ imageLoaded: false });
+ }
+ }
render() {
const { index, link, dispatch, contextMenuOptions, eventSource, shouldSendImpressionStats } = this.props;
const { props } = this;
const isContextMenuOpen = this.state.showContextMenu && this.state.activeCard === index;
// Display "now" as "trending" until we have new strings #3402
const { icon, intlID } = cardContextTypes[link.type === "now" ? "trending" : link.type] || {};
const hasImage = link.image || link.hasImage;
const imageStyle = { backgroundImage: link.image ? `url(${link.image})` : "none" };
@@ -2763,17 +2814,17 @@ class Card extends React.PureComponent {
"a",
{ href: link.url, onClick: !props.placeholder && this.onLinkClick },
React.createElement(
"div",
{ className: "card" },
hasImage && React.createElement(
"div",
{ className: "card-preview-image-outer" },
- React.createElement("div", { className: `card-preview-image${link.image ? " loaded" : ""}`, style: imageStyle })
+ React.createElement("div", { className: `card-preview-image${this.state.imageLoaded ? " loaded" : ""}`, style: imageStyle })
),
React.createElement(
"div",
{ className: `card-details${hasImage ? "" : " no-image"}` },
link.hostname && React.createElement(
"div",
{ className: "card-host-name" },
link.hostname
--- a/browser/extensions/activity-stream/data/content/activity-stream.css
+++ b/browser/extensions/activity-stream/data/content/activity-stream.css
@@ -992,37 +992,35 @@ main {
box-shadow: 0 0 0 5px #D7D7DB;
transition: box-shadow 150ms; }
.card-outer:-moz-any(:hover, :focus, .active):not(.placeholder) .context-menu-button {
transform: scale(1);
opacity: 1; }
.card-outer:-moz-any(:hover, :focus, .active):not(.placeholder) .card-title {
color: #0060DF; }
.card-outer .card-preview-image-outer {
+ background-color: #F9F9FA;
position: relative;
- background: linear-gradient(135deg, #B1B1B3, #D7D7DB);
height: 122px;
border-radius: 3px 3px 0 0;
overflow: hidden; }
- .card-outer .card-preview-image-outer:dir(rtl) {
- background: linear-gradient(225deg, #B1B1B3, #D7D7DB); }
.card-outer .card-preview-image-outer::after {
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
bottom: 0;
content: " ";
position: absolute;
width: 100%; }
.card-outer .card-preview-image-outer .card-preview-image {
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
- transition: opacity 1s;
- opacity: 0; }
+ opacity: 0;
+ transition: opacity 1s cubic-bezier(0.07, 0.95, 0, 1); }
.card-outer .card-preview-image-outer .card-preview-image.loaded {
opacity: 1; }
.card-outer .card-details {
padding: 15px 16px 12px; }
.card-outer .card-details.no-image {
padding-top: 16px; }
.card-outer .card-text {
overflow: hidden;
--- a/browser/extensions/activity-stream/install.rdf.in
+++ b/browser/extensions/activity-stream/install.rdf.in
@@ -3,17 +3,17 @@
#filter substitution
<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:em="http://www.mozilla.org/2004/em-rdf#">
<Description about="urn:mozilla:install-manifest">
<em:id>activity-stream@mozilla.org</em:id>
<em:type>2</em:type>
<em:bootstrap>true</em:bootstrap>
<em:unpack>false</em:unpack>
- <em:version>2017.10.02.1050-3d8f7870</em:version>
+ <em:version>2017.10.02.1052-7470a9c6</em:version>
<em:name>Activity Stream</em:name>
<em:description>A rich visual history feed and a reimagined home page make it easier than ever to find exactly what you're looking for in Firefox.</em:description>
<em:multiprocessCompatible>true</em:multiprocessCompatible>
<em:targetApplication>
<Description>
<em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id>
<em:minVersion>@MOZ_APP_VERSION@</em:minVersion>
--- a/browser/extensions/activity-stream/test/unit/unit-entry.js
+++ b/browser/extensions/activity-stream/test/unit/unit-entry.js
@@ -22,16 +22,18 @@ overrider.set({
now: () => window.performance.now()
},
isSuccessCode: () => true
},
// eslint-disable-next-line object-shorthand
ContentSearchUIController: function() {}, // NB: This is a function/constructor
dump() {},
fetch() {},
+ // eslint-disable-next-line object-shorthand
+ Image: function() {}, // NB: This is a function/constructor
Preferences: FakePrefs,
Services: {
locale: {
getAppLocalesAsLangTags() {},
getRequestedLocale() {},
negotiateLanguages() {}
},
urlFormatter: {formatURL: str => str},