Bug 1519879 - Spoc Items in List. r=Mardak a=lizzard
authork88hudson <k88hudson@gmail.com>
Sat, 16 Feb 2019 00:49:06 +0200
changeset 515986 0bb65ba5bb92dd0722091e18e33b5c2e1d80521d
parent 515985 5bd6cc8899ceeda1f462e83f2cad90b51dff621e
child 515987 3c371055b3815f02899b6a5055496d29e35f79b6
push id1953
push userffxbld-merge
push dateMon, 11 Mar 2019 12:10:20 +0000
treeherdermozilla-release@9c35dcbaa899 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersMardak, lizzard
bugs1519879
milestone66.0
Bug 1519879 - Spoc Items in List. r=Mardak a=lizzard Reviewers: Mardak Reviewed By: Mardak Bug #: 1519879 Differential Revision: https://phabricator.services.mozilla.com/D19712
browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx
browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx
browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/Hero.jsx
browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/List.jsx
browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/_List.scss
browser/components/newtab/content-src/components/DiscoveryStreamComponents/SpocIntersectionObserver/SpocIntersectionObserver.jsx
browser/components/newtab/css/activity-stream-linux.css
browser/components/newtab/css/activity-stream-mac.css
browser/components/newtab/css/activity-stream-windows.css
browser/components/newtab/data/content/activity-stream.bundle.js
browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/List.test.jsx
--- a/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx
@@ -172,17 +172,17 @@ export class _DiscoveryStreamBase extend
         );
       case "HorizontalRule":
         return (<HorizontalRule />);
       case "List":
         rows = this.extractRows(component, MAX_ROWS_LIST);
         return (
           <ImpressionStats rows={rows} dispatch={this.props.dispatch} source={component.type}>
             <List
-              feed={component.feed}
+              data={component.data}
               fullWidth={component.properties.full_width}
               hasBorders={component.properties.border === "border"}
               hasImages={component.properties.has_images}
               hasNumbers={component.properties.has_numbers}
               items={component.properties.items}
               type={component.type}
               header={component.header} />
           </ImpressionStats>
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx
@@ -1,70 +1,19 @@
-import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
+import {actionCreators as ac} from "common/Actions.jsm";
 import React from "react";
-
-const VISIBLE = "visible";
-const VISIBILITY_CHANGE_EVENT = "visibilitychange";
-const INTERSECTION_RATIO = 0.5;
+import {SpocIntersectionObserver} from "content-src/components/DiscoveryStreamComponents/SpocIntersectionObserver/SpocIntersectionObserver";
 
 export class DSCard extends React.PureComponent {
   constructor(props) {
     super(props);
 
-    this.cardElementRef = this.cardElementRef.bind(this);
     this.onLinkClick = this.onLinkClick.bind(this);
   }
 
-  componentDidMount() {
-    if (this.props.document.visibilityState === VISIBLE) {
-      this.setupIntersectionObserver();
-    } else {
-      this._onVisibilityChange = () => {
-        if (this.props.document.visibilityState === VISIBLE) {
-          this.setupIntersectionObserver();
-          this.props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
-        }
-      };
-      this.props.document.addEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
-    }
-  }
-
-  componentWillUnmount() {
-    if (this._onVisibilityChange) {
-      this.props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
-    }
-    if (this._intersectionObserver) {
-      this._intersectionObserver.unobserve(this.cardElement);
-    }
-  }
-
-  setupIntersectionObserver() {
-    const options = {threshold: INTERSECTION_RATIO};
-    this._intersectionObserver = new IntersectionObserver(entries => {
-      for (let entry of entries) {
-        if (entry.isIntersecting && entry.intersectionRatio >= INTERSECTION_RATIO) {
-          this.dispatchSpocImpression();
-          break;
-        }
-      }
-    }, options);
-    this._intersectionObserver.observe(this.cardElement);
-  }
-
-  dispatchSpocImpression() {
-    if (this.props.campaignId) {
-      this.props.dispatch(ac.OnlyToMain({type: at.DISCOVERY_STREAM_SPOC_IMPRESSION, data: {campaignId: this.props.campaignId}}));
-    }
-    this._intersectionObserver.unobserve(this.cardElement);
-  }
-
-  cardElementRef(element) {
-    this.cardElement = element;
-  }
-
   onLinkClick(event) {
     if (this.props.dispatch) {
       this.props.dispatch(ac.UserEvent({
         event: "CLICK",
         source: this.props.type.toUpperCase(),
         action_position: this.props.index,
       }));
 
@@ -73,35 +22,33 @@ export class DSCard extends React.PureCo
         click: 0,
         tiles: [{id: this.props.id, pos: this.props.index}],
       }));
     }
   }
 
   render() {
     return (
-      <a href={this.props.url} className="ds-card" onClick={this.onLinkClick} ref={this.cardElementRef}>
-        <div className="img-wrapper">
-          <div className="img" style={{backgroundImage: `url(${this.props.image_src}`}} />
-        </div>
-        <div className="meta">
-          <div className="info-wrap">
-            <header className="title">{this.props.title}</header>
-            {this.props.excerpt && <p className="excerpt">{this.props.excerpt}</p>}
+      <SpocIntersectionObserver campaignId={this.props.campaignId} dispatch={this.props.dispatch}>
+        <a href={this.props.url} className="ds-card" onClick={this.onLinkClick}>
+          <div className="img-wrapper">
+            <div className="img" style={{backgroundImage: `url(${this.props.image_src}`}} />
           </div>
-          <p>
-            {this.props.context && (
-              <span>
-                <span className="context">{this.props.context}</span>
-                <br />
-              </span>
-            )}
-            <span className="source">{this.props.source}</span>
-          </p>
-        </div>
-      </a>
+          <div className="meta">
+            <div className="info-wrap">
+              <header className="title">{this.props.title}</header>
+              {this.props.excerpt && <p className="excerpt">{this.props.excerpt}</p>}
+            </div>
+            <p>
+              {this.props.context && (
+                <span>
+                  <span className="context">{this.props.context}</span>
+                  <br />
+                </span>
+              )}
+              <span className="source">{this.props.source}</span>
+            </p>
+          </div>
+        </a>
+      </SpocIntersectionObserver>
     );
   }
 }
-
-DSCard.defaultProps = {
-  document: global.document,
-};
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/Hero.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/Hero.jsx
@@ -52,17 +52,17 @@ export class Hero extends React.PureComp
         dispatch={this.props.dispatch}
         context={rec.context}
         source={rec.domain} />
     ));
 
     let list = (
       <List
         recStartingPoint={1}
-        feed={this.props.feed}
+        data={data}
         hasImages={true}
         hasBorders={this.props.border === `border`}
         items={this.props.items - 1}
         type={`Hero`} />
     );
 
     return (
       <div>
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/List.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/List.jsx
@@ -1,11 +1,12 @@
 import {actionCreators as ac} from "common/Actions.jsm";
 import {connect} from "react-redux";
 import React from "react";
+import {SpocIntersectionObserver} from "content-src/components/DiscoveryStreamComponents/SpocIntersectionObserver/SpocIntersectionObserver";
 
 /**
  * @note exported for testing only
  */
 export class ListItem extends React.PureComponent {
   // TODO performance: get feeds to send appropriately sized images rather
   // than waiting longer and scaling down on client?
   constructor(props) {
@@ -26,56 +27,64 @@ export class ListItem extends React.Pure
         click: 0,
         tiles: [{id: this.props.id, pos: this.props.index}],
       }));
     }
   }
 
   render() {
     return (
-      <li className="ds-list-item">
-        <a className="ds-list-item-link" href={this.props.url} onClick={this.onLinkClick}>
-          <div className="ds-list-item-text">
-            <div className="ds-list-item-title">{this.props.title}</div>
-            {this.props.excerpt && <div className="ds-list-item-excerpt">{this.props.excerpt}</div>}
-            <div className="ds-list-item-info">{this.props.domain}</div>
-          </div>
-          <div className="ds-list-image" style={{backgroundImage: `url(${this.props.image_src})`}} />
-        </a>
-      </li>
+      <SpocIntersectionObserver campaignId={this.props.campaignId} dispatch={this.props.dispatch}>
+        <li className="ds-list-item">
+          <a className="ds-list-item-link" href={this.props.url} onClick={this.onLinkClick}>
+            <div className="ds-list-item-text">
+              <div className="ds-list-item-title">{this.props.title}</div>
+              {this.props.excerpt && <div className="ds-list-item-excerpt">{this.props.excerpt}</div>}
+              <p>
+                {this.props.context && (
+                  <span>
+                    <span className="ds-list-item-context">{this.props.context}</span>
+                    <br />
+                  </span>
+                )}
+                <span className="ds-list-item-info">{this.props.domain}</span>
+              </p>
+            </div>
+            <div className="ds-list-image" style={{backgroundImage: `url(${this.props.image_src})`}} />
+          </a>
+        </li>
+      </SpocIntersectionObserver>
     );
   }
 }
 
 /**
  * @note exported for testing only
  */
 export function _List(props) {
-  const feed = props.DiscoveryStream.feeds[props.feed.url];
-
-  if (!feed || !feed.data || !feed.data.recommendations) {
+  const feed = props.data;
+  if (!feed || !feed.recommendations) {
     return null;
   }
-
-  const recs = feed.data.recommendations;
-
+  const recs = feed.recommendations;
   let recMarkup = recs.slice(props.recStartingPoint,
                              props.recStartingPoint + props.items).map((rec, index) => (
     <ListItem key={`ds-list-item-${index}`}
+      campaignId={rec.campaign_id}
       dispatch={props.dispatch}
       domain={rec.domain}
       excerpt={rec.excerpt}
       id={rec.id}
       image_src={rec.image_src}
       index={index}
       title={rec.title}
+      context={rec.context}
       type={props.type}
       url={rec.url} />)
   );
-
   const listStyles = [
     "ds-list",
     props.fullWidth ? "ds-list-full-width" : "",
     props.hasBorders ? "ds-list-borders" : "",
     props.hasImages ? "ds-list-images" : "",
     props.hasNumbers ? "ds-list-numbers" : "",
   ];
   return (
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/_List.scss
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/_List.scss
@@ -165,19 +165,27 @@
   }
 
   .ds-list-item-excerpt {
     @include limit-visibile-lines(2, $item-line-height, $item-font-size);
     color: var(--newtab-text-secondary-color);
     margin: 4px 0 8px;
   }
 
-  .ds-list-item-info {
+  p {
+    font-size: $item-font-size * 1px;
+    line-height: $item-line-height * 1px;
+    margin: 8px 0 0;
+  }
+
+  .ds-list-item-info,
+  .ds-list-item-context {
     @include limit-visibile-lines(1, $item-line-height, $item-font-size);
-    color: var(--newtab-text-secondary-color);
+    color: $grey-50;
+    font-size: 13px;
     text-overflow: ellipsis;
   }
 
   .ds-list-item-title {
     font-weight: 600;
     margin-bottom: 4px;
   }
 
copy from browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx
copy to browser/components/newtab/content-src/components/DiscoveryStreamComponents/SpocIntersectionObserver/SpocIntersectionObserver.jsx
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SpocIntersectionObserver/SpocIntersectionObserver.jsx
@@ -1,21 +1,19 @@
 import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
 import React from "react";
-
 const VISIBLE = "visible";
 const VISIBILITY_CHANGE_EVENT = "visibilitychange";
 const INTERSECTION_RATIO = 0.5;
 
-export class DSCard extends React.PureComponent {
+export class SpocIntersectionObserver extends React.PureComponent {
   constructor(props) {
     super(props);
 
-    this.cardElementRef = this.cardElementRef.bind(this);
-    this.onLinkClick = this.onLinkClick.bind(this);
+    this.spocElementRef = this.spocElementRef.bind(this);
   }
 
   componentDidMount() {
     if (this.props.document.visibilityState === VISIBLE) {
       this.setupIntersectionObserver();
     } else {
       this._onVisibilityChange = () => {
         if (this.props.document.visibilityState === VISIBLE) {
@@ -27,81 +25,48 @@ export class DSCard extends React.PureCo
     }
   }
 
   componentWillUnmount() {
     if (this._onVisibilityChange) {
       this.props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
     }
     if (this._intersectionObserver) {
-      this._intersectionObserver.unobserve(this.cardElement);
+      this._intersectionObserver.unobserve(this.spocElement);
     }
   }
 
   setupIntersectionObserver() {
     const options = {threshold: INTERSECTION_RATIO};
     this._intersectionObserver = new IntersectionObserver(entries => {
       for (let entry of entries) {
         if (entry.isIntersecting && entry.intersectionRatio >= INTERSECTION_RATIO) {
           this.dispatchSpocImpression();
           break;
         }
       }
     }, options);
-    this._intersectionObserver.observe(this.cardElement);
+    this._intersectionObserver.observe(this.spocElement);
   }
 
   dispatchSpocImpression() {
     if (this.props.campaignId) {
       this.props.dispatch(ac.OnlyToMain({type: at.DISCOVERY_STREAM_SPOC_IMPRESSION, data: {campaignId: this.props.campaignId}}));
     }
-    this._intersectionObserver.unobserve(this.cardElement);
-  }
-
-  cardElementRef(element) {
-    this.cardElement = element;
+    this._intersectionObserver.unobserve(this.spocElement);
   }
 
-  onLinkClick(event) {
-    if (this.props.dispatch) {
-      this.props.dispatch(ac.UserEvent({
-        event: "CLICK",
-        source: this.props.type.toUpperCase(),
-        action_position: this.props.index,
-      }));
-
-      this.props.dispatch(ac.ImpressionStats({
-        source: this.props.type.toUpperCase(),
-        click: 0,
-        tiles: [{id: this.props.id, pos: this.props.index}],
-      }));
-    }
+  spocElementRef(element) {
+    this.spocElement = element;
   }
 
   render() {
     return (
-      <a href={this.props.url} className="ds-card" onClick={this.onLinkClick} ref={this.cardElementRef}>
-        <div className="img-wrapper">
-          <div className="img" style={{backgroundImage: `url(${this.props.image_src}`}} />
-        </div>
-        <div className="meta">
-          <div className="info-wrap">
-            <header className="title">{this.props.title}</header>
-            {this.props.excerpt && <p className="excerpt">{this.props.excerpt}</p>}
-          </div>
-          <p>
-            {this.props.context && (
-              <span>
-                <span className="context">{this.props.context}</span>
-                <br />
-              </span>
-            )}
-            <span className="source">{this.props.source}</span>
-          </p>
-        </div>
-      </a>
+      <div ref={this.spocElementRef}>
+        {this.props.children}
+      </div>
     );
   }
 }
 
-DSCard.defaultProps = {
+SpocIntersectionObserver.defaultProps = {
   document: global.document,
 };
--- a/browser/components/newtab/css/activity-stream-linux.css
+++ b/browser/components/newtab/css/activity-stream-linux.css
@@ -2159,22 +2159,28 @@ main {
     justify-content: space-between; }
   .ds-list-item .ds-list-item-excerpt {
     font-size: 14px;
     line-height: 20px;
     max-height: 2.85714em;
     overflow: hidden;
     color: var(--newtab-text-secondary-color);
     margin: 4px 0 8px; }
-  .ds-list-item .ds-list-item-info {
+  .ds-list-item p {
+    font-size: 14px;
+    line-height: 20px;
+    margin: 8px 0 0; }
+  .ds-list-item .ds-list-item-info,
+  .ds-list-item .ds-list-item-context {
     font-size: 14px;
     line-height: 20px;
     max-height: 1.42857em;
     overflow: hidden;
-    color: var(--newtab-text-secondary-color);
+    color: #737373;
+    font-size: 13px;
     text-overflow: ellipsis; }
   .ds-list-item .ds-list-item-title {
     font-weight: 600;
     margin-bottom: 4px; }
   .ds-list-item .ds-list-item-text {
     display: flex;
     flex-direction: column; }
   .ds-list-item .ds-list-image {
--- a/browser/components/newtab/css/activity-stream-mac.css
+++ b/browser/components/newtab/css/activity-stream-mac.css
@@ -2162,22 +2162,28 @@ main {
     justify-content: space-between; }
   .ds-list-item .ds-list-item-excerpt {
     font-size: 14px;
     line-height: 20px;
     max-height: 2.85714em;
     overflow: hidden;
     color: var(--newtab-text-secondary-color);
     margin: 4px 0 8px; }
-  .ds-list-item .ds-list-item-info {
+  .ds-list-item p {
+    font-size: 14px;
+    line-height: 20px;
+    margin: 8px 0 0; }
+  .ds-list-item .ds-list-item-info,
+  .ds-list-item .ds-list-item-context {
     font-size: 14px;
     line-height: 20px;
     max-height: 1.42857em;
     overflow: hidden;
-    color: var(--newtab-text-secondary-color);
+    color: #737373;
+    font-size: 13px;
     text-overflow: ellipsis; }
   .ds-list-item .ds-list-item-title {
     font-weight: 600;
     margin-bottom: 4px; }
   .ds-list-item .ds-list-item-text {
     display: flex;
     flex-direction: column; }
   .ds-list-item .ds-list-image {
--- a/browser/components/newtab/css/activity-stream-windows.css
+++ b/browser/components/newtab/css/activity-stream-windows.css
@@ -2159,22 +2159,28 @@ main {
     justify-content: space-between; }
   .ds-list-item .ds-list-item-excerpt {
     font-size: 14px;
     line-height: 20px;
     max-height: 2.85714em;
     overflow: hidden;
     color: var(--newtab-text-secondary-color);
     margin: 4px 0 8px; }
-  .ds-list-item .ds-list-item-info {
+  .ds-list-item p {
+    font-size: 14px;
+    line-height: 20px;
+    margin: 8px 0 0; }
+  .ds-list-item .ds-list-item-info,
+  .ds-list-item .ds-list-item-context {
     font-size: 14px;
     line-height: 20px;
     max-height: 1.42857em;
     overflow: hidden;
-    color: var(--newtab-text-secondary-color);
+    color: #737373;
+    font-size: 13px;
     text-overflow: ellipsis; }
   .ds-list-item .ds-list-item-title {
     font-weight: 600;
     margin-bottom: 4px; }
   .ds-list-item .ds-list-item-text {
     display: flex;
     flex-direction: column; }
   .ds-list-item .ds-list-image {
--- a/browser/components/newtab/data/content/activity-stream.bundle.js
+++ b/browser/components/newtab/data/content/activity-stream.bundle.js
@@ -3712,33 +3712,31 @@ class _ConfirmDialog extends react__WEBP
 const ConfirmDialog = Object(react_redux__WEBPACK_IMPORTED_MODULE_1__["connect"])(state => state.Dialog)(_ConfirmDialog);
 
 /***/ }),
 /* 29 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
-/* WEBPACK VAR INJECTION */(function(global) {/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "DSCard", function() { return DSCard; });
+/* WEBPACK VAR INJECTION */(function(global) {/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SpocIntersectionObserver", function() { return SpocIntersectionObserver; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(10);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_1__);
 
 
-
 const VISIBLE = "visible";
 const VISIBILITY_CHANGE_EVENT = "visibilitychange";
 const INTERSECTION_RATIO = 0.5;
 
-class DSCard extends react__WEBPACK_IMPORTED_MODULE_1___default.a.PureComponent {
+class SpocIntersectionObserver extends react__WEBPACK_IMPORTED_MODULE_1___default.a.PureComponent {
   constructor(props) {
     super(props);
 
-    this.cardElementRef = this.cardElementRef.bind(this);
-    this.onLinkClick = this.onLinkClick.bind(this);
+    this.spocElementRef = this.spocElementRef.bind(this);
   }
 
   componentDidMount() {
     if (this.props.document.visibilityState === VISIBLE) {
       this.setupIntersectionObserver();
     } else {
       this._onVisibilityChange = () => {
         if (this.props.document.visibilityState === VISIBLE) {
@@ -3750,111 +3748,54 @@ class DSCard extends react__WEBPACK_IMPO
     }
   }
 
   componentWillUnmount() {
     if (this._onVisibilityChange) {
       this.props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
     }
     if (this._intersectionObserver) {
-      this._intersectionObserver.unobserve(this.cardElement);
+      this._intersectionObserver.unobserve(this.spocElement);
     }
   }
 
   setupIntersectionObserver() {
     const options = { threshold: INTERSECTION_RATIO };
     this._intersectionObserver = new IntersectionObserver(entries => {
       for (let entry of entries) {
         if (entry.isIntersecting && entry.intersectionRatio >= INTERSECTION_RATIO) {
           this.dispatchSpocImpression();
           break;
         }
       }
     }, options);
-    this._intersectionObserver.observe(this.cardElement);
+    this._intersectionObserver.observe(this.spocElement);
   }
 
   dispatchSpocImpression() {
     if (this.props.campaignId) {
       this.props.dispatch(common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].OnlyToMain({ type: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionTypes"].DISCOVERY_STREAM_SPOC_IMPRESSION, data: { campaignId: this.props.campaignId } }));
     }
-    this._intersectionObserver.unobserve(this.cardElement);
-  }
-
-  cardElementRef(element) {
-    this.cardElement = element;
-  }
-
-  onLinkClick(event) {
-    if (this.props.dispatch) {
-      this.props.dispatch(common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].UserEvent({
-        event: "CLICK",
-        source: this.props.type.toUpperCase(),
-        action_position: this.props.index
-      }));
-
-      this.props.dispatch(common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].ImpressionStats({
-        source: this.props.type.toUpperCase(),
-        click: 0,
-        tiles: [{ id: this.props.id, pos: this.props.index }]
-      }));
-    }
+    this._intersectionObserver.unobserve(this.spocElement);
+  }
+
+  spocElementRef(element) {
+    this.spocElement = element;
   }
 
   render() {
     return react__WEBPACK_IMPORTED_MODULE_1___default.a.createElement(
-      "a",
-      { href: this.props.url, className: "ds-card", onClick: this.onLinkClick, ref: this.cardElementRef },
-      react__WEBPACK_IMPORTED_MODULE_1___default.a.createElement(
-        "div",
-        { className: "img-wrapper" },
-        react__WEBPACK_IMPORTED_MODULE_1___default.a.createElement("div", { className: "img", style: { backgroundImage: `url(${this.props.image_src}` } })
-      ),
-      react__WEBPACK_IMPORTED_MODULE_1___default.a.createElement(
-        "div",
-        { className: "meta" },
-        react__WEBPACK_IMPORTED_MODULE_1___default.a.createElement(
-          "div",
-          { className: "info-wrap" },
-          react__WEBPACK_IMPORTED_MODULE_1___default.a.createElement(
-            "header",
-            { className: "title" },
-            this.props.title
-          ),
-          this.props.excerpt && react__WEBPACK_IMPORTED_MODULE_1___default.a.createElement(
-            "p",
-            { className: "excerpt" },
-            this.props.excerpt
-          )
-        ),
-        react__WEBPACK_IMPORTED_MODULE_1___default.a.createElement(
-          "p",
-          null,
-          this.props.context && react__WEBPACK_IMPORTED_MODULE_1___default.a.createElement(
-            "span",
-            null,
-            react__WEBPACK_IMPORTED_MODULE_1___default.a.createElement(
-              "span",
-              { className: "context" },
-              this.props.context
-            ),
-            react__WEBPACK_IMPORTED_MODULE_1___default.a.createElement("br", null)
-          ),
-          react__WEBPACK_IMPORTED_MODULE_1___default.a.createElement(
-            "span",
-            { className: "source" },
-            this.props.source
-          )
-        )
-      )
+      "div",
+      { ref: this.spocElementRef },
+      this.props.children
     );
   }
 }
 
-DSCard.defaultProps = {
+SpocIntersectionObserver.defaultProps = {
   document: global.document
 };
 /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(1)))
 
 /***/ }),
 /* 30 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
@@ -7161,37 +7102,121 @@ function enableASRouterContent(store, as
 }
 
 /***/ }),
 /* 55 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 
-// EXTERNAL MODULE: ./content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx
-var DSCard = __webpack_require__(29);
+// EXTERNAL MODULE: ./common/Actions.jsm
+var Actions = __webpack_require__(2);
 
 // EXTERNAL MODULE: external "React"
 var external_React_ = __webpack_require__(10);
 var external_React_default = /*#__PURE__*/__webpack_require__.n(external_React_);
 
+// EXTERNAL MODULE: ./content-src/components/DiscoveryStreamComponents/SpocIntersectionObserver/SpocIntersectionObserver.jsx
+var SpocIntersectionObserver = __webpack_require__(29);
+
+// CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx
+
+
+
+
+class DSCard_DSCard extends external_React_default.a.PureComponent {
+  constructor(props) {
+    super(props);
+
+    this.onLinkClick = this.onLinkClick.bind(this);
+  }
+
+  onLinkClick(event) {
+    if (this.props.dispatch) {
+      this.props.dispatch(Actions["actionCreators"].UserEvent({
+        event: "CLICK",
+        source: this.props.type.toUpperCase(),
+        action_position: this.props.index
+      }));
+
+      this.props.dispatch(Actions["actionCreators"].ImpressionStats({
+        source: this.props.type.toUpperCase(),
+        click: 0,
+        tiles: [{ id: this.props.id, pos: this.props.index }]
+      }));
+    }
+  }
+
+  render() {
+    return external_React_default.a.createElement(
+      SpocIntersectionObserver["SpocIntersectionObserver"],
+      { campaignId: this.props.campaignId, dispatch: this.props.dispatch },
+      external_React_default.a.createElement(
+        "a",
+        { href: this.props.url, className: "ds-card", onClick: this.onLinkClick },
+        external_React_default.a.createElement(
+          "div",
+          { className: "img-wrapper" },
+          external_React_default.a.createElement("div", { className: "img", style: { backgroundImage: `url(${this.props.image_src}` } })
+        ),
+        external_React_default.a.createElement(
+          "div",
+          { className: "meta" },
+          external_React_default.a.createElement(
+            "div",
+            { className: "info-wrap" },
+            external_React_default.a.createElement(
+              "header",
+              { className: "title" },
+              this.props.title
+            ),
+            this.props.excerpt && external_React_default.a.createElement(
+              "p",
+              { className: "excerpt" },
+              this.props.excerpt
+            )
+          ),
+          external_React_default.a.createElement(
+            "p",
+            null,
+            this.props.context && external_React_default.a.createElement(
+              "span",
+              null,
+              external_React_default.a.createElement(
+                "span",
+                { className: "context" },
+                this.props.context
+              ),
+              external_React_default.a.createElement("br", null)
+            ),
+            external_React_default.a.createElement(
+              "span",
+              { className: "source" },
+              this.props.source
+            )
+          )
+        )
+      )
+    );
+  }
+}
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx
 
 
 
 class CardGrid_CardGrid extends external_React_default.a.PureComponent {
   render() {
     const { data } = this.props;
 
     // Handle a render before feed has been fetched by displaying nothing
     if (!data) {
       return external_React_default.a.createElement("div", null);
     }
 
-    let cards = data.recommendations.slice(0, this.props.items).map((rec, index) => external_React_default.a.createElement(DSCard["DSCard"], {
+    let cards = data.recommendations.slice(0, this.props.items).map((rec, index) => external_React_default.a.createElement(DSCard_DSCard, {
       campaignId: rec.campaign_id,
       key: `dscard-${index}`,
       image_src: rec.image_src,
       title: rec.title,
       excerpt: rec.excerpt,
       url: rec.url,
       id: rec.id,
       index: index,
@@ -7266,24 +7291,22 @@ class DSMessage_DSMessage extends extern
           { href: this.props.link_url },
           this.props.link_text
         )
       ),
       external_React_default.a.createElement("hr", { className: "ds-hr" })
     );
   }
 }
-// EXTERNAL MODULE: ./common/Actions.jsm
-var Actions = __webpack_require__(2);
-
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/List/List.jsx
 
 
 
 
+
 /**
  * @note exported for testing only
  */
 class List_ListItem extends external_React_default.a.PureComponent {
   // TODO performance: get feeds to send appropriately sized images rather
   // than waiting longer and scaling down on client?
   constructor(props) {
     super(props);
@@ -7303,69 +7326,85 @@ class List_ListItem extends external_Rea
         click: 0,
         tiles: [{ id: this.props.id, pos: this.props.index }]
       }));
     }
   }
 
   render() {
     return external_React_default.a.createElement(
-      "li",
-      { className: "ds-list-item" },
+      SpocIntersectionObserver["SpocIntersectionObserver"],
+      { campaignId: this.props.campaignId, dispatch: this.props.dispatch },
       external_React_default.a.createElement(
-        "a",
-        { className: "ds-list-item-link", href: this.props.url, onClick: this.onLinkClick },
+        "li",
+        { className: "ds-list-item" },
         external_React_default.a.createElement(
-          "div",
-          { className: "ds-list-item-text" },
+          "a",
+          { className: "ds-list-item-link", href: this.props.url, onClick: this.onLinkClick },
           external_React_default.a.createElement(
             "div",
-            { className: "ds-list-item-title" },
-            this.props.title
-          ),
-          this.props.excerpt && external_React_default.a.createElement(
-            "div",
-            { className: "ds-list-item-excerpt" },
-            this.props.excerpt
+            { className: "ds-list-item-text" },
+            external_React_default.a.createElement(
+              "div",
+              { className: "ds-list-item-title" },
+              this.props.title
+            ),
+            this.props.excerpt && external_React_default.a.createElement(
+              "div",
+              { className: "ds-list-item-excerpt" },
+              this.props.excerpt
+            ),
+            external_React_default.a.createElement(
+              "p",
+              null,
+              this.props.context && external_React_default.a.createElement(
+                "span",
+                null,
+                external_React_default.a.createElement(
+                  "span",
+                  { className: "ds-list-item-context" },
+                  this.props.context
+                ),
+                external_React_default.a.createElement("br", null)
+              ),
+              external_React_default.a.createElement(
+                "span",
+                { className: "ds-list-item-info" },
+                this.props.domain
+              )
+            )
           ),
-          external_React_default.a.createElement(
-            "div",
-            { className: "ds-list-item-info" },
-            this.props.domain
-          )
-        ),
-        external_React_default.a.createElement("div", { className: "ds-list-image", style: { backgroundImage: `url(${this.props.image_src})` } })
+          external_React_default.a.createElement("div", { className: "ds-list-image", style: { backgroundImage: `url(${this.props.image_src})` } })
+        )
       )
     );
   }
 }
 
 /**
  * @note exported for testing only
  */
 function _List(props) {
-  const feed = props.DiscoveryStream.feeds[props.feed.url];
-
-  if (!feed || !feed.data || !feed.data.recommendations) {
+  const feed = props.data;
+  if (!feed || !feed.recommendations) {
     return null;
   }
-
-  const recs = feed.data.recommendations;
-
+  const recs = feed.recommendations;
   let recMarkup = recs.slice(props.recStartingPoint, props.recStartingPoint + props.items).map((rec, index) => external_React_default.a.createElement(List_ListItem, { key: `ds-list-item-${index}`,
+    campaignId: rec.campaign_id,
     dispatch: props.dispatch,
     domain: rec.domain,
     excerpt: rec.excerpt,
     id: rec.id,
     image_src: rec.image_src,
     index: index,
     title: rec.title,
+    context: rec.context,
     type: props.type,
     url: rec.url }));
-
   const listStyles = ["ds-list", props.fullWidth ? "ds-list-full-width" : "", props.hasBorders ? "ds-list-borders" : "", props.hasImages ? "ds-list-images" : "", props.hasNumbers ? "ds-list-numbers" : ""];
   return external_React_default.a.createElement(
     "div",
     null,
     props.header && props.header.title ? external_React_default.a.createElement(
       "div",
       { className: "ds-header" },
       props.header.title
@@ -7423,32 +7462,32 @@ class Hero_Hero extends external_React_d
     if (!data || !data.recommendations) {
       return external_React_default.a.createElement("div", null);
     }
 
     let [heroRec, ...otherRecs] = data.recommendations.slice(0, this.props.items);
     this.heroRec = heroRec;
 
     // Note that `{index + 1}` is necessary below for telemetry since we treat heroRec as index 0.
-    let cards = otherRecs.map((rec, index) => external_React_default.a.createElement(DSCard["DSCard"], {
+    let cards = otherRecs.map((rec, index) => external_React_default.a.createElement(DSCard_DSCard, {
       campaignId: rec.campaign_id,
       key: `dscard-${index}`,
       image_src: rec.image_src,
       title: rec.title,
       url: rec.url,
       id: rec.id,
       index: index + 1,
       type: this.props.type,
       dispatch: this.props.dispatch,
       context: rec.context,
       source: rec.domain }));
 
     let list = external_React_default.a.createElement(List, {
       recStartingPoint: 1,
-      feed: this.props.feed,
+      data: data,
       hasImages: true,
       hasBorders: this.props.border === `border`,
       items: this.props.items - 1,
       type: `Hero` });
 
     return external_React_default.a.createElement(
       "div",
       null,
@@ -7950,17 +7989,17 @@ class DiscoveryStreamBase_DiscoveryStrea
       case "HorizontalRule":
         return external_React_default.a.createElement(HorizontalRule_HorizontalRule, null);
       case "List":
         rows = this.extractRows(component, MAX_ROWS_LIST);
         return external_React_default.a.createElement(
           ImpressionStats["ImpressionStats"],
           { rows: rows, dispatch: this.props.dispatch, source: component.type },
           external_React_default.a.createElement(List, {
-            feed: component.feed,
+            data: component.data,
             fullWidth: component.properties.full_width,
             hasBorders: component.properties.border === "border",
             hasImages: component.properties.has_images,
             hasNumbers: component.properties.has_numbers,
             items: component.properties.items,
             type: component.type,
             header: component.header })
         );
--- a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/List.test.jsx
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/List.test.jsx
@@ -1,41 +1,33 @@
 import {_List as List, ListItem} from "content-src/components/DiscoveryStreamComponents/List/List";
 import {GlobalOverrider} from "test/unit/utils";
 import React from "react";
 import {shallow} from "enzyme";
 
 describe("<List> presentation component", () => {
-  const ValidRecommendations = [{a: 1}, {a: 2}];
+  const ValidRecommendations = [{url: 1}, {url: 2}, {campaign_id: 11, context: "test spoc", url: 3}];
   const ValidListProps = {
-    DiscoveryStream: {
-      feeds: {
-        fakeFeedUrl: {
-          data: {
-            recommendations: ValidRecommendations,
-          },
-        },
-      },
+    data: {
+      recommendations: ValidRecommendations,
     },
     feed: {
       url: "fakeFeedUrl",
     },
     header: {
       title: "fakeFeedTitle",
     },
   };
 
   it("should return null if feed.data is falsy", () => {
     const ListProps = {
-      DiscoveryStream: {feeds: {a: "stuff"}},
-      feed: {url: "a"},
+      data: {feeds: {a: "stuff"}},
     };
 
     const wrapper = shallow(<List {...ListProps} />);
-
     assert.isNull(wrapper.getElement());
   });
 
   it("should return something containing a <ul> if props are valid", () => {
     const wrapper = shallow(<List {...ValidListProps} />);
 
     const list = wrapper.find("ul");
     assert.ok(wrapper.exists());
@@ -43,26 +35,63 @@ describe("<List> presentation component"
   });
 
   it("should return the right number of ListItems if props are valid", () => {
     const wrapper = shallow(<List {...ValidListProps} />);
 
     const listItem = wrapper.find(ListItem);
     assert.lengthOf(listItem, ValidRecommendations.length);
   });
+
+  it("should return fewer ListItems for fewer items", () => {
+    const wrapper = shallow(<List {...ValidListProps} items={1} />);
+
+    const listItem = wrapper.find(ListItem);
+    assert.lengthOf(listItem, 1);
+  });
+
+  it("should return fewer ListItems for starting point", () => {
+    const wrapper = shallow(<List {...ValidListProps} recStartingPoint={1} />);
+
+    const listItem = wrapper.find(ListItem);
+    assert.lengthOf(listItem, ValidRecommendations.length - 1);
+  });
+
+  it("should return expected ListItems when offset", () => {
+    const wrapper = shallow(<List {...ValidListProps} items={2} recStartingPoint={1} />);
+
+    const listItemUrls = wrapper.find(ListItem).map(i => i.prop("url"));
+    assert.sameOrderedMembers(listItemUrls, [ValidRecommendations[1].url, ValidRecommendations[2].url]);
+  });
+
+  it("should return expected spoc ListItem", () => {
+    const wrapper = shallow(<List {...ValidListProps} items={3} recStartingPoint={0} />);
+
+    const listItemCampaigns = wrapper.find(ListItem).map(i => i.prop("campaignId"));
+    assert.sameOrderedMembers(listItemCampaigns, [undefined, undefined, ValidRecommendations[2].campaign_id]);
+
+    const listItemContext = wrapper.find(ListItem).map(i => i.prop("context"));
+    assert.sameOrderedMembers(listItemContext, [undefined, undefined, ValidRecommendations[2].context]);
+  });
 });
 
 describe("<ListItem> presentation component", () => {
   const ValidListItemProps = {
     url: "FAKE_URL",
     title: "FAKE_TITLE",
     domain: "example.com",
     image_src: "FAKE_IMAGE_SRC",
   };
-
+  const ValidLSpocListItemProps = {
+    url: "FAKE_URL",
+    title: "FAKE_TITLE",
+    domain: "example.com",
+    image_src: "FAKE_IMAGE_SRC",
+    context: "FAKE_CONTEXT",
+  };
   let globals;
 
   beforeEach(() => {
     globals = new GlobalOverrider();
   });
 
   afterEach(() => {
     globals.sandbox.restore();
@@ -77,9 +106,23 @@ describe("<ListItem> presentation compon
   });
 
   it("should include an background image of props.image_src", () => {
     const wrapper = shallow(<ListItem {...ValidListItemProps} />);
 
     const imageStyle = wrapper.find(".ds-list-image").prop("style");
     assert.propertyVal(imageStyle, "backgroundImage", `url(${ValidListItemProps.image_src})`);
   });
+
+  it("should not contain 'span.ds-list-item-context' without props.context", () => {
+    const wrapper = shallow(<ListItem {...ValidListItemProps} />);
+
+    const contextEl = wrapper.find("span.ds-list-item-context");
+    assert.lengthOf(contextEl, 0);
+  });
+
+  it("should contain 'span.ds-list-item-context' spoc element", () => {
+    const wrapper = shallow(<ListItem {...ValidLSpocListItemProps} />);
+
+    const contextEl = wrapper.find("span.ds-list-item-context");
+    assert.lengthOf(contextEl, 1);
+  });
 });