Bug 1382785 - Add Pocket, search delay, and bug fixes to Activity Stream draft
authorUrsula Sarracini
Thu, 20 Jul 2017 16:59:59 -0400
changeset 612569 1fb5509fa452e257826c7a1882d3f52024c75a69
parent 612171 68046a58f82913eb7804e4796ec981f6f8ea490e
child 638450 57c94644bebebd3f14f5b1cce5652dd47857308e
push id69541
push userusarracini@mozilla.com
push dateThu, 20 Jul 2017 21:00:17 +0000
bugs1382785
milestone56.0a1
Bug 1382785 - Add Pocket, search delay, and bug fixes to Activity Stream MozReview-Commit-ID: CQEN0Rzy6TX
browser/extensions/activity-stream/common/Actions.jsm
browser/extensions/activity-stream/common/Reducers.jsm
browser/extensions/activity-stream/data/content/activity-stream.bundle.js
browser/extensions/activity-stream/data/content/activity-stream.css
browser/extensions/activity-stream/data/content/activity-stream.html
browser/extensions/activity-stream/data/content/assets/glyph-historyItem-16.svg
browser/extensions/activity-stream/data/content/assets/glyph-info-option-12.svg
browser/extensions/activity-stream/data/content/assets/glyph-now-16.svg
browser/extensions/activity-stream/data/content/assets/glyph-pocket-16.svg
browser/extensions/activity-stream/data/content/assets/glyph-trending-16.svg
browser/extensions/activity-stream/data/content/assets/topic-show-more-12.svg
browser/extensions/activity-stream/data/locales.json
browser/extensions/activity-stream/lib/ActivityStream.jsm
browser/extensions/activity-stream/lib/PlacesFeed.jsm
browser/extensions/activity-stream/lib/SnippetsFeed.jsm
browser/extensions/activity-stream/lib/Store.jsm
browser/extensions/activity-stream/lib/SystemTickFeed.jsm
browser/extensions/activity-stream/lib/TopStoriesFeed.jsm
browser/extensions/activity-stream/test/functional/mochitest/browser.ini
browser/extensions/activity-stream/test/mozinfo.json
browser/extensions/activity-stream/test/unit/common/Reducers.test.js
browser/extensions/activity-stream/test/unit/lib/ActivityStream.test.js
browser/extensions/activity-stream/test/unit/lib/PlacesFeed.test.js
browser/extensions/activity-stream/test/unit/lib/SnippetsFeed.test.js
browser/extensions/activity-stream/test/unit/lib/SystemTickFeed.test.js
browser/extensions/activity-stream/test/unit/lib/TopStoriesFeed.test.js
browser/extensions/activity-stream/test/unit/lib/init-store.test.js
browser/extensions/activity-stream/test/unit/unit-entry.js
--- a/browser/extensions/activity-stream/common/Actions.jsm
+++ b/browser/extensions/activity-stream/common/Actions.jsm
@@ -26,16 +26,17 @@ const actionTypes = {};
 for (const type of [
   "BLOCK_URL",
   "BOOKMARK_URL",
   "DELETE_BOOKMARK_BY_ID",
   "DELETE_HISTORY_URL",
   "DELETE_HISTORY_URL_CONFIRM",
   "DIALOG_CANCEL",
   "DIALOG_OPEN",
+  "FEED_INIT",
   "INIT",
   "LOCALE_UPDATED",
   "NEW_TAB_INITIAL_STATE",
   "NEW_TAB_LOAD",
   "NEW_TAB_UNLOAD",
   "NEW_TAB_VISIBLE",
   "OPEN_NEW_WINDOW",
   "OPEN_PRIVATE_WINDOW",
@@ -45,17 +46,23 @@ for (const type of [
   "PLACES_BOOKMARK_REMOVED",
   "PLACES_HISTORY_CLEARED",
   "PLACES_LINK_BLOCKED",
   "PLACES_LINK_DELETED",
   "PREFS_INITIAL_VALUES",
   "PREF_CHANGED",
   "SAVE_TO_POCKET",
   "SCREENSHOT_UPDATED",
+  "SECTION_DEREGISTER",
+  "SECTION_REGISTER",
+  "SECTION_ROWS_UPDATE",
   "SET_PREF",
+  "SNIPPETS_DATA",
+  "SNIPPETS_RESET",
+  "SYSTEM_TICK",
   "TELEMETRY_PERFORMANCE_EVENT",
   "TELEMETRY_UNDESIRED_EVENT",
   "TELEMETRY_USER_EVENT",
   "TOP_SITES_PIN",
   "TOP_SITES_UNPIN",
   "TOP_SITES_UPDATED",
   "UNINIT"
 ]) {
--- a/browser/extensions/activity-stream/common/Reducers.jsm
+++ b/browser/extensions/activity-stream/common/Reducers.jsm
@@ -11,30 +11,32 @@ const INITIAL_STATE = {
     initialized: false,
     // The locale of the browser
     locale: "",
     // Localized strings with defaults
     strings: {},
     // The version of the system-addon
     version: null
   },
+  Snippets: {initialized: false},
   TopSites: {
     // Have we received real data from history yet?
     initialized: false,
     // The history (and possibly default) links
     rows: []
   },
   Prefs: {
     initialized: false,
     values: {}
   },
   Dialog: {
     visible: false,
     data: {}
-  }
+  },
+  Sections: []
 };
 
 function App(prevState = INITIAL_STATE.App, action) {
   switch (action.type) {
     case at.INIT:
       return Object.assign({}, action.data || {}, {initialized: true});
     case at.LOCALE_UPDATED: {
       if (!action.data) {
@@ -100,25 +102,31 @@ function TopSites(prevState = INITIAL_ST
         if (row && row.url === action.data.url) {
           hasMatch = true;
           return Object.assign({}, row, {screenshot: action.data.screenshot});
         }
         return row;
       });
       return hasMatch ? Object.assign({}, prevState, {rows: newRows}) : prevState;
     case at.PLACES_BOOKMARK_ADDED:
+      if (!action.data) {
+        return prevState;
+      }
       newRows = prevState.rows.map(site => {
         if (site && site.url === action.data.url) {
           const {bookmarkGuid, bookmarkTitle, lastModified} = action.data;
           return Object.assign({}, site, {bookmarkGuid, bookmarkTitle, bookmarkDateCreated: lastModified});
         }
         return site;
       });
       return Object.assign({}, prevState, {rows: newRows});
     case at.PLACES_BOOKMARK_REMOVED:
+      if (!action.data) {
+        return prevState;
+      }
       newRows = prevState.rows.map(site => {
         if (site && site.url === action.data.url) {
           const newSite = Object.assign({}, site);
           delete newSite.bookmarkGuid;
           delete newSite.bookmarkTitle;
           delete newSite.bookmarkDateCreated;
           return newSite;
         }
@@ -160,13 +168,63 @@ function Prefs(prevState = INITIAL_STATE
       newValues = Object.assign({}, prevState.values);
       newValues[action.data.name] = action.data.value;
       return Object.assign({}, prevState, {values: newValues});
     default:
       return prevState;
   }
 }
 
+function Sections(prevState = INITIAL_STATE.Sections, action) {
+  let hasMatch;
+  let newState;
+  switch (action.type) {
+    case at.SECTION_DEREGISTER:
+      return prevState.filter(section => section.id !== action.data);
+    case at.SECTION_REGISTER:
+      // If section exists in prevState, update it
+      newState = prevState.map(section => {
+        if (section && section.id === action.data.id) {
+          hasMatch = true;
+          return Object.assign({}, section, action.data);
+        }
+        return section;
+      });
+      // If section doesn't exist in prevState, create a new section object and
+      // append it to the sections state
+      if (!hasMatch) {
+        const initialized = action.data.rows && action.data.rows.length > 0;
+        newState.push(Object.assign({title: "", initialized, rows: []}, action.data));
+      }
+      return newState;
+    case at.SECTION_ROWS_UPDATE:
+      return prevState.map(section => {
+        if (section && section.id === action.data.id) {
+          return Object.assign({}, section, action.data);
+        }
+        return section;
+      });
+    case at.PLACES_LINK_DELETED:
+    case at.PLACES_LINK_BLOCKED:
+      return prevState.map(section =>
+        Object.assign({}, section, {rows: section.rows.filter(site => site.url !== action.data.url)}));
+    default:
+      return prevState;
+  }
+}
+
+function Snippets(prevState = INITIAL_STATE.Snippets, action) {
+  switch (action.type) {
+    case at.SNIPPETS_DATA:
+      return Object.assign({}, prevState, {initialized: true}, action.data);
+    case at.SNIPPETS_RESET:
+      return INITIAL_STATE.Snippets;
+    default:
+      return prevState;
+  }
+}
+
 this.INITIAL_STATE = INITIAL_STATE;
-this.reducers = {TopSites, App, Prefs, Dialog};
+
+this.reducers = {TopSites, App, Snippets, Prefs, Dialog, Sections};
 this.insertPinned = insertPinned;
 
 this.EXPORTED_SYMBOLS = ["reducers", "INITIAL_STATE", "insertPinned"];
--- a/browser/extensions/activity-stream/data/content/activity-stream.bundle.js
+++ b/browser/extensions/activity-stream/data/content/activity-stream.bundle.js
@@ -1,78 +1,84 @@
 /******/ (function(modules) { // webpackBootstrap
 /******/ 	// The module cache
 /******/ 	var installedModules = {};
-/******/
+
 /******/ 	// The require function
 /******/ 	function __webpack_require__(moduleId) {
-/******/
+
 /******/ 		// Check if module is in cache
 /******/ 		if(installedModules[moduleId])
 /******/ 			return installedModules[moduleId].exports;
-/******/
+
 /******/ 		// Create a new module (and put it into the cache)
 /******/ 		var module = installedModules[moduleId] = {
 /******/ 			i: moduleId,
 /******/ 			l: false,
 /******/ 			exports: {}
 /******/ 		};
-/******/
+
 /******/ 		// Execute the module function
 /******/ 		modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
-/******/
+
 /******/ 		// Flag the module as loaded
 /******/ 		module.l = true;
-/******/
+
 /******/ 		// Return the exports of the module
 /******/ 		return module.exports;
 /******/ 	}
-/******/
-/******/
+
+
 /******/ 	// expose the modules object (__webpack_modules__)
 /******/ 	__webpack_require__.m = modules;
-/******/
+
 /******/ 	// expose the module cache
 /******/ 	__webpack_require__.c = installedModules;
-/******/
+
 /******/ 	// identity function for calling harmony imports with the correct context
 /******/ 	__webpack_require__.i = function(value) { return value; };
-/******/
+
 /******/ 	// define getter function for harmony exports
 /******/ 	__webpack_require__.d = function(exports, name, getter) {
 /******/ 		if(!__webpack_require__.o(exports, name)) {
 /******/ 			Object.defineProperty(exports, name, {
 /******/ 				configurable: false,
 /******/ 				enumerable: true,
 /******/ 				get: getter
 /******/ 			});
 /******/ 		}
 /******/ 	};
-/******/
+
 /******/ 	// getDefaultExport function for compatibility with non-harmony modules
 /******/ 	__webpack_require__.n = function(module) {
 /******/ 		var getter = module && module.__esModule ?
 /******/ 			function getDefault() { return module['default']; } :
 /******/ 			function getModuleExports() { return module; };
 /******/ 		__webpack_require__.d(getter, 'a', getter);
 /******/ 		return getter;
 /******/ 	};
-/******/
+
 /******/ 	// Object.prototype.hasOwnProperty.call
 /******/ 	__webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
-/******/
+
 /******/ 	// __webpack_public_path__
 /******/ 	__webpack_require__.p = "";
-/******/
+
 /******/ 	// Load entry module and return exports
-/******/ 	return __webpack_require__(__webpack_require__.s = 19);
+/******/ 	return __webpack_require__(__webpack_require__.s = 25);
 /******/ })
 /************************************************************************/
 /******/ ([
 /* 0 */
+/***/ (function(module, exports) {
+
+module.exports = React;
+
+/***/ }),
+/* 1 */
 /***/ (function(module, exports, __webpack_require__) {
 
 "use strict";
 /* 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/. */
 
 
@@ -92,17 +98,17 @@ const globalImportContext = typeof Windo
 
 
 // Create an object that avoids accidental differing key/value pairs:
 // {
 //   INIT: "INIT",
 //   UNINIT: "UNINIT"
 // }
 const actionTypes = {};
-for (const type of ["BLOCK_URL", "BOOKMARK_URL", "DELETE_BOOKMARK_BY_ID", "DELETE_HISTORY_URL", "DELETE_HISTORY_URL_CONFIRM", "DIALOG_CANCEL", "DIALOG_OPEN", "INIT", "LOCALE_UPDATED", "NEW_TAB_INITIAL_STATE", "NEW_TAB_LOAD", "NEW_TAB_UNLOAD", "NEW_TAB_VISIBLE", "OPEN_NEW_WINDOW", "OPEN_PRIVATE_WINDOW", "PINNED_SITES_UPDATED", "PLACES_BOOKMARK_ADDED", "PLACES_BOOKMARK_CHANGED", "PLACES_BOOKMARK_REMOVED", "PLACES_HISTORY_CLEARED", "PLACES_LINK_BLOCKED", "PLACES_LINK_DELETED", "PREFS_INITIAL_VALUES", "PREF_CHANGED", "SAVE_TO_POCKET", "SCREENSHOT_UPDATED", "SET_PREF", "TELEMETRY_PERFORMANCE_EVENT", "TELEMETRY_UNDESIRED_EVENT", "TELEMETRY_USER_EVENT", "TOP_SITES_PIN", "TOP_SITES_UNPIN", "TOP_SITES_UPDATED", "UNINIT"]) {
+for (const type of ["BLOCK_URL", "BOOKMARK_URL", "DELETE_BOOKMARK_BY_ID", "DELETE_HISTORY_URL", "DELETE_HISTORY_URL_CONFIRM", "DIALOG_CANCEL", "DIALOG_OPEN", "FEED_INIT", "INIT", "LOCALE_UPDATED", "NEW_TAB_INITIAL_STATE", "NEW_TAB_LOAD", "NEW_TAB_UNLOAD", "NEW_TAB_VISIBLE", "OPEN_NEW_WINDOW", "OPEN_PRIVATE_WINDOW", "PINNED_SITES_UPDATED", "PLACES_BOOKMARK_ADDED", "PLACES_BOOKMARK_CHANGED", "PLACES_BOOKMARK_REMOVED", "PLACES_HISTORY_CLEARED", "PLACES_LINK_BLOCKED", "PLACES_LINK_DELETED", "PREFS_INITIAL_VALUES", "PREF_CHANGED", "SAVE_TO_POCKET", "SCREENSHOT_UPDATED", "SECTION_DEREGISTER", "SECTION_REGISTER", "SECTION_ROWS_UPDATE", "SET_PREF", "SNIPPETS_DATA", "SNIPPETS_RESET", "SYSTEM_TICK", "TELEMETRY_PERFORMANCE_EVENT", "TELEMETRY_UNDESIRED_EVENT", "TELEMETRY_USER_EVENT", "TOP_SITES_PIN", "TOP_SITES_UNPIN", "TOP_SITES_UPDATED", "UNINIT"]) {
   actionTypes[type] = type;
 }
 
 // Helper function for creating routed actions between content and main
 // Not intended to be used by consumers
 function _RouteMessage(action, options) {
   const meta = action.meta ? Object.assign({}, action.meta) : {};
   if (!options || !options.from || !options.to) {
@@ -271,32 +277,26 @@ module.exports = {
   globalImportContext,
   UI_CODE,
   BACKGROUND_PROCESS,
   MAIN_MESSAGE_TYPE,
   CONTENT_MESSAGE_TYPE
 };
 
 /***/ }),
-/* 1 */
-/***/ (function(module, exports) {
-
-module.exports = React;
-
-/***/ }),
 /* 2 */
 /***/ (function(module, exports) {
 
-module.exports = ReactRedux;
+module.exports = ReactIntl;
 
 /***/ }),
 /* 3 */
 /***/ (function(module, exports) {
 
-module.exports = ReactIntl;
+module.exports = ReactRedux;
 
 /***/ }),
 /* 4 */
 /***/ (function(module, exports, __webpack_require__) {
 
 "use strict";
 
 
@@ -320,41 +320,116 @@ module.exports = function shortURL(link)
   }
   const eTLD = link.eTLD;
 
   const hostname = (link.hostname || new URL(link.url).hostname).replace(/^www\./i, "");
 
   // Remove the eTLD (e.g., com, net) and the preceding period from the hostname
   const eTLDLength = (eTLD || "").length || hostname.match(/\.com$/) && 3;
   const eTLDExtra = eTLDLength > 0 ? -(eTLDLength + 1) : Infinity;
-  return hostname.slice(0, eTLDExtra).toLowerCase() || hostname;
+  // If URL and hostname are not present fallback to page title.
+  return hostname.slice(0, eTLDExtra).toLowerCase() || hostname || link.title;
 };
 
 /***/ }),
 /* 5 */
 /***/ (function(module, exports, __webpack_require__) {
 
 "use strict";
 
 
-const React = __webpack_require__(1);
+const React = __webpack_require__(0);
 
 var _require = __webpack_require__(2);
 
+const injectIntl = _require.injectIntl;
+
+const ContextMenu = __webpack_require__(15);
+
+var _require2 = __webpack_require__(1);
+
+const ac = _require2.actionCreators;
+
+const linkMenuOptions = __webpack_require__(21);
+const DEFAULT_SITE_MENU_OPTIONS = ["CheckPinTopSite", "Separator", "OpenInNewWindow", "OpenInPrivateWindow"];
+
+class LinkMenu extends React.Component {
+  getOptions() {
+    const props = this.props;
+    const site = props.site,
+          index = props.index,
+          source = props.source;
+
+    // Handle special case of default site
+
+    const propOptions = !site.isDefault ? props.options : DEFAULT_SITE_MENU_OPTIONS;
+
+    const options = propOptions.map(o => linkMenuOptions[o](site, index)).map(option => {
+      const action = option.action,
+            id = option.id,
+            type = option.type,
+            userEvent = option.userEvent;
+
+      if (!type && id) {
+        option.label = props.intl.formatMessage(option);
+        option.onClick = () => {
+          props.dispatch(action);
+          if (userEvent) {
+            props.dispatch(ac.UserEvent({
+              event: userEvent,
+              source,
+              action_position: index
+            }));
+          }
+        };
+      }
+      return option;
+    });
+
+    // This is for accessibility to support making each item tabbable.
+    // We want to know which item is the first and which item
+    // is the last, so we can close the context menu accordingly.
+    options[0].first = true;
+    options[options.length - 1].last = true;
+    return options;
+  }
+  render() {
+    return React.createElement(ContextMenu, {
+      visible: this.props.visible,
+      onUpdate: this.props.onUpdate,
+      options: this.getOptions() });
+  }
+}
+
+module.exports = injectIntl(LinkMenu);
+module.exports._unconnected = LinkMenu;
+
+/***/ }),
+/* 6 */
+/***/ (function(module, exports, __webpack_require__) {
+
+"use strict";
+
+
+const React = __webpack_require__(0);
+
+var _require = __webpack_require__(3);
+
 const connect = _require.connect;
 
-var _require2 = __webpack_require__(3);
+var _require2 = __webpack_require__(2);
 
 const addLocaleData = _require2.addLocaleData,
       IntlProvider = _require2.IntlProvider;
 
-const TopSites = __webpack_require__(15);
-const Search = __webpack_require__(14);
-const ConfirmDialog = __webpack_require__(10);
-const PreferencesPane = __webpack_require__(13);
+const TopSites = __webpack_require__(19);
+const Search = __webpack_require__(17);
+const ConfirmDialog = __webpack_require__(14);
+const PreferencesPane = __webpack_require__(16);
+const Sections = __webpack_require__(18);
 
 // Locales that should be displayed RTL
 const RTL_LIST = ["ar", "he", "fa", "ur"];
 
 // Add the locale data for pluralization and relative-time formatting for now,
 // this just uses english locale data. We can make this more sophisticated if
 // more features are needed.
 function addLocaleDataForReactIntl(_ref) {
@@ -404,38 +479,39 @@ class Base extends React.Component {
       React.createElement(
         "div",
         { className: "outer-wrapper" },
         React.createElement(
           "main",
           null,
           prefs.showSearch && React.createElement(Search, null),
           prefs.showTopSites && React.createElement(TopSites, null),
+          React.createElement(Sections, null),
           React.createElement(ConfirmDialog, null)
         ),
         React.createElement(PreferencesPane, null)
       )
     );
   }
 }
 
 module.exports = connect(state => ({ App: state.App, Prefs: state.Prefs }))(Base);
 
 /***/ }),
-/* 6 */
+/* 7 */
 /***/ (function(module, exports, __webpack_require__) {
 
 "use strict";
 
 
-var _require = __webpack_require__(0);
+var _require = __webpack_require__(1);
 
 const at = _require.actionTypes;
 
-var _require2 = __webpack_require__(17);
+var _require2 = __webpack_require__(22);
 
 const perfSvc = _require2.perfService;
 
 
 const VISIBLE = "visible";
 const VISIBILITY_CHANGE_EVENT = "visibilitychange";
 
 module.exports = class DetectUserSessionStart {
@@ -490,31 +566,31 @@ module.exports = class DetectUserSession
     if (this.document.visibilityState === VISIBLE) {
       this._sendEvent();
       this.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
     }
   }
 };
 
 /***/ }),
-/* 7 */
+/* 8 */
 /***/ (function(module, exports, __webpack_require__) {
 
 "use strict";
 
 
 /* eslint-env mozilla/frame-script */
 
-var _require = __webpack_require__(18);
+var _require = __webpack_require__(24);
 
 const createStore = _require.createStore,
       combineReducers = _require.combineReducers,
       applyMiddleware = _require.applyMiddleware;
 
-var _require2 = __webpack_require__(0);
+var _require2 = __webpack_require__(1);
 
 const au = _require2.actionUtils;
 
 
 const MERGE_STORE_ACTION = "NEW_TAB_INITIAL_STATE";
 const OUTGOING_MESSAGE_NAME = "ActivityStream:ContentToMain";
 const INCOMING_MESSAGE_NAME = "ActivityStream:MainToContent";
 
@@ -559,66 +635,340 @@ const messageMiddleware = store => next 
  *
  * @param  {object} reducers An object containing Redux reducers
  * @return {object}          A redux store
  */
 module.exports = function initStore(reducers) {
   const store = createStore(mergeStateReducer(combineReducers(reducers)), applyMiddleware(messageMiddleware));
 
   addMessageListener(INCOMING_MESSAGE_NAME, msg => {
-    store.dispatch(msg.data);
+    try {
+      store.dispatch(msg.data);
+    } catch (ex) {
+      console.error("Content msg:", msg, "Dispatch error: ", ex); // eslint-disable-line no-console
+      dump(`Content msg: ${ JSON.stringify(msg) }\nDispatch error: ${ ex }\n${ ex.stack }`);
+    }
   });
 
   return store;
 };
 
 module.exports.MERGE_STORE_ACTION = MERGE_STORE_ACTION;
 module.exports.OUTGOING_MESSAGE_NAME = OUTGOING_MESSAGE_NAME;
 module.exports.INCOMING_MESSAGE_NAME = INCOMING_MESSAGE_NAME;
 
 /***/ }),
-/* 8 */
+/* 9 */
+/***/ (function(module, exports, __webpack_require__) {
+
+"use strict";
+/* WEBPACK VAR INJECTION */(function(global) {
+
+const DATABASE_NAME = "snippets_db";
+const DATABASE_VERSION = 1;
+const SNIPPETS_OBJECTSTORE_NAME = "snippets";
+const SNIPPETS_UPDATE_INTERVAL_MS = 14400000; // 4 hours.
+
+/**
+ * SnippetsMap - A utility for cacheing values related to the snippet. It has
+ *               the same interface as a Map, but is optionally backed by
+ *               indexedDB for persistent storage.
+ *               Call .connect() to open a database connection and restore any
+ *               previously cached data, if necessary.
+ *
+ */
+class SnippetsMap extends Map {
+  constructor() {
+    super(...arguments);
+    this._db = null;
+  }
+
+  set(key, value) {
+    super.set(key, value);
+    return this._dbTransaction(db => db.put(value, key));
+  }
+
+  delete(key, value) {
+    super.delete(key);
+    return this._dbTransaction(db => db.delete(key));
+  }
+
+  clear() {
+    super.clear();
+    return this._dbTransaction(db => db.clear());
+  }
+
+  /**
+   * connect - Attaches an indexedDB back-end to the Map so that any set values
+   *           are also cached in a store. It also restores any existing values
+   *           that are already stored in the indexedDB store.
+   *
+   * @return {type}  description
+   */
+  async connect() {
+    // Open the connection
+    const db = await this._openDB();
+
+    // Restore any existing values
+    await this._restoreFromDb(db);
+
+    // Attach a reference to the db
+    this._db = db;
+  }
+
+  /**
+   * _dbTransaction - Returns a db transaction wrapped with the given modifier
+   *                  function as a Promise. If the db has not been connected,
+   *                  it resolves immediately.
+   *
+   * @param  {func} modifier A function to call with the transaction
+   * @return {obj}           A Promise that resolves when the transaction has
+   *                         completed or errored
+   */
+  _dbTransaction(modifier) {
+    if (!this._db) {
+      return Promise.resolve();
+    }
+    return new Promise((resolve, reject) => {
+      const transaction = modifier(this._db.transaction(SNIPPETS_OBJECTSTORE_NAME, "readwrite").objectStore(SNIPPETS_OBJECTSTORE_NAME));
+      transaction.onsuccess = event => resolve();
+
+      /* istanbul ignore next */
+      transaction.onerror = event => reject(transaction.error);
+    });
+  }
+
+  _openDB() {
+    return new Promise((resolve, reject) => {
+      const openRequest = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
+
+      /* istanbul ignore next */
+      openRequest.onerror = event => {
+        // Try to delete the old database so that we can start this process over
+        // next time.
+        indexedDB.deleteDatabase(DATABASE_NAME);
+        reject(event);
+      };
+
+      openRequest.onupgradeneeded = event => {
+        const db = event.target.result;
+        if (!db.objectStoreNames.contains(SNIPPETS_OBJECTSTORE_NAME)) {
+          db.createObjectStore(SNIPPETS_OBJECTSTORE_NAME);
+        }
+      };
+
+      openRequest.onsuccess = event => {
+        let db = event.target.result;
+
+        /* istanbul ignore next */
+        db.onerror = err => console.error(err); // eslint-disable-line no-console
+        /* istanbul ignore next */
+        db.onversionchange = versionChangeEvent => versionChangeEvent.target.close();
+
+        resolve(db);
+      };
+    });
+  }
+
+  _restoreFromDb(db) {
+    return new Promise((resolve, reject) => {
+      let cursorRequest;
+      try {
+        cursorRequest = db.transaction(SNIPPETS_OBJECTSTORE_NAME).objectStore(SNIPPETS_OBJECTSTORE_NAME).openCursor();
+      } catch (err) {
+        // istanbul ignore next
+        reject(err);
+        // istanbul ignore next
+        return;
+      }
+
+      /* istanbul ignore next */
+      cursorRequest.onerror = event => reject(event);
+
+      cursorRequest.onsuccess = event => {
+        let cursor = event.target.result;
+        // Populate the cache from the persistent storage.
+        if (cursor) {
+          this.set(cursor.key, cursor.value);
+          cursor.continue();
+        } else {
+          // We are done.
+          resolve();
+        }
+      };
+    });
+  }
+}
+
+/**
+ * SnippetsProvider - Initializes a SnippetsMap and loads snippets from a
+ *                    remote location, or else default snippets if the remote
+ *                    snippets cannot be retrieved.
+ */
+class SnippetsProvider {
+  constructor() {
+    // Initialize the Snippets Map and attaches it to a global so that
+    // the snippet payload can interact with it.
+    global.gSnippetsMap = new SnippetsMap();
+  }
+
+  get snippetsMap() {
+    return global.gSnippetsMap;
+  }
+
+  async _refreshSnippets() {
+    // Check if the cached version of of the snippets in snippetsMap. If it's too
+    // old, blow away the entire snippetsMap.
+    const cachedVersion = this.snippetsMap.get("snippets-cached-version");
+    if (cachedVersion !== this.version) {
+      this.snippetsMap.clear();
+    }
+
+    // Has enough time passed for us to require an update?
+    const lastUpdate = this.snippetsMap.get("snippets-last-update");
+    const needsUpdate = !(lastUpdate >= 0) || Date.now() - lastUpdate > SNIPPETS_UPDATE_INTERVAL_MS;
+
+    if (needsUpdate && this.snippetsURL) {
+      this.snippetsMap.set("snippets-last-update", Date.now());
+      try {
+        // TODO: timeout?
+        const response = await fetch(this.snippetsURL);
+        if (response.status === 200) {
+          const payload = await response.text();
+
+          this.snippetsMap.set("snippets", payload);
+          this.snippetsMap.set("snippets-cached-version", this.version);
+        }
+      } catch (e) {
+        console.error(e); // eslint-disable-line no-console
+      }
+    }
+  }
+
+  _showDefaultSnippets() {
+    // TODO
+  }
+
+  _showRemoteSnippets() {
+    const snippetsEl = document.getElementById(this.elementId);
+    const containerEl = document.getElementById(this.containerElementId);
+    const payload = this.snippetsMap.get("snippets");
+
+    if (!snippetsEl) {
+      throw new Error(`No element was found with id '${ this.elementId }'.`);
+    }
+
+    // This could happen if fetching failed
+    if (!payload) {
+      throw new Error("No remote snippets were found in gSnippetsMap.");
+    }
+
+    // Note that injecting snippets can throw if they're invalid XML.
+    snippetsEl.innerHTML = payload;
+
+    // Scripts injected by innerHTML are inactive, so we have to relocate them
+    // through DOM manipulation to activate their contents.
+    for (const scriptEl of snippetsEl.getElementsByTagName("script")) {
+      const relocatedScript = document.createElement("script");
+      relocatedScript.text = scriptEl.text;
+      scriptEl.parentNode.replaceChild(relocatedScript, scriptEl);
+    }
+
+    // Unhide the container if everything went OK
+    if (containerEl) {
+      containerEl.style.display = "block";
+    }
+  }
+
+  /**
+   * init - Fetch the snippet payload and show snippets
+   *
+   * @param  {obj} options
+   * @param  {str} options.snippetsURL  The URL from which we fetch snippets
+   * @param  {int} options.version  The current snippets version
+   * @param  {str} options.elementId  The id of the element of the snippets container
+   */
+  async init(options) {
+    Object.assign(this, {
+      snippetsURL: "",
+      version: 0,
+      elementId: "snippets",
+      containerElementId: "snippets-container",
+      connect: true
+    }, options);
+
+    // TODO: Requires enabling indexedDB on newtab
+    // Restore the snippets map from indexedDB
+    if (this.connect) {
+      try {
+        await this.snippetsMap.connect();
+      } catch (e) {
+        console.error(e); // eslint-disable-line no-console
+      }
+    }
+
+    // Refresh snippets, if enough time has passed.
+    await this._refreshSnippets();
+
+    // Try showing remote snippets, falling back to defaults if necessary.
+    try {
+      this._showRemoteSnippets();
+    } catch (e) {
+      this._showDefaultSnippets(e);
+    }
+  }
+}
+
+module.exports.SnippetsMap = SnippetsMap;
+module.exports.SnippetsProvider = SnippetsProvider;
+module.exports.SNIPPETS_UPDATE_INTERVAL_MS = SNIPPETS_UPDATE_INTERVAL_MS;
+/* WEBPACK VAR INJECTION */}.call(exports, __webpack_require__(23)))
+
+/***/ }),
+/* 10 */
 /***/ (function(module, exports, __webpack_require__) {
 
 "use strict";
 /* 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/. */
 
 
-var _require = __webpack_require__(0);
+var _require = __webpack_require__(1);
 
 const at = _require.actionTypes;
 
 
 const INITIAL_STATE = {
   App: {
     // Have we received real data from the app yet?
     initialized: false,
     // The locale of the browser
     locale: "",
     // Localized strings with defaults
     strings: {},
     // The version of the system-addon
     version: null
   },
+  Snippets: { initialized: false },
   TopSites: {
     // Have we received real data from history yet?
     initialized: false,
     // The history (and possibly default) links
     rows: []
   },
   Prefs: {
     initialized: false,
     values: {}
   },
   Dialog: {
     visible: false,
     data: {}
-  }
+  },
+  Sections: []
 };
 
 function App() {
   let prevState = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : INITIAL_STATE.App;
   let action = arguments[1];
 
   switch (action.type) {
     case at.INIT:
@@ -696,29 +1046,35 @@ function TopSites() {
         if (row && row.url === action.data.url) {
           hasMatch = true;
           return Object.assign({}, row, { screenshot: action.data.screenshot });
         }
         return row;
       });
       return hasMatch ? Object.assign({}, prevState, { rows: newRows }) : prevState;
     case at.PLACES_BOOKMARK_ADDED:
+      if (!action.data) {
+        return prevState;
+      }
       newRows = prevState.rows.map(site => {
         if (site && site.url === action.data.url) {
           var _action$data2 = action.data;
           const bookmarkGuid = _action$data2.bookmarkGuid,
                 bookmarkTitle = _action$data2.bookmarkTitle,
                 lastModified = _action$data2.lastModified;
 
           return Object.assign({}, site, { bookmarkGuid, bookmarkTitle, bookmarkDateCreated: lastModified });
         }
         return site;
       });
       return Object.assign({}, prevState, { rows: newRows });
     case at.PLACES_BOOKMARK_REMOVED:
+      if (!action.data) {
+        return prevState;
+      }
       newRows = prevState.rows.map(site => {
         if (site && site.url === action.data.url) {
           const newSite = Object.assign({}, site);
           delete newSite.bookmarkGuid;
           delete newSite.bookmarkTitle;
           delete newSite.bookmarkDateCreated;
           return newSite;
         }
@@ -766,47 +1122,247 @@ function Prefs() {
       newValues = Object.assign({}, prevState.values);
       newValues[action.data.name] = action.data.value;
       return Object.assign({}, prevState, { values: newValues });
     default:
       return prevState;
   }
 }
 
-var reducers = { TopSites, App, Prefs, Dialog };
+function Sections() {
+  let prevState = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : INITIAL_STATE.Sections;
+  let action = arguments[1];
+
+  let hasMatch;
+  let newState;
+  switch (action.type) {
+    case at.SECTION_DEREGISTER:
+      return prevState.filter(section => section.id !== action.data);
+    case at.SECTION_REGISTER:
+      // If section exists in prevState, update it
+      newState = prevState.map(section => {
+        if (section && section.id === action.data.id) {
+          hasMatch = true;
+          return Object.assign({}, section, action.data);
+        }
+        return section;
+      });
+      // If section doesn't exist in prevState, create a new section object and
+      // append it to the sections state
+      if (!hasMatch) {
+        const initialized = action.data.rows && action.data.rows.length > 0;
+        newState.push(Object.assign({ title: "", initialized, rows: [] }, action.data));
+      }
+      return newState;
+    case at.SECTION_ROWS_UPDATE:
+      return prevState.map(section => {
+        if (section && section.id === action.data.id) {
+          return Object.assign({}, section, action.data);
+        }
+        return section;
+      });
+    case at.PLACES_LINK_DELETED:
+    case at.PLACES_LINK_BLOCKED:
+      return prevState.map(section => Object.assign({}, section, { rows: section.rows.filter(site => site.url !== action.data.url) }));
+    default:
+      return prevState;
+  }
+}
+
+function Snippets() {
+  let prevState = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : INITIAL_STATE.Snippets;
+  let action = arguments[1];
+
+  switch (action.type) {
+    case at.SNIPPETS_DATA:
+      return Object.assign({}, prevState, { initialized: true }, action.data);
+    case at.SNIPPETS_RESET:
+      return INITIAL_STATE.Snippets;
+    default:
+      return prevState;
+  }
+}
+
+var reducers = { TopSites, App, Snippets, Prefs, Dialog, Sections };
 module.exports = {
   reducers,
   INITIAL_STATE,
   insertPinned
 };
 
 /***/ }),
-/* 9 */
+/* 11 */
 /***/ (function(module, exports) {
 
 module.exports = ReactDOM;
 
 /***/ }),
-/* 10 */
+/* 12 */
 /***/ (function(module, exports, __webpack_require__) {
 
 "use strict";
 
 
-const React = __webpack_require__(1);
+const React = __webpack_require__(0);
+const LinkMenu = __webpack_require__(5);
+const shortURL = __webpack_require__(4);
 
 var _require = __webpack_require__(2);
 
+const FormattedMessage = _require.FormattedMessage;
+
+const cardContextTypes = __webpack_require__(13);
+
+/**
+ * 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.Component {
+  constructor(props) {
+    super(props);
+    this.state = { showContextMenu: false, activeCard: null };
+  }
+  toggleContextMenu(event, index) {
+    this.setState({ showContextMenu: true, activeCard: index });
+  }
+  render() {
+    var _props = this.props;
+    const index = _props.index,
+          link = _props.link,
+          dispatch = _props.dispatch,
+          contextMenuOptions = _props.contextMenuOptions;
+
+    const isContextMenuOpen = this.state.showContextMenu && this.state.activeCard === index;
+    const hostname = shortURL(link);
+    var _cardContextTypes$lin = cardContextTypes[link.type];
+    const icon = _cardContextTypes$lin.icon,
+          intlID = _cardContextTypes$lin.intlID;
+
+
+    return React.createElement(
+      "li",
+      { className: `card-outer${ isContextMenuOpen ? " active" : "" }` },
+      React.createElement(
+        "a",
+        { href: link.url },
+        React.createElement(
+          "div",
+          { className: "card" },
+          link.image && React.createElement("div", { className: "card-preview-image", style: { backgroundImage: `url(${ link.image })` } }),
+          React.createElement(
+            "div",
+            { className: "card-details" },
+            React.createElement(
+              "div",
+              { className: "card-host-name" },
+              " ",
+              hostname,
+              " "
+            ),
+            React.createElement(
+              "div",
+              { className: `card-text${ link.image ? "" : " full-height" }` },
+              React.createElement(
+                "h4",
+                { className: "card-title" },
+                " ",
+                link.title,
+                " "
+              ),
+              React.createElement(
+                "p",
+                { className: "card-description" },
+                " ",
+                link.description,
+                " "
+              )
+            ),
+            React.createElement(
+              "div",
+              { className: "card-context" },
+              React.createElement("span", { className: `card-context-icon icon icon-${ icon }` }),
+              React.createElement(
+                "div",
+                { className: "card-context-label" },
+                React.createElement(FormattedMessage, { id: intlID, defaultMessage: "Visited" })
+              )
+            )
+          )
+        )
+      ),
+      React.createElement(
+        "button",
+        { className: "context-menu-button",
+          onClick: e => {
+            e.preventDefault();
+            this.toggleContextMenu(e, index);
+          } },
+        React.createElement(
+          "span",
+          { className: "sr-only" },
+          `Open context menu for ${ link.title }`
+        )
+      ),
+      React.createElement(LinkMenu, {
+        dispatch: dispatch,
+        visible: isContextMenuOpen,
+        onUpdate: val => this.setState({ showContextMenu: val }),
+        index: index,
+        site: link,
+        options: link.context_menu_options || contextMenuOptions })
+    );
+  }
+}
+module.exports = Card;
+
+/***/ }),
+/* 13 */
+/***/ (function(module, exports, __webpack_require__) {
+
+"use strict";
+
+
+module.exports = {
+  history: {
+    intlID: "type_label_visited",
+    icon: "historyItem"
+  },
+  bookmark: {
+    intlID: "type_label_bookmarked",
+    icon: "bookmark"
+  },
+  trending: {
+    intlID: "type_label_recommended",
+    icon: "trending"
+  }
+};
+
+/***/ }),
+/* 14 */
+/***/ (function(module, exports, __webpack_require__) {
+
+"use strict";
+
+
+const React = __webpack_require__(0);
+
+var _require = __webpack_require__(3);
+
 const connect = _require.connect;
 
-var _require2 = __webpack_require__(3);
+var _require2 = __webpack_require__(2);
 
 const FormattedMessage = _require2.FormattedMessage;
 
-var _require3 = __webpack_require__(0);
+var _require3 = __webpack_require__(1);
 
 const actionTypes = _require3.actionTypes,
       ac = _require3.actionCreators;
 
 /**
  * ConfirmDialog component.
  * One primary action button, one cancel button.
  *
@@ -899,23 +1455,23 @@ const ConfirmDialog = React.createClass(
   }
 });
 
 module.exports = connect(state => state.Dialog)(ConfirmDialog);
 module.exports._unconnected = ConfirmDialog;
 module.exports.Dialog = ConfirmDialog;
 
 /***/ }),
-/* 11 */
+/* 15 */
 /***/ (function(module, exports, __webpack_require__) {
 
 "use strict";
 
 
-const React = __webpack_require__(1);
+const React = __webpack_require__(0);
 
 class ContextMenu extends React.Component {
   constructor(props) {
     super(props);
     this.hideContext = this.hideContext.bind(this);
   }
   hideContext() {
     this.props.onUpdate(false);
@@ -969,120 +1525,47 @@ class ContextMenu extends React.Componen
             React.createElement(
               "a",
               { tabIndex: "0",
                 onKeyDown: e => this.onKeyDown(e, option),
                 onClick: () => {
                   this.hideContext();
                   option.onClick();
                 } },
-              option.icon && React.createElement("span", { className: `icon icon-spacer icon-${option.icon}` }),
+              option.icon && React.createElement("span", { className: `icon icon-spacer icon-${ option.icon }` }),
               option.label
             )
           );
         })
       )
     );
   }
 }
 
 module.exports = ContextMenu;
 
 /***/ }),
-/* 12 */
+/* 16 */
 /***/ (function(module, exports, __webpack_require__) {
 
 "use strict";
 
 
-const React = __webpack_require__(1);
+const React = __webpack_require__(0);
 
 var _require = __webpack_require__(3);
 
-const injectIntl = _require.injectIntl;
-
-const ContextMenu = __webpack_require__(11);
-
-var _require2 = __webpack_require__(0);
-
-const ac = _require2.actionCreators;
-
-const linkMenuOptions = __webpack_require__(16);
-const DEFAULT_SITE_MENU_OPTIONS = ["CheckPinTopSite", "Separator", "OpenInNewWindow", "OpenInPrivateWindow"];
-
-class LinkMenu extends React.Component {
-  getOptions() {
-    const props = this.props;
-    const site = props.site,
-          index = props.index,
-          source = props.source;
-
-    // Handle special case of default site
-
-    const propOptions = !site.isDefault ? props.options : DEFAULT_SITE_MENU_OPTIONS;
-
-    const options = propOptions.map(o => linkMenuOptions[o](site, index)).map(option => {
-      const action = option.action,
-            id = option.id,
-            type = option.type,
-            userEvent = option.userEvent;
-
-      if (!type && id) {
-        option.label = props.intl.formatMessage(option);
-        option.onClick = () => {
-          props.dispatch(action);
-          if (userEvent) {
-            props.dispatch(ac.UserEvent({
-              event: userEvent,
-              source,
-              action_position: index
-            }));
-          }
-        };
-      }
-      return option;
-    });
-
-    // This is for accessibility to support making each item tabbable.
-    // We want to know which item is the first and which item
-    // is the last, so we can close the context menu accordingly.
-    options[0].first = true;
-    options[options.length - 1].last = true;
-    return options;
-  }
-  render() {
-    return React.createElement(ContextMenu, {
-      visible: this.props.visible,
-      onUpdate: this.props.onUpdate,
-      options: this.getOptions() });
-  }
-}
-
-module.exports = injectIntl(LinkMenu);
-module.exports._unconnected = LinkMenu;
-
-/***/ }),
-/* 13 */
-/***/ (function(module, exports, __webpack_require__) {
-
-"use strict";
-
-
-const React = __webpack_require__(1);
-
-var _require = __webpack_require__(2);
-
 const connect = _require.connect;
 
-var _require2 = __webpack_require__(3);
+var _require2 = __webpack_require__(2);
 
 const injectIntl = _require2.injectIntl,
       FormattedMessage = _require2.FormattedMessage;
 
-var _require3 = __webpack_require__(0);
+var _require3 = __webpack_require__(1);
 
 const ac = _require3.actionCreators;
 
 
 const PreferencesInput = props => React.createElement(
   "section",
   null,
   React.createElement("input", { type: "checkbox", id: props.prefName, name: props.prefName, checked: props.value, onChange: props.onChange, className: props.className }),
@@ -1133,43 +1616,45 @@ class PreferencesPane extends React.Comp
     const isVisible = this.state.visible;
     return React.createElement(
       "div",
       { className: "prefs-pane-wrapper", ref: "wrapper" },
       React.createElement(
         "div",
         { className: "prefs-pane-button" },
         React.createElement("button", {
-          className: `prefs-button icon ${isVisible ? "icon-dismiss" : "icon-settings"}`,
+          className: `prefs-button icon ${ isVisible ? "icon-dismiss" : "icon-settings" }`,
           title: props.intl.formatMessage({ id: isVisible ? "settings_pane_done_button" : "settings_pane_button_label" }),
           onClick: this.togglePane })
       ),
       React.createElement(
         "div",
         { className: "prefs-pane" },
         React.createElement(
           "div",
-          { className: `sidebar ${isVisible ? "" : "hidden"}` },
+          { className: `sidebar ${ isVisible ? "" : "hidden" }` },
           React.createElement(
             "div",
             { className: "prefs-modal-inner-wrapper" },
             React.createElement(
               "h1",
               null,
               React.createElement(FormattedMessage, { id: "settings_pane_header" })
             ),
             React.createElement(
               "p",
               null,
               React.createElement(FormattedMessage, { id: "settings_pane_body" })
             ),
             React.createElement(PreferencesInput, { className: "showSearch", prefName: "showSearch", value: prefs.showSearch, onChange: this.handleChange,
               titleStringId: "settings_pane_search_header", descStringId: "settings_pane_search_body" }),
             React.createElement(PreferencesInput, { className: "showTopSites", prefName: "showTopSites", value: prefs.showTopSites, onChange: this.handleChange,
-              titleStringId: "settings_pane_topsites_header", descStringId: "settings_pane_topsites_body" })
+              titleStringId: "settings_pane_topsites_header", descStringId: "settings_pane_topsites_body" }),
+            React.createElement(PreferencesInput, { className: "showTopStories", prefName: "feeds.section.topstories", value: prefs["feeds.section.topstories"], onChange: this.handleChange,
+              titleStringId: "settings_pane_pocketstories_header", descStringId: "settings_pane_pocketstories_body" })
           ),
           React.createElement(
             "section",
             { className: "actions" },
             React.createElement(
               "button",
               { className: "done", onClick: this.togglePane },
               React.createElement(FormattedMessage, { id: "settings_pane_done_button" })
@@ -1181,35 +1666,35 @@ class PreferencesPane extends React.Comp
   }
 }
 
 module.exports = connect(state => ({ Prefs: state.Prefs }))(injectIntl(PreferencesPane));
 module.exports.PreferencesPane = PreferencesPane;
 module.exports.PreferencesInput = PreferencesInput;
 
 /***/ }),
-/* 14 */
+/* 17 */
 /***/ (function(module, exports, __webpack_require__) {
 
 "use strict";
 /* globals ContentSearchUIController */
 
 
-const React = __webpack_require__(1);
+const React = __webpack_require__(0);
 
-var _require = __webpack_require__(2);
+var _require = __webpack_require__(3);
 
 const connect = _require.connect;
 
-var _require2 = __webpack_require__(3);
+var _require2 = __webpack_require__(2);
 
 const FormattedMessage = _require2.FormattedMessage,
       injectIntl = _require2.injectIntl;
 
-var _require3 = __webpack_require__(0);
+var _require3 = __webpack_require__(1);
 
 const ac = _require3.actionCreators;
 
 
 class Search extends React.Component {
   constructor(props) {
     super(props);
     this.onClick = this.onClick.bind(this);
@@ -1280,36 +1765,156 @@ class Search extends React.Component {
     );
   }
 }
 
 module.exports = connect()(injectIntl(Search));
 module.exports._unconnected = Search;
 
 /***/ }),
-/* 15 */
+/* 18 */
 /***/ (function(module, exports, __webpack_require__) {
 
 "use strict";
 
 
-const React = __webpack_require__(1);
+var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
 
-var _require = __webpack_require__(2);
+const React = __webpack_require__(0);
+
+var _require = __webpack_require__(3);
 
 const connect = _require.connect;
 
-var _require2 = __webpack_require__(3);
+var _require2 = __webpack_require__(2);
+
+const FormattedMessage = _require2.FormattedMessage;
+
+const Card = __webpack_require__(12);
+const Topics = __webpack_require__(20);
+
+class Section extends React.Component {
+  render() {
+    var _props = this.props;
+    const id = _props.id,
+          title = _props.title,
+          icon = _props.icon,
+          rows = _props.rows,
+          infoOption = _props.infoOption,
+          emptyState = _props.emptyState,
+          dispatch = _props.dispatch,
+          maxCards = _props.maxCards,
+          contextMenuOptions = _props.contextMenuOptions;
+
+    const initialized = rows && rows.length > 0;
+    const shouldShowTopics = id === "TopStories" && this.props.topics && this.props.read_more_endpoint;
+    // <Section> <-- React component
+    // <section> <-- HTML5 element
+    return React.createElement(
+      "section",
+      null,
+      React.createElement(
+        "div",
+        { className: "section-top-bar" },
+        React.createElement(
+          "h3",
+          { className: "section-title" },
+          React.createElement("span", { className: `icon icon-small-spacer icon-${ icon }` }),
+          React.createElement(FormattedMessage, title)
+        ),
+        infoOption && React.createElement(
+          "span",
+          { className: "section-info-option" },
+          React.createElement(
+            "span",
+            { className: "sr-only" },
+            React.createElement(FormattedMessage, { id: "section_info_option" })
+          ),
+          React.createElement("img", { className: "info-option-icon" }),
+          React.createElement(
+            "div",
+            { className: "info-option" },
+            infoOption.header && React.createElement(
+              "div",
+              { className: "info-option-header" },
+              React.createElement(FormattedMessage, infoOption.header)
+            ),
+            infoOption.body && React.createElement(
+              "p",
+              { className: "info-option-body" },
+              React.createElement(FormattedMessage, infoOption.body)
+            ),
+            infoOption.link && React.createElement(
+              "a",
+              { href: infoOption.link.href, target: "_blank", rel: "noopener noreferrer", className: "info-option-link" },
+              React.createElement(FormattedMessage, infoOption.link)
+            )
+          )
+        )
+      ),
+      React.createElement(
+        "ul",
+        { className: "section-list", style: { padding: 0 } },
+        rows.slice(0, maxCards).map((link, index) => link && React.createElement(Card, { index: index, dispatch: dispatch, link: link, contextMenuOptions: contextMenuOptions }))
+      ),
+      !initialized && React.createElement(
+        "div",
+        { className: "section-empty-state" },
+        React.createElement(
+          "div",
+          { className: "empty-state" },
+          React.createElement("img", { className: `empty-state-icon icon icon-${ emptyState.icon }` }),
+          React.createElement(
+            "p",
+            { className: "empty-state-message" },
+            React.createElement(FormattedMessage, emptyState.message)
+          )
+        )
+      ),
+      shouldShowTopics && React.createElement(Topics, { topics: this.props.topics, read_more_endpoint: this.props.read_more_endpoint })
+    );
+  }
+}
+
+class Sections extends React.Component {
+  render() {
+    const sections = this.props.Sections;
+    return React.createElement(
+      "div",
+      { className: "sections-list" },
+      sections.map(section => React.createElement(Section, _extends({ key: section.id }, section, { dispatch: this.props.dispatch })))
+    );
+  }
+}
+
+module.exports = connect(state => ({ Sections: state.Sections }))(Sections);
+module.exports._unconnected = Sections;
+module.exports.Section = Section;
+
+/***/ }),
+/* 19 */
+/***/ (function(module, exports, __webpack_require__) {
+
+"use strict";
+
+
+const React = __webpack_require__(0);
+
+var _require = __webpack_require__(3);
+
+const connect = _require.connect;
+
+var _require2 = __webpack_require__(2);
 
 const FormattedMessage = _require2.FormattedMessage;
 
 const shortURL = __webpack_require__(4);
-const LinkMenu = __webpack_require__(12);
+const LinkMenu = __webpack_require__(5);
 
-var _require3 = __webpack_require__(0);
+var _require3 = __webpack_require__(1);
 
 const ac = _require3.actionCreators;
 
 const TOP_SITES_SOURCE = "TOP_SITES";
 const TOP_SITES_CONTEXT_MENU_OPTIONS = ["CheckPinTopSite", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl", "DeleteUrl"];
 
 class TopSite extends React.Component {
   constructor(props) {
@@ -1329,38 +1934,38 @@ class TopSite extends React.Component {
   render() {
     var _props = this.props;
     const link = _props.link,
           index = _props.index,
           dispatch = _props.dispatch;
 
     const isContextMenuOpen = this.state.showContextMenu && this.state.activeTile === index;
     const title = link.pinTitle || shortURL(link);
-    const screenshotClassName = `screenshot${link.screenshot ? " active" : ""}`;
-    const topSiteOuterClassName = `top-site-outer${isContextMenuOpen ? " active" : ""}`;
-    const style = { backgroundImage: link.screenshot ? `url(${link.screenshot})` : "none" };
+    const screenshotClassName = `screenshot${ link.screenshot ? " active" : "" }`;
+    const topSiteOuterClassName = `top-site-outer${ isContextMenuOpen ? " active" : "" }`;
+    const style = { backgroundImage: link.screenshot ? `url(${ link.screenshot })` : "none" };
     return React.createElement(
       "li",
-      { className: topSiteOuterClassName, key: link.url },
+      { className: topSiteOuterClassName, key: link.guid || link.url },
       React.createElement(
         "a",
         { onClick: () => this.trackClick(), href: link.url },
         React.createElement(
           "div",
           { className: "tile", "aria-hidden": true },
           React.createElement(
             "span",
             { className: "letter-fallback" },
             title[0]
           ),
           React.createElement("div", { className: screenshotClassName, style: style })
         ),
         React.createElement(
           "div",
-          { className: `title ${link.isPinned ? "pinned" : ""}` },
+          { className: `title ${ link.isPinned ? "pinned" : "" }` },
           link.isPinned && React.createElement("div", { className: "icon icon-pin-small" }),
           React.createElement(
             "span",
             null,
             title
           )
         )
       ),
@@ -1369,17 +1974,17 @@ class TopSite extends React.Component {
         { className: "context-menu-button",
           onClick: e => {
             e.preventDefault();
             this.toggleContextMenu(e, index);
           } },
         React.createElement(
           "span",
           { className: "sr-only" },
-          `Open context menu for ${title}`
+          `Open context menu for ${ title }`
         )
       ),
       React.createElement(LinkMenu, {
         dispatch: dispatch,
         visible: isContextMenuOpen,
         onUpdate: val => this.setState({ showContextMenu: val }),
         site: link,
         index: index,
@@ -1396,35 +2001,100 @@ const TopSites = props => React.createEl
     "h3",
     { className: "section-title" },
     React.createElement(FormattedMessage, { id: "header_top_sites" })
   ),
   React.createElement(
     "ul",
     { className: "top-sites-list" },
     props.TopSites.rows.map((link, index) => link && React.createElement(TopSite, {
-      key: link.url,
+      key: link.guid || link.url,
       dispatch: props.dispatch,
       link: link,
       index: index }))
   )
 );
 
 module.exports = connect(state => ({ TopSites: state.TopSites }))(TopSites);
 module.exports._unconnected = TopSites;
 module.exports.TopSite = TopSite;
 
 /***/ }),
-/* 16 */
+/* 20 */
 /***/ (function(module, exports, __webpack_require__) {
 
 "use strict";
 
 
-var _require = __webpack_require__(0);
+const React = __webpack_require__(0);
+
+var _require = __webpack_require__(2);
+
+const FormattedMessage = _require.FormattedMessage;
+
+
+class Topic extends React.Component {
+  render() {
+    var _props = this.props;
+    const url = _props.url,
+          name = _props.name;
+
+    return React.createElement(
+      "li",
+      null,
+      React.createElement(
+        "a",
+        { key: name, className: "topic-link", href: url },
+        name
+      )
+    );
+  }
+}
+
+class Topics extends React.Component {
+  render() {
+    var _props2 = this.props;
+    const topics = _props2.topics,
+          read_more_endpoint = _props2.read_more_endpoint;
+
+    return React.createElement(
+      "div",
+      { className: "topic" },
+      React.createElement(
+        "span",
+        null,
+        React.createElement(FormattedMessage, { id: "pocket_read_more" })
+      ),
+      React.createElement(
+        "ul",
+        null,
+        topics.map(t => React.createElement(Topic, { key: t.name, url: t.url, name: t.name }))
+      ),
+      React.createElement(
+        "a",
+        { className: "topic-read-more", href: read_more_endpoint },
+        React.createElement(FormattedMessage, { id: "pocket_read_even_more" }),
+        React.createElement("span", { className: "topic-read-more-logo" })
+      )
+    );
+  }
+}
+
+module.exports = Topics;
+module.exports._unconnected = Topics;
+module.exports.Topic = Topic;
+
+/***/ }),
+/* 21 */
+/***/ (function(module, exports, __webpack_require__) {
+
+"use strict";
+
+
+var _require = __webpack_require__(1);
 
 const at = _require.actionTypes,
       ac = _require.actionCreators;
 
 const shortURL = __webpack_require__(4);
 
 /**
  * List of functions that return items that can be included as menu options in a
@@ -1519,17 +2189,17 @@ module.exports = {
     userEvent: "SAVE_TO_POCKET"
   })
 };
 
 module.exports.CheckBookmark = site => site.bookmarkGuid ? module.exports.RemoveBookmark(site) : module.exports.AddBookmark(site);
 module.exports.CheckPinTopSite = (site, index) => site.isPinned ? module.exports.UnpinTopSite(site) : module.exports.PinTopSite(site, index);
 
 /***/ }),
-/* 17 */
+/* 22 */
 /***/ (function(module, exports, __webpack_require__) {
 
 "use strict";
 /* globals Services */
 
 
 let usablePerfObj;
 
@@ -1609,63 +2279,108 @@ var _PerfService = function _PerfService
    * @return {Number}       the returned start time, as a DOMHighResTimeStamp
    *
    * @throws {Error}        "No Marks with the name ..." if none are available
    */
   getMostRecentAbsMarkStartByName(name) {
     let entries = this.getEntriesByName(name, "mark");
 
     if (!entries.length) {
-      throw new Error(`No marks with the name ${name}`);
+      throw new Error(`No marks with the name ${ name }`);
     }
 
     let mostRecentEntry = entries[entries.length - 1];
     return this._perf.timeOrigin + mostRecentEntry.startTime;
   }
 };
 
 var perfService = new _PerfService();
 module.exports = {
   _PerfService,
   perfService
 };
 
 /***/ }),
-/* 18 */
+/* 23 */
+/***/ (function(module, exports) {
+
+var g;
+
+// This works in non-strict mode
+g = (function() {
+	return this;
+})();
+
+try {
+	// This works if eval is allowed (see CSP)
+	g = g || Function("return this")() || (1,eval)("this");
+} catch(e) {
+	// This works if the window reference is available
+	if(typeof window === "object")
+		g = window;
+}
+
+// g can still be undefined, but nothing to do about it...
+// We return undefined, instead of nothing here, so it's
+// easier to handle this case. if(!global) { ...}
+
+module.exports = g;
+
+
+/***/ }),
+/* 24 */
 /***/ (function(module, exports) {
 
 module.exports = Redux;
 
 /***/ }),
-/* 19 */
+/* 25 */
 /***/ (function(module, exports, __webpack_require__) {
 
 "use strict";
 
 
-const React = __webpack_require__(1);
-const ReactDOM = __webpack_require__(9);
-const Base = __webpack_require__(5);
+const React = __webpack_require__(0);
+const ReactDOM = __webpack_require__(11);
+const Base = __webpack_require__(6);
 
-var _require = __webpack_require__(2);
+var _require = __webpack_require__(3);
 
 const Provider = _require.Provider;
 
-const initStore = __webpack_require__(7);
+const initStore = __webpack_require__(8);
 
-var _require2 = __webpack_require__(8);
+var _require2 = __webpack_require__(10);
 
 const reducers = _require2.reducers;
 
-const DetectUserSessionStart = __webpack_require__(6);
+const DetectUserSessionStart = __webpack_require__(7);
+
+var _require3 = __webpack_require__(9);
+
+const SnippetsProvider = _require3.SnippetsProvider;
+
 
 new DetectUserSessionStart().sendEventOrAddListener();
 
 const store = initStore(reducers);
 
 ReactDOM.render(React.createElement(
   Provider,
   { store: store },
   React.createElement(Base, null)
 ), document.getElementById("root"));
 
+// Trigger snippets when snippets data has been received.
+const snippets = new SnippetsProvider();
+const unsubscribe = store.subscribe(() => {
+  const state = store.getState();
+  if (state.Snippets.initialized) {
+    snippets.init({
+      snippetsURL: state.Snippets.snippetsURL,
+      version: state.Snippets.version
+    });
+    unsubscribe();
+  }
+});
+
 /***/ })
 /******/ ]);
\ No newline at end of file
--- a/browser/extensions/activity-stream/data/content/activity-stream.css
+++ b/browser/extensions/activity-stream/data/content/activity-stream.css
@@ -1,8 +1,9 @@
+@charset "UTF-8";
 html {
   box-sizing: border-box; }
 
 *,
 *::before,
 *::after {
   box-sizing: inherit; }
 
@@ -25,16 +26,18 @@ input {
   width: 16px;
   height: 16px;
   background-size: 16px;
   background-position: center center;
   background-repeat: no-repeat;
   vertical-align: middle; }
   .icon.icon-spacer {
     margin-inline-end: 8px; }
+  .icon.icon-small-spacer {
+    margin-inline-end: 6px; }
   .icon.icon-bookmark {
     background-image: url("assets/glyph-bookmark-16.svg"); }
   .icon.icon-bookmark-remove {
     background-image: url("assets/glyph-bookmark-remove-16.svg"); }
   .icon.icon-delete {
     background-image: url("assets/glyph-delete-16.svg"); }
   .icon.icon-dismiss {
     background-image: url("assets/glyph-dismiss-16.svg"); }
@@ -45,21 +48,29 @@ input {
   .icon.icon-settings {
     background-image: url("assets/glyph-settings-16.svg"); }
   .icon.icon-pin {
     background-image: url("assets/glyph-pin-16.svg"); }
   .icon.icon-unpin {
     background-image: url("assets/glyph-unpin-16.svg"); }
   .icon.icon-pocket {
     background-image: url("assets/glyph-pocket-16.svg"); }
+  .icon.icon-historyItem {
+    background-image: url("assets/glyph-historyItem-16.svg"); }
+  .icon.icon-trending {
+    background-image: url("assets/glyph-trending-16.svg"); }
+  .icon.icon-now {
+    background-image: url("assets/glyph-now-16.svg"); }
   .icon.icon-pin-small {
     background-image: url("assets/glyph-pin-12.svg");
     background-size: 12px;
     height: 12px;
     width: 12px; }
+  .icon.icon-check {
+    background-image: url("chrome://browser/skin/check.svg"); }
 
 html,
 body,
 #root {
   height: 100%; }
 
 body {
   background: #F6F6F8;
@@ -129,32 +140,45 @@ a {
       box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.1);
       transition: box-shadow 150ms; }
     .actions button.done {
       background: #0695F9;
       border: solid 1px #1677CF;
       color: #FFF;
       margin-inline-start: auto; }
 
+#snippets-container {
+  display: none;
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  background: white;
+  height: 122px; }
+
+#snippets {
+  max-width: 736px;
+  margin: 0 auto; }
+
 .outer-wrapper {
   display: flex;
   flex-grow: 1;
   padding: 62px 32px 32px;
   height: 100%; }
 
 main {
   margin: auto; }
   @media (min-width: 672px) {
     main {
       width: 608px; } }
   @media (min-width: 800px) {
     main {
       width: 736px; } }
   main section {
-    margin-bottom: 41px; }
+    margin-bottom: 40px; }
 
 .section-title {
   color: #6E707E;
   font-size: 13px;
   font-weight: bold;
   text-transform: uppercase;
   margin: 0 0 18px; }
 
@@ -200,20 +224,20 @@ main {
       transform: scale(0.25);
       opacity: 0;
       transition-property: transform, opacity;
       transition-duration: 200ms;
       z-index: 399; }
       .top-sites-list .top-site-outer .context-menu-button:focus, .top-sites-list .top-site-outer .context-menu-button:active {
         transform: scale(1);
         opacity: 1; }
-    .top-sites-list .top-site-outer:hover .tile, .top-sites-list .top-site-outer:active .tile, .top-sites-list .top-site-outer:focus .tile, .top-sites-list .top-site-outer.active .tile {
+    .top-sites-list .top-site-outer:hover .tile, .top-sites-list .top-site-outer:focus .tile, .top-sites-list .top-site-outer.active .tile {
       box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1), 0 0 0 5px rgba(0, 0, 0, 0.1);
       transition: box-shadow 150ms; }
-    .top-sites-list .top-site-outer:hover .context-menu-button, .top-sites-list .top-site-outer:active .context-menu-button, .top-sites-list .top-site-outer:focus .context-menu-button, .top-sites-list .top-site-outer.active .context-menu-button {
+    .top-sites-list .top-site-outer:hover .context-menu-button, .top-sites-list .top-site-outer:focus .context-menu-button, .top-sites-list .top-site-outer.active .context-menu-button {
       transform: scale(1);
       opacity: 1; }
     .top-sites-list .top-site-outer .tile {
       position: relative;
       height: 96px;
       width: 96px;
       border-radius: 6px;
       box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1);
@@ -253,16 +277,127 @@ main {
       .top-sites-list .top-site-outer .title span {
         display: block;
         overflow: hidden;
         text-overflow: ellipsis;
         white-space: nowrap; }
       .top-sites-list .top-site-outer .title.pinned span {
         padding: 0 13px; }
 
+.sections-list .section-top-bar {
+  position: relative;
+  height: 16px;
+  margin-bottom: 18px; }
+  .sections-list .section-top-bar .section-title {
+    float: left; }
+  .sections-list .section-top-bar .section-info-option {
+    float: right; }
+  .sections-list .section-top-bar .info-option-icon {
+    background-image: url("assets/glyph-info-option-12.svg");
+    background-size: 12px 12px;
+    background-repeat: no-repeat;
+    background-position: center;
+    height: 16px;
+    width: 16px;
+    display: inline-block; }
+  .sections-list .section-top-bar .section-info-option div {
+    visibility: hidden;
+    opacity: 0;
+    transition: visibility 0.2s, opacity 0.2s ease-out;
+    transition-delay: 0.5s; }
+  .sections-list .section-top-bar .section-info-option:hover div {
+    visibility: visible;
+    opacity: 1;
+    transition: visibility 0.2s, opacity 0.2s ease-out; }
+  .sections-list .section-top-bar .info-option {
+    z-index: 9999;
+    position: absolute;
+    background: #FFF;
+    border: solid 1px rgba(0, 0, 0, 0.1);
+    border-radius: 3px;
+    font-size: 13px;
+    color: #0C0C0D;
+    line-height: 120%;
+    width: 320px;
+    right: 0;
+    top: 34px;
+    margin-top: -4px;
+    margin-right: -4px;
+    padding: 24px;
+    -moz-user-select: none; }
+  .sections-list .section-top-bar .info-option-header {
+    font-size: 15px;
+    font-weight: 600; }
+  .sections-list .section-top-bar .info-option-body {
+    margin: 0;
+    margin-top: 12px; }
+  .sections-list .section-top-bar .info-option-link {
+    display: block;
+    margin-top: 12px;
+    color: #0A84FF; }
+
+.sections-list .section-list {
+  width: 768px;
+  clear: both;
+  margin: 0; }
+
+.sections-list .section-empty-state {
+  width: 100%;
+  height: 266px;
+  display: flex;
+  border: solid 1px rgba(0, 0, 0, 0.1);
+  border-radius: 3px; }
+  .sections-list .section-empty-state .empty-state {
+    margin: auto;
+    max-width: 350px; }
+    .sections-list .section-empty-state .empty-state .empty-state-icon {
+      background-size: 50px 50px;
+      background-repeat: no-repeat;
+      background-position: center;
+      fill: rgba(160, 160, 160, 0.4);
+      -moz-context-properties: fill;
+      height: 50px;
+      width: 50px;
+      margin: 0 auto;
+      display: block; }
+    .sections-list .section-empty-state .empty-state .empty-state-message {
+      margin-bottom: 0;
+      font-size: 13px;
+      font-weight: 300;
+      color: #A0A0A0;
+      text-align: center; }
+
+.topic {
+  font-size: 13px;
+  color: #BFC0C7;
+  min-width: 780px; }
+  .topic ul {
+    display: inline;
+    padding-left: 12px; }
+  .topic ul li {
+    display: inline; }
+  .topic ul li::after {
+    content: '•';
+    padding-left: 8px;
+    padding-right: 8px; }
+  .topic ul li:last-child::after {
+    content: none; }
+  .topic .topic-link {
+    color: #008EA4; }
+  .topic .topic-read-more {
+    float: right;
+    margin-right: 40px;
+    color: #008EA4; }
+  .topic .topic-read-more-logo {
+    padding-right: 10px;
+    margin-left: 5px;
+    background-image: url("assets/topic-show-more-12.svg");
+    background-repeat: no-repeat;
+    background-position-y: 2px; }
+
 .search-wrapper {
   cursor: default;
   display: flex;
   position: relative;
   margin: 0 0 48px;
   width: 100%;
   height: 36px; }
   .search-wrapper input {
@@ -511,8 +646,114 @@ main {
   z-index: 11001; }
 
 .modal {
   background: #FFF;
   border: solid 1px rgba(0, 0, 0, 0.1);
   border-radius: 3px;
   font-size: 14px;
   z-index: 11002; }
+
+.card-outer {
+  background: #FFF;
+  display: inline-block;
+  margin-inline-end: 32px;
+  margin-bottom: 16px;
+  width: 224px;
+  border-radius: 3px;
+  border-color: rgba(0, 0, 0, 0.1);
+  height: 266px;
+  position: relative; }
+  .card-outer .context-menu-button {
+    cursor: pointer;
+    position: absolute;
+    top: -13.5px;
+    offset-inline-end: -13.5px;
+    width: 27px;
+    height: 27px;
+    background-color: #FFF;
+    background-image: url("assets/glyph-more-16.svg");
+    background-position: 65%;
+    background-repeat: no-repeat;
+    background-clip: padding-box;
+    border: 1px solid rgba(0, 0, 0, 0.2);
+    border-radius: 100%;
+    box-shadow: 0 2px 0 rgba(0, 0, 0, 0.1);
+    transform: scale(0.25);
+    opacity: 0;
+    transition-property: transform, opacity;
+    transition-duration: 200ms;
+    z-index: 399; }
+    .card-outer .context-menu-button:focus, .card-outer .context-menu-button:active {
+      transform: scale(1);
+      opacity: 1; }
+  .card-outer .card {
+    height: 100%;
+    border-radius: 3px; }
+  .card-outer > a {
+    display: block;
+    color: inherit;
+    height: 100%;
+    outline: none;
+    position: absolute; }
+    .card-outer > a.active .card, .card-outer > a:focus .card {
+      box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1), 0 0 0 5px rgba(0, 0, 0, 0.1);
+      transition: box-shadow 150ms; }
+  .card-outer:hover, .card-outer:focus, .card-outer.active {
+    outline: none;
+    box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1), 0 0 0 5px rgba(0, 0, 0, 0.1);
+    transition: box-shadow 150ms; }
+    .card-outer:hover .context-menu-button, .card-outer:focus .context-menu-button, .card-outer.active .context-menu-button {
+      transform: scale(1);
+      opacity: 1; }
+  .card-outer .card-preview-image {
+    position: relative;
+    background-size: cover;
+    background-position: center;
+    background-repeat: no-repeat;
+    height: 122px;
+    border-bottom-color: rgba(0, 0, 0, 0.1);
+    border-bottom-style: solid;
+    border-bottom-width: 1px;
+    border-radius: 3px 3px 0 0; }
+  .card-outer .card-details {
+    padding: 10px 16px 12px; }
+  .card-outer .card-text {
+    overflow: hidden;
+    max-height: 78px; }
+    .card-outer .card-text.full-height {
+      max-height: 200px; }
+  .card-outer .card-host-name {
+    color: #858585;
+    font-size: 10px;
+    padding-bottom: 4px;
+    text-transform: uppercase; }
+  .card-outer .card-title {
+    margin: 0 0 2px;
+    font-size: inherit;
+    word-wrap: break-word; }
+  .card-outer .card-description {
+    font-size: 12px;
+    margin: 0;
+    word-wrap: break-word;
+    overflow: hidden;
+    line-height: 18px;
+    max-height: 34px; }
+  .card-outer .card-context {
+    padding: 16px 16px 14px 14px;
+    position: absolute;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    color: #A0A0A0;
+    font-size: 11px;
+    display: flex;
+    align-items: center; }
+  .card-outer .card-context-icon {
+    opacity: 0.5;
+    font-size: 13px;
+    margin-inline-end: 6px;
+    display: block; }
+  .card-outer .card-context-label {
+    flex-grow: 1;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap; }
--- a/browser/extensions/activity-stream/data/content/activity-stream.html
+++ b/browser/extensions/activity-stream/data/content/activity-stream.html
@@ -3,16 +3,20 @@
   <head>
     <meta charset="utf-8">
     <title></title>
     <link rel="stylesheet" href="chrome://browser/content/contentSearchUI.css" />
     <link rel="stylesheet" href="resource://activity-stream/data/content/activity-stream.css" />
   </head>
   <body class="activity-stream">
     <div id="root"></div>
+    <div id="snippets-container">
+      <div id="topSection"></div> <!-- TODO: placeholder for v4 snippets. It should be removed when we switch to v5 -->
+      <div id="snippets"></div>
+    </div>
     <script src="chrome://browser/content/contentSearchUI.js"></script>
     <script src="resource://activity-stream/vendor/react.js"></script>
     <script src="resource://activity-stream/vendor/react-dom.js"></script>
     <script src="resource://activity-stream/vendor/react-intl.js"></script>
     <script src="resource://activity-stream/vendor/redux.js"></script>
     <script src="resource://activity-stream/vendor/react-redux.js"></script>
     <script src="resource://activity-stream/data/content/activity-stream.bundle.js"></script>
   </body>
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/data/content/assets/glyph-historyItem-16.svg
@@ -0,0 +1,6 @@
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
+  <path fill="#4d4d4d" d="M365,190a4,4,0,1,1,4-4A4,4,0,0,1,365,190Zm0-6a2,2,0,1,0,2,2A2,2,0,0,0,365,184Z" transform="translate(-357 -178)"/>
+</svg>
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/data/content/assets/glyph-info-option-12.svg
@@ -0,0 +1,1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12"><path fill="#999" d="M6 0a6 6 0 1 0 6 6 6 6 0 0 0-6-6zm.7 10.26a1.13 1.13 0 0 1-.78.28 1.13 1.13 0 0 1-.78-.28 1 1 0 0 1 0-1.42 1.13 1.13 0 0 1 .78-.28 1.13 1.13 0 0 1 .78.28 1 1 0 0 1 0 1.42zM8.55 5a3 3 0 0 1-.62.81l-.67.63a1.58 1.58 0 0 0-.4.57 2.24 2.24 0 0 0-.12.74H5.06a3.82 3.82 0 0 1 .19-1.35 2.11 2.11 0 0 1 .63-.86 4.17 4.17 0 0 0 .66-.67 1.09 1.09 0 0 0 .23-.67.73.73 0 0 0-.77-.86.71.71 0 0 0-.57.26 1.1 1.1 0 0 0-.23.7h-2A2.36 2.36 0 0 1 4 2.47a2.94 2.94 0 0 1 2-.65 3.06 3.06 0 0 1 2 .6 2.12 2.12 0 0 1 .72 1.72 2 2 0 0 1-.17.86z"/></svg>
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/data/content/assets/glyph-now-16.svg
@@ -0,0 +1,6 @@
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
+  <path fill="#4d4d4d" d="M8 0a8 8 0 1 0 8 8 8.009 8.009 0 0 0-8-8zm0 14a6 6 0 1 1 6-6 6.007 6.007 0 0 1-6 6zm3.5-6H8V4.5a.5.5 0 0 0-1 0v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 0-1z"/>
+</svg>
--- a/browser/extensions/activity-stream/data/content/assets/glyph-pocket-16.svg
+++ b/browser/extensions/activity-stream/data/content/assets/glyph-pocket-16.svg
@@ -1,6 +1,6 @@
 <!-- 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/. -->
 <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
-  <path fill="context-fill" d="M8 15a8 8 0 0 1-8-8V3a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v4a8 8 0 0 1-8 8zm3.985-10.032a.99.99 0 0 0-.725.319L7.978 8.57 4.755 5.336A.984.984 0 0 0 4 4.968a1 1 0 0 0-.714 1.7l-.016.011 3.293 3.306.707.707a1 1 0 0 0 1.414 0l.707-.707L12.7 6.679a1 1 0 0 0-.715-1.711z"/>
-</svg>
\ No newline at end of file
+  <path fill="#4d4d4d" d="M8 15a8 8 0 0 1-8-8V3a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v4a8 8 0 0 1-8 8zm3.985-10.032a.99.99 0 0 0-.725.319L7.978 8.57 4.755 5.336A.984.984 0 0 0 4 4.968a1 1 0 0 0-.714 1.7l-.016.011 3.293 3.306.707.707a1 1 0 0 0 1.414 0l.707-.707L12.7 6.679a1 1 0 0 0-.715-1.711z"/>
+</svg>
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/data/content/assets/glyph-trending-16.svg
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="Context-/-Pocket-Trending" fill="#999999">
+            <path d="M12.164765,5.74981818 C12.4404792,5.74981818 12.5976221,6.06981818 12.4233364,6.28509091 C10.7404792,8.37236364 4.26619353,15.6829091 4.15905067,15.744 C5.70047924,12.3301818 7.1276221,8.976 7.1276221,8.976 L4.3276221,8.976 C4.09905067,8.976 3.9376221,8.74472727 4.02333638,8.52654545 C4.70047924,6.77672727 6.86190781,1.32945455 7.30476495,0.216727273 C7.35333638,0.0916363636 7.46190781,0.0174545455 7.59476495,0.016 C8.32476495,0.0130909091 10.7904792,0.00290909091 12.5790507,0 C12.844765,0 12.9976221,0.305454545 12.8433364,0.525090909 L9.17190781,5.74981818 L12.164765,5.74981818 Z" id="Fill-1"></path>
+        </g>
+    </g>
+</svg>
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/data/content/assets/topic-show-more-12.svg
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="12px" height="12px" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 43.2 (39069) - http://www.bohemiancoding.com/sketch -->
+    <title>Icon / &gt;</title>
+    <desc>Created with Sketch.</desc>
+    <defs></defs>
+    <g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
+        <g id="Icon-/-&gt;" stroke-width="2" stroke="#008EA4">
+            <polyline id="Path-2" points="4 2 8 6 4 10"></polyline>
+        </g>
+    </g>
+</svg>
\ No newline at end of file
--- a/browser/extensions/activity-stream/data/locales.json
+++ b/browser/extensions/activity-stream/data/locales.json
@@ -1020,16 +1020,17 @@
   },
   "en-US": {
     "newtab_page_title": "New Tab",
     "default_label_loading": "Loading…",
     "header_top_sites": "Top Sites",
     "header_stories": "Top Stories",
     "header_visit_again": "Visit Again",
     "header_bookmarks": "Recent Bookmarks",
+    "header_recommended_by": "Recommended by {provider}",
     "header_bookmarks_placeholder": "You don’t have any bookmarks yet.",
     "header_stories_from": "from",
     "type_label_visited": "Visited",
     "type_label_bookmarked": "Bookmarked",
     "type_label_synced": "Synced from another device",
     "type_label_recommended": "Trending",
     "type_label_open": "Open",
     "type_label_topic": "Topic",
@@ -1046,16 +1047,17 @@
     "confirm_history_delete_p1": "Are you sure you want to delete every instance of this page from your history?",
     "confirm_history_delete_notice_p2": "This action cannot be undone.",
     "menu_action_save_to_pocket": "Save to Pocket",
     "search_for_something_with": "Search for {search_term} with:",
     "search_button": "Search",
     "search_header": "{search_engine_name} Search",
     "search_web_placeholder": "Search the Web",
     "search_settings": "Change Search Settings",
+    "section_info_option": "Info",
     "welcome_title": "Welcome to new tab",
     "welcome_body": "Firefox will use this space to show your most relevant bookmarks, articles, videos, and pages you’ve recently visited, so you can get back to them easily.",
     "welcome_label": "Identifying your Highlights",
     "time_label_less_than_minute": "<1m",
     "time_label_minute": "{number}m",
     "time_label_hour": "{number}h",
     "time_label_day": "{number}d",
     "settings_pane_button_label": "Customize your New Tab page",
@@ -1090,17 +1092,18 @@
     "topsites_form_add_button": "Add",
     "topsites_form_save_button": "Save",
     "topsites_form_cancel_button": "Cancel",
     "topsites_form_url_validation": "Valid URL required",
     "pocket_read_more": "Popular Topics:",
     "pocket_read_even_more": "View More Stories",
     "pocket_feedback_header": "The best of the web, curated by over 25 million people.",
     "pocket_feedback_body": "Pocket, a part of the Mozilla family, will help connect you to high-quality content that you may not have found otherwise.",
-    "pocket_send_feedback": "Send Feedback"
+    "pocket_send_feedback": "Send Feedback",
+    "empty_state_topstories": "You’ve caught up. Check back later for more top stories from Pocket. Can’t wait? Select a popular topic to find more great stories from around the web."
   },
   "en-ZA": {},
   "eo": {
     "newtab_page_title": "Nova legosigno",
     "default_label_loading": "Ŝargado…",
     "header_top_sites": "Plej vizititaj",
     "header_highlights": "Elstaraĵoj",
     "type_label_visited": "Vizititaj",
--- a/browser/extensions/activity-stream/lib/ActivityStream.jsm
+++ b/browser/extensions/activity-stream/lib/ActivityStream.jsm
@@ -9,21 +9,44 @@ const {utils: Cu} = Components;
 // common case to avoid the overhead of wrapping and detecting lazy loading.
 const {actionTypes: at} = Cu.import("resource://activity-stream/common/Actions.jsm", {});
 const {DefaultPrefs} = Cu.import("resource://activity-stream/lib/ActivityStreamPrefs.jsm", {});
 const {LocalizationFeed} = Cu.import("resource://activity-stream/lib/LocalizationFeed.jsm", {});
 const {NewTabInit} = Cu.import("resource://activity-stream/lib/NewTabInit.jsm", {});
 const {PlacesFeed} = Cu.import("resource://activity-stream/lib/PlacesFeed.jsm", {});
 const {PrefsFeed} = Cu.import("resource://activity-stream/lib/PrefsFeed.jsm", {});
 const {Store} = Cu.import("resource://activity-stream/lib/Store.jsm", {});
+const {SnippetsFeed} = Cu.import("resource://activity-stream/lib/SnippetsFeed.jsm", {});
+const {SystemTickFeed} = Cu.import("resource://activity-stream/lib/SystemTickFeed.jsm", {});
 const {TelemetryFeed} = Cu.import("resource://activity-stream/lib/TelemetryFeed.jsm", {});
 const {TopSitesFeed} = Cu.import("resource://activity-stream/lib/TopSitesFeed.jsm", {});
+const {TopStoriesFeed} = Cu.import("resource://activity-stream/lib/TopStoriesFeed.jsm", {});
 
 const REASON_ADDON_UNINSTALL = 6;
 
+// Sections, keyed by section id
+const SECTIONS = new Map([
+  ["topstories", {
+    feed: TopStoriesFeed,
+    prefTitle: "Fetches content recommendations from a configurable content provider",
+    showByDefault: false
+  }]
+]);
+
+const SECTION_FEEDS_CONFIG = Array.from(SECTIONS.entries()).map(entry => {
+  const id = entry[0];
+  const {feed: Feed, prefTitle, showByDefault: value} = entry[1];
+  return {
+    name: `section.${id}`,
+    factory: () => new Feed(),
+    title: prefTitle || `${id} section feed`,
+    value
+  };
+});
+
 const PREFS_CONFIG = new Map([
   ["default.sites", {
     title: "Comma-separated list of default top sites to fill in behind visited sites",
     value: "https://www.facebook.com/,https://www.youtube.com/,https://www.amazon.com/,https://www.yahoo.com/,https://www.ebay.com/,https://twitter.com/"
   }],
   ["showSearch", {
     title: "Show the Search bar on the New Tab page",
     value: true
@@ -40,21 +63,34 @@ const PREFS_CONFIG = new Map([
   ["telemetry.log", {
     title: "Log telemetry events in the console",
     value: false,
     value_local_dev: true
   }],
   ["telemetry.ping.endpoint", {
     title: "Telemetry server endpoint",
     value: "https://tiles.services.mozilla.com/v4/links/activity-stream"
+  }],
+  ["feeds.section.topstories.options", {
+    title: "Configuration options for top stories feed",
+    value: `{
+      "stories_endpoint": "https://getpocket.com/v3/firefox/global-recs?consumer_key=$apiKey",
+      "topics_endpoint": "https://getpocket.com/v3/firefox/trending-topics?consumer_key=$apiKey",
+      "read_more_endpoint": "https://getpocket.com/explore/trending?src=ff_new_tab",
+      "learn_more_endpoint": "https://getpocket.com/firefox_learnmore?src=ff_newtab",
+      "survey_link": "https://www.surveymonkey.com/r/newtabffx",
+      "api_key_pref": "extensions.pocket.oAuthConsumerKey",
+      "provider_name": "Pocket",
+      "provider_icon": "pocket"
+    }`
   }]
 ]);
 
 const FEEDS_CONFIG = new Map();
-for (const {name, factory, title, value} of [
+for (const {name, factory, title, value} of SECTION_FEEDS_CONFIG.concat([
   {
     name: "localization",
     factory: () => new LocalizationFeed(),
     title: "Initialize strings and detect locale for Activity Stream",
     value: true
   },
   {
     name: "newtabinit",
@@ -70,28 +106,40 @@ for (const {name, factory, title, value}
   },
   {
     name: "prefs",
     factory: () => new PrefsFeed(PREFS_CONFIG),
     title: "Preferences",
     value: true
   },
   {
+    name: "snippets",
+    factory: () => new SnippetsFeed(),
+    title: "Gets snippets data",
+    value: false
+  },
+  {
+    name: "systemtick",
+    factory: () => new SystemTickFeed(),
+    title: "Produces system tick events to periodically check for data expiry",
+    value: true
+  },
+  {
     name: "telemetry",
     factory: () => new TelemetryFeed(),
     title: "Relays telemetry-related actions to TelemetrySender",
     value: true
   },
   {
     name: "topsites",
     factory: () => new TopSitesFeed(),
     title: "Queries places and gets metadata for Top Sites section",
     value: true
   }
-]) {
+])) {
   const pref = `feeds.${name}`;
   FEEDS_CONFIG.set(pref, factory);
   PREFS_CONFIG.set(pref, {title, value});
 }
 
 this.ActivityStream = class ActivityStream {
 
   /**
@@ -130,9 +178,9 @@ this.ActivityStream = class ActivityStre
       // so we DON'T want to do this on an upgrade/downgrade, only on a
       // real uninstall
       this._defaultPrefs.reset();
     }
   }
 };
 
 this.PREFS_CONFIG = PREFS_CONFIG;
-this.EXPORTED_SYMBOLS = ["ActivityStream"];
+this.EXPORTED_SYMBOLS = ["ActivityStream", "SECTIONS"];
--- a/browser/extensions/activity-stream/lib/PlacesFeed.jsm
+++ b/browser/extensions/activity-stream/lib/PlacesFeed.jsm
@@ -8,16 +8,18 @@ Cu.import("resource://gre/modules/Servic
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 const {actionCreators: ac, actionTypes: at} = Cu.import("resource://activity-stream/common/Actions.jsm", {});
 
 XPCOMUtils.defineLazyModuleGetter(this, "NewTabUtils",
   "resource://gre/modules/NewTabUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
   "resource://gre/modules/PlacesUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Pocket",
+  "chrome://pocket/content/Pocket.jsm");
 
 const LINK_BLOCKED_EVENT = "newtab-linkBlocked";
 
 /**
  * Observer - a wrapper around history/bookmark observers to add the QueryInterface.
  */
 class Observer {
   constructor(dispatch, observerInterface) {
@@ -200,16 +202,19 @@ class PlacesFeed {
         NewTabUtils.activityStreamLinks.addBookmark(action.data);
         break;
       case at.DELETE_BOOKMARK_BY_ID:
         NewTabUtils.activityStreamLinks.deleteBookmark(action.data);
         break;
       case at.DELETE_HISTORY_URL:
         NewTabUtils.activityStreamLinks.deleteHistoryEntry(action.data);
         break;
+      case at.SAVE_TO_POCKET:
+        Pocket.savePage(action._target.browser, action.data.site.url, action.data.site.title);
+        break;
     }
   }
 }
 
 this.PlacesFeed = PlacesFeed;
 
 // Exported for testing only
 PlacesFeed.HistoryObserver = HistoryObserver;
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/lib/SnippetsFeed.jsm
@@ -0,0 +1,58 @@
+/* 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/. */
+"use strict";
+
+const {utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Console.jsm");
+const {actionTypes: at, actionCreators: ac} = Cu.import("resource://activity-stream/common/Actions.jsm", {});
+
+// Url to fetch snippets, in the urlFormatter service format.
+const SNIPPETS_URL_PREF = "browser.aboutHomeSnippets.updateUrl";
+
+// Should be bumped up if the snippets content format changes.
+const STARTPAGE_VERSION = 4;
+
+this.SnippetsFeed = class SnippetsFeed {
+  constructor() {
+    this._onUrlChange = this._onUrlChange.bind(this);
+  }
+  get snippetsURL() {
+    const updateURL = Services
+      .prefs.getStringPref(SNIPPETS_URL_PREF)
+      .replace("%STARTPAGE_VERSION%", STARTPAGE_VERSION);
+    return Services.urlFormatter.formatURL(updateURL);
+  }
+  init() {
+    const data = {
+      snippetsURL: this.snippetsURL,
+      version: STARTPAGE_VERSION
+    };
+    this.store.dispatch(ac.BroadcastToContent({type: at.SNIPPETS_DATA, data}));
+    Services.prefs.addObserver(SNIPPETS_URL_PREF, this._onUrlChange);
+  }
+  uninit() {
+    this.store.dispatch({type: at.SNIPPETS_RESET});
+    Services.prefs.removeObserver(SNIPPETS_URL_PREF, this._onUrlChange);
+  }
+  _onUrlChange() {
+    this.store.dispatch(ac.BroadcastToContent({
+      type: at.SNIPPETS_DATA,
+      data: {snippetsURL: this.snippetsURL}
+    }));
+  }
+  onAction(action) {
+    switch (action.type) {
+      case at.INIT:
+        this.init();
+        break;
+      case at.FEED_INIT:
+        if (action.data === "feeds.snippets") { this.init(); }
+        break;
+    }
+  }
+};
+
+this.EXPORTED_SYMBOLS = ["SnippetsFeed"];
--- a/browser/extensions/activity-stream/lib/Store.jsm
+++ b/browser/extensions/activity-stream/lib/Store.jsm
@@ -4,16 +4,17 @@
 "use strict";
 
 const {utils: Cu} = Components;
 
 const {ActivityStreamMessageChannel} = Cu.import("resource://activity-stream/lib/ActivityStreamMessageChannel.jsm", {});
 const {Prefs} = Cu.import("resource://activity-stream/lib/ActivityStreamPrefs.jsm", {});
 const {reducers} = Cu.import("resource://activity-stream/common/Reducers.jsm", {});
 const {redux} = Cu.import("resource://activity-stream/vendor/Redux.jsm", {});
+const {actionTypes: at} = Cu.import("resource://activity-stream/common/Actions.jsm", {});
 
 /**
  * Store - This has a similar structure to a redux store, but includes some extra
  *         functionality to allow for routing of actions between the Main processes
  *         and child processes via a ActivityStreamMessageChannel.
  *         It also accepts an array of "Feeds" on inititalization, which
  *         can listen for any action that is dispatched through the store.
  */
@@ -86,16 +87,17 @@ this.Store = class Store {
 
   /**
    * onPrefChanged - Listener for handling feed changes.
    */
   onPrefChanged(name, value) {
     if (this._feedFactories.has(name)) {
       if (value) {
         this.initFeed(name);
+        this.dispatch({type: at.FEED_INIT, data: name});
       } else {
         this.uninitFeed(name);
       }
     }
   }
 
   /**
    * init - Initializes the ActivityStreamMessageChannel channel, and adds feeds.
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/lib/SystemTickFeed.jsm
@@ -0,0 +1,35 @@
+/* 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/. */
+"use strict";
+
+const {utils: Cu} = Components;
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+const {actionTypes: at} = Cu.import("resource://activity-stream/common/Actions.jsm", {});
+
+XPCOMUtils.defineLazyModuleGetter(this, "setInterval", "resource://gre/modules/Timer.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "clearInterval", "resource://gre/modules/Timer.jsm");
+
+// Frequency at which SYSTEM_TICK events are fired
+const SYSTEM_TICK_INTERVAL = 5 * 60 * 1000;
+
+this.SystemTickFeed = class SystemTickFeed {
+  init() {
+    this.intervalId = setInterval(() => this.store.dispatch({type: at.SYSTEM_TICK}), SYSTEM_TICK_INTERVAL);
+  }
+
+  onAction(action) {
+    switch (action.type) {
+      case at.INIT:
+        this.init();
+        break;
+      case at.UNINIT:
+        clearInterval(this.intervalId);
+        break;
+    }
+  }
+};
+
+this.SYSTEM_TICK_INTERVAL = SYSTEM_TICK_INTERVAL;
+this.EXPORTED_SYMBOLS = ["SystemTickFeed", "SYSTEM_TICK_INTERVAL"];
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/lib/TopStoriesFeed.jsm
@@ -0,0 +1,187 @@
+/* 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/. */
+"use strict";
+
+const {utils: Cu} = Components;
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/NewTabUtils.jsm");
+Cu.importGlobalProperties(["fetch"]);
+
+const {actionCreators: ac, actionTypes: at} = Cu.import("resource://activity-stream/common/Actions.jsm", {});
+const {Prefs} = Cu.import("resource://activity-stream/lib/ActivityStreamPrefs.jsm", {});
+
+const STORIES_UPDATE_TIME = 30 * 60 * 1000; // 30 minutes
+const TOPICS_UPDATE_TIME = 3 * 60 * 60 * 1000; // 3 hours
+const SECTION_ID = "TopStories";
+
+this.TopStoriesFeed = class TopStoriesFeed {
+  constructor() {
+    this.storiesLastUpdated = 0;
+    this.topicsLastUpdated = 0;
+  }
+
+  init() {
+    try {
+      const prefs = new Prefs();
+      const options = JSON.parse(prefs.get("feeds.section.topstories.options"));
+      const apiKey = this._getApiKeyFromPref(options.api_key_pref);
+      this.stories_endpoint = this._produceUrlWithApiKey(options.stories_endpoint, apiKey);
+      this.topics_endpoint = this._produceUrlWithApiKey(options.topics_endpoint, apiKey);
+      this.read_more_endpoint = options.read_more_endpoint;
+
+      // TODO https://github.com/mozilla/activity-stream/issues/2902
+      const sectionOptions = {
+        id: SECTION_ID,
+        icon: options.provider_icon,
+        title: {id: "header_recommended_by", values: {provider: options.provider_name}},
+        rows: [],
+        maxCards: 3,
+        contextMenuOptions: ["SaveToPocket", "Separator", "CheckBookmark", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl"],
+        infoOption: {
+          header: {id: "pocket_feedback_header"},
+          body: {id: "pocket_feedback_body"},
+          link: {
+            href: options.survey_link,
+            id: "pocket_send_feedback"
+          }
+        },
+        emptyState: {
+          message: {id: "empty_state_topstories"},
+          icon: "check"
+        }
+      };
+      this.store.dispatch(ac.BroadcastToContent({type: at.SECTION_REGISTER, data: sectionOptions}));
+
+      this.fetchStories();
+      this.fetchTopics();
+    } catch (e) {
+      Cu.reportError(`Problem initializing top stories feed: ${e.message}`);
+    }
+  }
+
+  uninit() {
+    this.store.dispatch(ac.BroadcastToContent({type: at.SECTION_DEREGISTER, data: SECTION_ID}));
+  }
+
+  async fetchStories() {
+    if (this.stories_endpoint) {
+      const stories = await fetch(this.stories_endpoint)
+        .then(response => {
+          if (response.ok) {
+            return response.text();
+          }
+          throw new Error(`Stories endpoint returned unexpected status: ${response.status}`);
+        })
+        .then(body => {
+          let items = JSON.parse(body).list;
+          items = items
+            .filter(s => !NewTabUtils.blockedLinks.isBlocked(s.dedupe_url))
+            .map(s => ({
+              "guid": s.id,
+              "type": "trending",
+              "title": s.title,
+              "description": s.excerpt,
+              "image": this._normalizeUrl(s.image_src),
+              "url": s.dedupe_url,
+              "lastVisitDate": s.published_timestamp
+            }));
+          return items;
+        })
+        .catch(error => Cu.reportError(`Failed to fetch content: ${error.message}`));
+
+      if (stories) {
+        this.dispatchUpdateEvent(this.storiesLastUpdated,
+          {"type": at.SECTION_ROWS_UPDATE, "data": {"id": SECTION_ID, "rows": stories}});
+        this.storiesLastUpdated = Date.now();
+      }
+    }
+  }
+
+  async fetchTopics() {
+    if (this.topics_endpoint) {
+      const topics = await fetch(this.topics_endpoint)
+        .then(response => {
+          if (response.ok) {
+            return response.text();
+          }
+          throw new Error(`Topics endpoint returned unexpected status: ${response.status}`);
+        })
+        .then(body => JSON.parse(body).topics)
+        .catch(error => Cu.reportError(`Failed to fetch topics: ${error.message}`));
+
+      if (topics) {
+        this.dispatchUpdateEvent(this.topicsLastUpdated,
+          {"type": at.SECTION_ROWS_UPDATE, "data": {"id": SECTION_ID, "topics": topics, "read_more_endpoint": this.read_more_endpoint}});
+        this.topicsLastUpdated = Date.now();
+      }
+    }
+  }
+
+  dispatchUpdateEvent(lastUpdated, evt) {
+    if (lastUpdated === 0) {
+      this.store.dispatch(ac.BroadcastToContent(evt));
+    } else {
+      this.store.dispatch(evt);
+    }
+  }
+
+  _getApiKeyFromPref(apiKeyPref) {
+    if (!apiKeyPref) {
+      return apiKeyPref;
+    }
+
+    return new Prefs().get(apiKeyPref) || Services.prefs.getCharPref(apiKeyPref);
+  }
+
+  _produceUrlWithApiKey(url, apiKey) {
+    if (!url) {
+      return url;
+    }
+
+    if (url.includes("$apiKey") && !apiKey) {
+      throw new Error(`An API key was specified but none configured: ${url}`);
+    }
+
+    return url.replace("$apiKey", apiKey);
+  }
+
+  // Need to remove parenthesis from image URLs as React will otherwise
+  // fail to render them properly as part of the card template.
+  _normalizeUrl(url) {
+    if (url) {
+      return url.replace(/\(/g, "%28").replace(/\)/g, "%29");
+    }
+    return url;
+  }
+
+  onAction(action) {
+    switch (action.type) {
+      case at.INIT:
+        this.init();
+        break;
+      case at.SYSTEM_TICK:
+        if (Date.now() - this.storiesLastUpdated >= STORIES_UPDATE_TIME) {
+          this.fetchStories();
+        }
+        if (Date.now() - this.topicsLastUpdated >= TOPICS_UPDATE_TIME) {
+          this.fetchTopics();
+        }
+        break;
+      case at.UNINIT:
+        this.uninit();
+        break;
+      case at.FEED_INIT:
+        if (action.data === "feeds.section.topstories") {
+          this.init();
+        }
+        break;
+    }
+  }
+};
+
+this.STORIES_UPDATE_TIME = STORIES_UPDATE_TIME;
+this.TOPICS_UPDATE_TIME = TOPICS_UPDATE_TIME;
+this.SECTION_ID = SECTION_ID;
+this.EXPORTED_SYMBOLS = ["TopStoriesFeed", "STORIES_UPDATE_TIME", "TOPICS_UPDATE_TIME", "SECTION_ID"];
--- a/browser/extensions/activity-stream/test/functional/mochitest/browser.ini
+++ b/browser/extensions/activity-stream/test/functional/mochitest/browser.ini
@@ -1,8 +1,7 @@
 [DEFAULT]
-skip-if=!nightly_build
 support-files =
   blue_page.html
 
 [browser_as_load_location.js]
 [browser_getScreenshots.js]
 skip-if=true # issue 2851
deleted file mode 100644
--- a/browser/extensions/activity-stream/test/mozinfo.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
-  "activity_stream": true
-}
--- a/browser/extensions/activity-stream/test/unit/common/Reducers.test.js
+++ b/browser/extensions/activity-stream/test/unit/common/Reducers.test.js
@@ -1,10 +1,11 @@
 const {reducers, INITIAL_STATE, insertPinned} = require("common/Reducers.jsm");
-const {TopSites, App, Prefs, Dialog} = reducers;
+const {TopSites, App, Snippets, Prefs, Dialog, Sections} = reducers;
+
 const {actionTypes: at} = require("common/Actions.jsm");
 
 describe("Reducers", () => {
   describe("App", () => {
     it("should return the initial state", () => {
       const nextState = App(undefined, {type: "FOO"});
       assert.equal(nextState, INITIAL_STATE.App);
     });
@@ -72,16 +73,20 @@ describe("Reducers", () => {
       assert.equal(newRow.url, action.data.url);
       assert.equal(newRow.bookmarkGuid, action.data.bookmarkGuid);
       assert.equal(newRow.bookmarkTitle, action.data.bookmarkTitle);
       assert.equal(newRow.bookmarkDateCreated, action.data.lastModified);
 
       // old row is unchanged
       assert.equal(nextState.rows[0], oldState.rows[0]);
     });
+    it("should not update state for empty action.data on PLACES_BOOKMARK_ADDED", () => {
+      const nextState = TopSites(undefined, {type: at.PLACES_BOOKMARK_ADDED});
+      assert.equal(nextState, INITIAL_STATE.TopSites);
+    });
     it("should remove a bookmark on PLACES_BOOKMARK_REMOVED", () => {
       const oldState = {
         rows: [{url: "foo.com"}, {
           url: "bar.com",
           bookmarkGuid: "bookmark123",
           bookmarkTitle: "Title for bar.com",
           lastModified: 123456
         }]
@@ -93,16 +98,20 @@ describe("Reducers", () => {
       assert.equal(newRow.url, oldState.rows[1].url);
       assert.isUndefined(newRow.bookmarkGuid);
       assert.isUndefined(newRow.bookmarkTitle);
       assert.isUndefined(newRow.bookmarkDateCreated);
 
       // old row is unchanged
       assert.deepEqual(nextState.rows[0], oldState.rows[0]);
     });
+    it("should not update state for empty action.data on PLACES_BOOKMARK_REMOVED", () => {
+      const nextState = TopSites(undefined, {type: at.PLACES_BOOKMARK_REMOVED});
+      assert.equal(nextState, INITIAL_STATE.TopSites);
+    });
     it("should remove a link on PLACES_LINK_BLOCKED and PLACES_LINK_DELETED", () => {
       const events = [at.PLACES_LINK_BLOCKED, at.PLACES_LINK_DELETED];
       events.forEach(event => {
         const oldState = {rows: [{url: "foo.com"}, {url: "bar.com"}]};
         const action = {type: event, data: {url: "bar.com"}};
         const nextState = TopSites(oldState, action);
         assert.deepEqual(nextState.rows, [{url: "foo.com"}]);
       });
@@ -174,16 +183,80 @@ describe("Reducers", () => {
     });
     it("should return inital state on DELETE_HISTORY_URL", () => {
       const action = {type: at.DELETE_HISTORY_URL};
       const nextState = Dialog(INITIAL_STATE.Dialog, action);
 
       assert.deepEqual(INITIAL_STATE.Dialog, nextState);
     });
   });
+  describe("Sections", () => {
+    let oldState;
+
+    beforeEach(() => {
+      oldState = new Array(5).fill(null).map((v, i) => ({
+        id: `foo_bar_${i}`,
+        title: `Foo Bar ${i}`,
+        initialized: false,
+        rows: [{url: "www.foo.bar"}, {url: "www.other.url"}]
+      }));
+    });
+
+    it("should return INITIAL_STATE by default", () => {
+      assert.equal(INITIAL_STATE.Sections, Sections(undefined, {type: "non_existent"}));
+    });
+    it("should remove the correct section on SECTION_DEREGISTER", () => {
+      const newState = Sections(oldState, {type: at.SECTION_DEREGISTER, data: "foo_bar_2"});
+      assert.lengthOf(newState, 4);
+      const expectedNewState = oldState.splice(2, 1) && oldState;
+      assert.deepEqual(newState, expectedNewState);
+    });
+    it("should add a section on SECTION_REGISTER if it doesn't already exist", () => {
+      const action = {type: at.SECTION_REGISTER, data: {id: "foo_bar_5", title: "Foo Bar 5"}};
+      const newState = Sections(oldState, action);
+      assert.lengthOf(newState, 6);
+      const insertedSection = newState.find(section => section.id === "foo_bar_5");
+      assert.propertyVal(insertedSection, "title", action.data.title);
+    });
+    it("should set newSection.rows === [] if no rows are provided on SECTION_REGISTER", () => {
+      const action = {type: at.SECTION_REGISTER, data: {id: "foo_bar_5", title: "Foo Bar 5"}};
+      const newState = Sections(oldState, action);
+      const insertedSection = newState.find(section => section.id === "foo_bar_5");
+      assert.deepEqual(insertedSection.rows, []);
+    });
+    it("should update a section on SECTION_REGISTER if it already exists", () => {
+      const NEW_TITLE = "New Title";
+      const action = {type: at.SECTION_REGISTER, data: {id: "foo_bar_2", title: NEW_TITLE}};
+      const newState = Sections(oldState, action);
+      assert.lengthOf(newState, 5);
+      const updatedSection = newState.find(section => section.id === "foo_bar_2");
+      assert.ok(updatedSection && updatedSection.title === NEW_TITLE);
+    });
+    it("should have no effect on SECTION_ROWS_UPDATE if the id doesn't exist", () => {
+      const action = {type: at.SECTION_ROWS_UPDATE, data: {id: "fake_id", data: "fake_data"}};
+      const newState = Sections(oldState, action);
+      assert.deepEqual(oldState, newState);
+    });
+    it("should update the section rows with the correct data on SECTION_ROWS_UPDATE", () => {
+      const FAKE_DATA = ["some", "fake", "data"];
+      const action = {type: at.SECTION_ROWS_UPDATE, data: {id: "foo_bar_2", rows: FAKE_DATA}};
+      const newState = Sections(oldState, action);
+      const updatedSection = newState.find(section => section.id === "foo_bar_2");
+      assert.equal(updatedSection.rows, FAKE_DATA);
+    });
+    it("should remove blocked and deleted urls from all rows in all sections", () => {
+      const blockAction = {type: at.PLACES_LINK_BLOCKED, data: {url: "www.foo.bar"}};
+      const deleteAction = {type: at.PLACES_LINK_DELETED, data: {url: "www.foo.bar"}};
+      const newBlockState = Sections(oldState, blockAction);
+      const newDeleteState = Sections(oldState, deleteAction);
+      newBlockState.concat(newDeleteState).forEach(section => {
+        assert.deepEqual(section.rows, [{url: "www.other.url"}]);
+      });
+    });
+  });
   describe("#insertPinned", () => {
     let links;
 
     beforeEach(() => {
       links =  new Array(12).fill(null).map((v, i) => ({url: `site${i}.com`}));
     });
 
     it("should place pinned links where they belong", () => {
@@ -239,9 +312,28 @@ describe("Reducers", () => {
       assert.notProperty(result[2], "pinIndex");
     });
     it("should handle a link present in both the links and pinned list", () => {
       const pinned = [links[7]];
       const result = insertPinned(links, pinned);
       assert.equal(links.length, result.length);
     });
   });
+  describe("Snippets", () => {
+    it("should return INITIAL_STATE by default", () => {
+      assert.equal(Snippets(undefined, {type: "some_action"}), INITIAL_STATE.Snippets);
+    });
+    it("should set initialized to true on a SNIPPETS_DATA action", () => {
+      const state = Snippets(undefined, {type: at.SNIPPETS_DATA, data: {}});
+      assert.isTrue(state.initialized);
+    });
+    it("should set the snippet data on a SNIPPETS_DATA action", () => {
+      const data = {snippetsURL: "foo.com", version: 4};
+      const state = Snippets(undefined, {type: at.SNIPPETS_DATA, data});
+      assert.propertyVal(state, "snippetsURL", data.snippetsURL);
+      assert.propertyVal(state, "version", data.version);
+    });
+    it("should reset to the initial state on a SNIPPETS_RESET action", () => {
+      const state = Snippets({initalized: true, foo: "bar"}, {type: at.SNIPPETS_RESET});
+      assert.equal(state, INITIAL_STATE.Snippets);
+    });
+  });
 });
--- a/browser/extensions/activity-stream/test/unit/lib/ActivityStream.test.js
+++ b/browser/extensions/activity-stream/test/unit/lib/ActivityStream.test.js
@@ -1,27 +1,31 @@
 const injector = require("inject!lib/ActivityStream.jsm");
 
 const REASON_ADDON_UNINSTALL = 6;
 
 describe("ActivityStream", () => {
   let sandbox;
   let as;
   let ActivityStream;
+  let SECTIONS;
   function Fake() {}
 
   beforeEach(() => {
     sandbox = sinon.sandbox.create();
-    ({ActivityStream} = injector({
+    ({ActivityStream, SECTIONS} = injector({
       "lib/LocalizationFeed.jsm": {LocalizationFeed: Fake},
       "lib/NewTabInit.jsm": {NewTabInit: Fake},
       "lib/PlacesFeed.jsm": {PlacesFeed: Fake},
       "lib/TelemetryFeed.jsm": {TelemetryFeed: Fake},
       "lib/TopSitesFeed.jsm": {TopSitesFeed: Fake},
-      "lib/PrefsFeed.jsm": {PrefsFeed: Fake}
+      "lib/PrefsFeed.jsm": {PrefsFeed: Fake},
+      "lib/SnippetsFeed.jsm": {SnippetsFeed: Fake},
+      "lib/TopStoriesFeed.jsm": {TopStoriesFeed: Fake},
+      "lib/SystemTickFeed.jsm": {SystemTickFeed: Fake}
     }));
     as = new ActivityStream();
     sandbox.stub(as.store, "init");
     sandbox.stub(as.store, "uninit");
     sandbox.stub(as._defaultPrefs, "init");
     sandbox.stub(as._defaultPrefs, "reset");
   });
 
@@ -101,10 +105,26 @@ describe("ActivityStream", () => {
     it("should create a Telemetry feed", () => {
       const feed = as.feeds.get("feeds.telemetry")();
       assert.instanceOf(feed, Fake);
     });
     it("should create a Prefs feed", () => {
       const feed = as.feeds.get("feeds.prefs")();
       assert.instanceOf(feed, Fake);
     });
+    it("should create a section feed for each section in SECTIONS", () => {
+      // If new sections are added, their feeds will have to be added to the
+      // list of injected feeds above for this test to pass
+      SECTIONS.forEach((value, key) => {
+        const feed = as.feeds.get(`feeds.section.${key}`)();
+        assert.instanceOf(feed, Fake);
+      });
+    });
+    it("should create a Snippets feed", () => {
+      const feed = as.feeds.get("feeds.snippets")();
+      assert.instanceOf(feed, Fake);
+    });
+    it("should create a SystemTick feed", () => {
+      const feed = as.feeds.get("feeds.systemtick")();
+      assert.instanceOf(feed, Fake);
+    });
   });
 });
--- a/browser/extensions/activity-stream/test/unit/lib/PlacesFeed.test.js
+++ b/browser/extensions/activity-stream/test/unit/lib/PlacesFeed.test.js
@@ -23,16 +23,17 @@ describe("PlacesFeed", () => {
         deleteHistoryEntry: sandbox.spy(),
         blockURL: sandbox.spy()
       }
     });
     globals.set("PlacesUtils", {
       history: {addObserver: sandbox.spy(), removeObserver: sandbox.spy()},
       bookmarks: {TYPE_BOOKMARK, addObserver: sandbox.spy(), removeObserver: sandbox.spy()}
     });
+    globals.set("Pocket", {savePage: sandbox.spy()});
     global.Components.classes["@mozilla.org/browser/nav-history-service;1"] = {
       getService() {
         return global.PlacesUtils.history;
       }
     };
     global.Components.classes["@mozilla.org/browser/nav-bookmarks-service;1"] = {
       getService() {
         return global.PlacesUtils.bookmarks;
@@ -93,16 +94,20 @@ describe("PlacesFeed", () => {
     it("should delete a bookmark on DELETE_BOOKMARK_BY_ID", () => {
       feed.onAction({type: at.DELETE_BOOKMARK_BY_ID, data: "g123kd"});
       assert.calledWith(global.NewTabUtils.activityStreamLinks.deleteBookmark, "g123kd");
     });
     it("should delete a history entry on DELETE_HISTORY_URL", () => {
       feed.onAction({type: at.DELETE_HISTORY_URL, data: "guava.com"});
       assert.calledWith(global.NewTabUtils.activityStreamLinks.deleteHistoryEntry, "guava.com");
     });
+    it("should save to Pocket on SAVE_TO_POCKET", () => {
+      feed.onAction({type: at.SAVE_TO_POCKET, data: {site: {url: "raspberry.com", title: "raspberry"}}, _target: {browser: {}}});
+      assert.calledWith(global.Pocket.savePage, {}, "raspberry.com", "raspberry");
+    });
   });
 
   describe("#observe", () => {
     it("should dispatch a PLACES_LINK_BLOCKED action with the url of the blocked link", () => {
       feed.observe(null, BLOCKED_EVENT, "foo123.com");
       assert.equal(feed.store.dispatch.firstCall.args[0].type, at.PLACES_LINK_BLOCKED);
       assert.deepEqual(feed.store.dispatch.firstCall.args[0].data, {url: "foo123.com"});
     });
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/test/unit/lib/SnippetsFeed.test.js
@@ -0,0 +1,60 @@
+const {SnippetsFeed} = require("lib/SnippetsFeed.jsm");
+const {actionTypes: at, actionCreators: ac} = require("common/Actions.jsm");
+
+describe("SnippetsFeed", () => {
+  let sandbox;
+  beforeEach(() => {
+    sandbox = sinon.sandbox.create();
+  });
+  afterEach(() => {
+    sandbox.restore();
+  });
+  it("should dispatch the right version and snippetsURL on INIT", () => {
+    const url = "foo.com/%STARTPAGE_VERSION%";
+    sandbox.stub(global.Services.prefs, "getStringPref").returns(url);
+    const feed = new SnippetsFeed();
+    feed.store = {dispatch: sandbox.stub()};
+
+    feed.onAction({type: at.INIT});
+
+    assert.calledWith(feed.store.dispatch, ac.BroadcastToContent({
+      type: at.SNIPPETS_DATA,
+      data: {
+        snippetsURL: "foo.com/4",
+        version: 4
+      }
+    }));
+  });
+  it("should call .init when a FEED_INIT happens for feeds.snippets", () => {
+    const feed = new SnippetsFeed();
+    sandbox.stub(feed, "init");
+    feed.store = {dispatch: sandbox.stub()};
+
+    feed.onAction({type: at.FEED_INIT, data: "feeds.snippets"});
+
+    assert.calledOnce(feed.init);
+  });
+  it("should dispatch a SNIPPETS_RESET on uninit", () => {
+    const feed = new SnippetsFeed();
+    feed.store = {dispatch: sandbox.stub()};
+
+    feed.uninit();
+
+    assert.calledWith(feed.store.dispatch, {type: at.SNIPPETS_RESET});
+  });
+  describe("_onUrlChange", () => {
+    it("should dispatch a new snippetsURL", () => {
+      const url = "boo.com/%STARTPAGE_VERSION%";
+      sandbox.stub(global.Services.prefs, "getStringPref").returns(url);
+      const feed = new SnippetsFeed();
+      feed.store = {dispatch: sandbox.stub()};
+
+      feed._onUrlChange();
+
+      assert.calledWith(feed.store.dispatch, ac.BroadcastToContent({
+        type: at.SNIPPETS_DATA,
+        data: {snippetsURL: "boo.com/4"}
+      }));
+    });
+  });
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/test/unit/lib/SystemTickFeed.test.js
@@ -0,0 +1,41 @@
+"use strict";
+const injector = require("inject!lib/SystemTickFeed.jsm");
+const {actionTypes: at} = require("common/Actions.jsm");
+
+describe("System Tick Feed", () => {
+  let SystemTickFeed;
+  let SYSTEM_TICK_INTERVAL;
+  let instance;
+  let clock;
+
+  beforeEach(() => {
+    clock = sinon.useFakeTimers();
+
+    ({SystemTickFeed, SYSTEM_TICK_INTERVAL} = injector({}));
+    instance = new SystemTickFeed();
+    instance.store = {getState() { return {}; }, dispatch() {}};
+  });
+  afterEach(() => {
+    clock.restore();
+  });
+  it("should create a SystemTickFeed", () => {
+    assert.instanceOf(instance, SystemTickFeed);
+  });
+  it("should fire SYSTEM_TICK events at configured interval", () => {
+    let expectation = sinon.mock(instance.store).expects("dispatch")
+      .twice()
+      .withExactArgs({type: at.SYSTEM_TICK});
+
+    instance.onAction({type: at.INIT});
+    clock.tick(SYSTEM_TICK_INTERVAL * 2);
+    expectation.verify();
+  });
+  it("should not fire SYSTEM_TICK events after UNINIT", () => {
+    let expectation = sinon.mock(instance.store).expects("dispatch")
+      .never();
+
+    instance.onAction({type: at.UNINIT});
+    clock.tick(SYSTEM_TICK_INTERVAL * 2);
+    expectation.verify();
+  });
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/test/unit/lib/TopStoriesFeed.test.js
@@ -0,0 +1,257 @@
+"use strict";
+const injector = require("inject!lib/TopStoriesFeed.jsm");
+const {FakePrefs} = require("test/unit/utils");
+const {actionCreators: ac, actionTypes: at} = require("common/Actions.jsm");
+const {GlobalOverrider} = require("test/unit/utils");
+
+describe("Top Stories Feed", () => {
+  let TopStoriesFeed;
+  let STORIES_UPDATE_TIME;
+  let TOPICS_UPDATE_TIME;
+  let SECTION_ID;
+  let instance;
+  let clock;
+  let globals;
+
+  beforeEach(() => {
+    FakePrefs.prototype.prefs["feeds.section.topstories.options"] = `{
+      "stories_endpoint": "https://somedomain.org/stories?key=$apiKey",
+      "topics_endpoint": "https://somedomain.org/topics?key=$apiKey",
+      "survey_link": "https://www.surveymonkey.com/r/newtabffx",
+      "api_key_pref": "apiKeyPref",
+      "provider_name": "test-provider",
+      "provider_icon": "provider-icon"
+    }`;
+    FakePrefs.prototype.prefs.apiKeyPref = "test-api-key";
+
+    globals = new GlobalOverrider();
+    clock = sinon.useFakeTimers();
+
+    ({TopStoriesFeed, STORIES_UPDATE_TIME, TOPICS_UPDATE_TIME, SECTION_ID} = injector({"lib/ActivityStreamPrefs.jsm": {Prefs: FakePrefs}}));
+    instance = new TopStoriesFeed();
+    instance.store = {getState() { return {}; }, dispatch: sinon.spy()};
+  });
+  afterEach(() => {
+    globals.restore();
+    clock.restore();
+  });
+  describe("#init", () => {
+    it("should create a TopStoriesFeed", () => {
+      assert.instanceOf(instance, TopStoriesFeed);
+    });
+    it("should initialize endpoints based on prefs", () => {
+      instance.onAction({type: at.INIT});
+      assert.equal("https://somedomain.org/stories?key=test-api-key", instance.stories_endpoint);
+      assert.equal("https://somedomain.org/topics?key=test-api-key", instance.topics_endpoint);
+    });
+    it("should register section", () => {
+      const expectedSectionOptions = {
+        id: SECTION_ID,
+        icon: "provider-icon",
+        title: {id: "header_recommended_by", values: {provider: "test-provider"}},
+        rows: [],
+        maxCards: 3,
+        contextMenuOptions: ["SaveToPocket", "Separator", "CheckBookmark", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl"],
+        infoOption: {
+          header: {id: "pocket_feedback_header"},
+          body: {id: "pocket_feedback_body"},
+          link: {
+            href: "https://www.surveymonkey.com/r/newtabffx",
+            id: "pocket_send_feedback"
+          }
+        },
+        emptyState: {
+          message: {id: "empty_state_topstories"},
+          icon: "check"
+        }
+      };
+
+      instance.onAction({type: at.INIT});
+      assert.calledOnce(instance.store.dispatch);
+      assert.propertyVal(instance.store.dispatch.firstCall.args[0], "type", at.SECTION_REGISTER);
+      assert.calledWith(instance.store.dispatch, ac.BroadcastToContent({
+        type: at.SECTION_REGISTER,
+        data: expectedSectionOptions
+      }));
+    });
+    it("should fetch stories on init", () => {
+      instance.fetchStories = sinon.spy();
+      instance.fetchTopics = sinon.spy();
+      instance.onAction({type: at.INIT});
+      assert.calledOnce(instance.fetchStories);
+    });
+    it("should fetch topics on init", () => {
+      instance.fetchStories = sinon.spy();
+      instance.fetchTopics = sinon.spy();
+      instance.onAction({type: at.INIT});
+      assert.calledOnce(instance.fetchTopics);
+    });
+    it("should not fetch if endpoint not configured", () => {
+      let fetchStub = globals.sandbox.stub();
+      globals.set("fetch", fetchStub);
+      FakePrefs.prototype.prefs["feeds.section.topstories.options"] = "{}";
+      instance.init();
+      assert.notCalled(fetchStub);
+    });
+    it("should report error for invalid configuration", () => {
+      globals.sandbox.spy(global.Components.utils, "reportError");
+      FakePrefs.prototype.prefs["feeds.section.topstories.options"] = "invalid";
+      instance.init();
+
+      assert.called(Components.utils.reportError);
+    });
+    it("should report error for missing api key", () => {
+      let fakeServices = {prefs: {getCharPref: sinon.spy()}};
+      globals.set("Services", fakeServices);
+      globals.sandbox.spy(global.Components.utils, "reportError");
+      FakePrefs.prototype.prefs["feeds.section.topstories.options"] = `{
+        "stories_endpoint": "https://somedomain.org/stories?key=$apiKey",
+        "topics_endpoint": "https://somedomain.org/topics?key=$apiKey"
+      }`;
+      instance.init();
+
+      assert.called(Components.utils.reportError);
+    });
+    it("should deregister section", () => {
+      instance.onAction({type: at.UNINIT});
+      assert.calledOnce(instance.store.dispatch);
+      assert.calledWith(instance.store.dispatch, ac.BroadcastToContent({
+        type: at.SECTION_DEREGISTER,
+        data: SECTION_ID
+      }));
+    });
+    it("should initialize on FEED_INIT", () => {
+      instance.init = sinon.spy();
+      instance.onAction({type: at.FEED_INIT, data: "feeds.section.topstories"});
+      assert.calledOnce(instance.init);
+    });
+  });
+  describe("#fetch", () => {
+    it("should fetch stories and send event", async () => {
+      let fetchStub = globals.sandbox.stub();
+      globals.set("fetch", fetchStub);
+      globals.set("NewTabUtils", {blockedLinks: {isBlocked: globals.sandbox.spy()}});
+
+      const response = `{"list": [{"id" : "1",
+        "title": "title",
+        "excerpt": "description",
+        "image_src": "image-url",
+        "dedupe_url": "rec-url",
+        "published_timestamp" : "123"
+      }]}`;
+      const stories = [{
+        "guid": "1",
+        "type": "trending",
+        "title": "title",
+        "description": "description",
+        "image": "image-url",
+        "url": "rec-url",
+        "lastVisitDate": "123"
+      }];
+
+      instance.stories_endpoint = "stories-endpoint";
+      fetchStub.resolves({ok: true, status: 200, text: () => response});
+      await instance.fetchStories();
+
+      assert.calledOnce(fetchStub);
+      assert.calledWithExactly(fetchStub, instance.stories_endpoint);
+      assert.calledOnce(instance.store.dispatch);
+      assert.propertyVal(instance.store.dispatch.firstCall.args[0], "type", at.SECTION_ROWS_UPDATE);
+      assert.deepEqual(instance.store.dispatch.firstCall.args[0].data.id, SECTION_ID);
+      assert.deepEqual(instance.store.dispatch.firstCall.args[0].data.rows, stories);
+    });
+    it("should dispatch events", () => {
+      instance.dispatchUpdateEvent(123, {});
+      assert.calledOnce(instance.store.dispatch);
+    });
+    it("should report error for unexpected stories response", async () => {
+      let fetchStub = globals.sandbox.stub();
+      globals.set("fetch", fetchStub);
+      globals.sandbox.spy(global.Components.utils, "reportError");
+
+      instance.stories_endpoint = "stories-endpoint";
+      fetchStub.resolves({ok: false, status: 400});
+      await instance.fetchStories();
+
+      assert.calledOnce(fetchStub);
+      assert.calledWithExactly(fetchStub, instance.stories_endpoint);
+      assert.notCalled(instance.store.dispatch);
+      assert.called(Components.utils.reportError);
+    });
+    it("should exclude blocked (dismissed) URLs", async () => {
+      let fetchStub = globals.sandbox.stub();
+      globals.set("fetch", fetchStub);
+      globals.set("NewTabUtils", {blockedLinks: {isBlocked: url => url === "blocked"}});
+
+      const response = `{"list": [{"dedupe_url" : "blocked"}, {"dedupe_url" : "not_blocked"}]}`;
+      instance.stories_endpoint = "stories-endpoint";
+      fetchStub.resolves({ok: true, status: 200, text: () => response});
+      await instance.fetchStories();
+
+      assert.calledOnce(instance.store.dispatch);
+      assert.propertyVal(instance.store.dispatch.firstCall.args[0], "type", at.SECTION_ROWS_UPDATE);
+      assert.equal(instance.store.dispatch.firstCall.args[0].data.rows.length, 1);
+      assert.equal(instance.store.dispatch.firstCall.args[0].data.rows[0].url, "not_blocked");
+    });
+    it("should fetch topics and send event", async () => {
+      let fetchStub = globals.sandbox.stub();
+      globals.set("fetch", fetchStub);
+
+      const response = `{"topics": [{"name" : "topic1", "url" : "url-topic1"}, {"name" : "topic2", "url" : "url-topic2"}]}`;
+      const topics = [{
+        "name": "topic1",
+        "url": "url-topic1"
+      }, {
+        "name": "topic2",
+        "url": "url-topic2"
+      }];
+
+      instance.topics_endpoint = "topics-endpoint";
+      fetchStub.resolves({ok: true, status: 200, text: () => response});
+      await instance.fetchTopics();
+
+      assert.calledOnce(fetchStub);
+      assert.calledWithExactly(fetchStub, instance.topics_endpoint);
+      assert.calledOnce(instance.store.dispatch);
+      assert.propertyVal(instance.store.dispatch.firstCall.args[0], "type", at.SECTION_ROWS_UPDATE);
+      assert.deepEqual(instance.store.dispatch.firstCall.args[0].data.id, SECTION_ID);
+      assert.deepEqual(instance.store.dispatch.firstCall.args[0].data.topics, topics);
+    });
+    it("should report error for unexpected topics response", async () => {
+      let fetchStub = globals.sandbox.stub();
+      globals.set("fetch", fetchStub);
+      globals.sandbox.spy(global.Components.utils, "reportError");
+
+      instance.topics_endpoint = "topics-endpoint";
+      fetchStub.resolves({ok: false, status: 400});
+      await instance.fetchTopics();
+
+      assert.calledOnce(fetchStub);
+      assert.calledWithExactly(fetchStub, instance.topics_endpoint);
+      assert.notCalled(instance.store.dispatch);
+      assert.called(Components.utils.reportError);
+    });
+  });
+  describe("#update", () => {
+    it("should fetch stories after update interval", () => {
+      instance.fetchStories = sinon.spy();
+      instance.fetchTopics = sinon.spy();
+      instance.onAction({type: at.SYSTEM_TICK});
+      assert.notCalled(instance.fetchStories);
+
+      clock.tick(STORIES_UPDATE_TIME);
+      instance.onAction({type: at.SYSTEM_TICK});
+      assert.calledOnce(instance.fetchStories);
+    });
+    it("should fetch topics after update interval", () => {
+      instance.fetchStories = sinon.spy();
+      instance.fetchTopics = sinon.spy();
+      instance.onAction({type: at.SYSTEM_TICK});
+      assert.notCalled(instance.fetchTopics);
+
+      clock.tick(TOPICS_UPDATE_TIME);
+      instance.onAction({type: at.SYSTEM_TICK});
+      assert.calledOnce(instance.fetchTopics);
+    });
+  });
+});
--- a/browser/extensions/activity-stream/test/unit/lib/init-store.test.js
+++ b/browser/extensions/activity-stream/test/unit/lib/init-store.test.js
@@ -19,16 +19,26 @@ describe("initStore", () => {
   it("should add a listener for incoming actions", () => {
     assert.calledWith(global.addMessageListener, initStore.INCOMING_MESSAGE_NAME);
     const callback = global.addMessageListener.firstCall.args[1];
     globals.sandbox.spy(store, "dispatch");
     const message = {name: initStore.INCOMING_MESSAGE_NAME, data: {type: "FOO"}};
     callback(message);
     assert.calledWith(store.dispatch, message.data);
   });
+  it("should log errors from failed messages", () => {
+    const callback = global.addMessageListener.firstCall.args[1];
+    globals.sandbox.stub(global.console, "error");
+    globals.sandbox.stub(store, "dispatch").throws(Error("failed"));
+
+    const message = {name: initStore.INCOMING_MESSAGE_NAME, data: {type: "FOO"}};
+    callback(message);
+
+    assert.calledOnce(global.console.error);
+  });
   it("should replace the state if a MERGE_STORE_ACTION is dispatched", () => {
     store.dispatch({type: initStore.MERGE_STORE_ACTION, data: {number: 42}});
     assert.deepEqual(store.getState(), {number: 42});
   });
   it("should send out SendToMain ations", () => {
     const action = ac.SendToMain({type: "FOO"});
     store.dispatch(action);
     assert.calledWith(global.sendAsyncMessage, initStore.OUTGOING_MESSAGE_NAME, action);
--- a/browser/extensions/activity-stream/test/unit/unit-entry.js
+++ b/browser/extensions/activity-stream/test/unit/unit-entry.js
@@ -24,26 +24,29 @@ overrider.set({
   },
   // eslint-disable-next-line object-shorthand
   ContentSearchUIController: function() {}, // NB: This is a function/constructor
   dump() {},
   fetch() {},
   Preferences: FakePrefs,
   Services: {
     locale: {getRequestedLocale() {}},
+    urlFormatter: {formatURL: str => str},
     mm: {
       addMessageListener: (msg, cb) => cb(),
       removeMessageListener() {}
     },
     appShell: {hiddenDOMWindow: {performance: new FakePerformance()}},
     obs: {
       addObserver() {},
       removeObserver() {}
     },
     prefs: {
+      addObserver() {},
+      removeObserver() {},
       getStringPref() {},
       getDefaultBranch() {
         return {
           setBoolPref() {},
           setIntPref() {},
           setStringPref() {},
           clearUserPref() {}
         };