Bug 1405038 - Export 7470a9c6 - fix(card): Fade from gradient to loaded image only after loading (#3611) draft
authorEd Lee <edilee@mozilla.com>
Mon, 02 Oct 2017 10:32:12 -0700
changeset 673679 077d6661414ed9aa2391785605d340ceb3f8adda
parent 673678 2dd8dd0937019f533639088c3782d1bc9eb10a89
child 673680 1c6d32b32aceae4938f0de1e662ddfba5efd5b98
push id82602
push userbmo:edilee@mozilla.com
push dateMon, 02 Oct 2017 17:32:33 +0000
bugs1405038
milestone57.0
Bug 1405038 - Export 7470a9c6 - fix(card): Fade from gradient to loaded image only after loading (#3611) MozReview-Commit-ID: J9p2zI1J1H3
browser/extensions/activity-stream/data/content/activity-stream.bundle.js
browser/extensions/activity-stream/data/content/activity-stream.css
browser/extensions/activity-stream/install.rdf.in
browser/extensions/activity-stream/test/unit/unit-entry.js
--- 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},