Bug 1382785 - Add Pocket, search delay, and bug fixes to Activity Stream r=Mardak
authorUrsula Sarracini
Thu, 20 Jul 2017 16:59:59 -0400
changeset 418790 eccc723bf425219a3b1f32b6b443971bafadcc51
parent 418789 ec7291c0411c49ff2227cb961083726496ddf01e
child 418791 664f305ee1a5c74f718deca378b64178e0a64ac5
push id7566
push usermtabara@mozilla.com
push dateWed, 02 Aug 2017 08:25:16 +0000
treeherdermozilla-beta@86913f512c3c [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersMardak
bugs1382785
milestone56.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1382785 - Add Pocket, search delay, and bug fixes to Activity Stream r=Mardak 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() {}
         };