Bug ACTIVITY-STREAM: Import activity-stream from 306bd7e2 draft
authorDan Mosedale <dmose@mozilla.org>
Thu, 26 Apr 2018 17:21:50 -0700
changeset 795820 44ab0ba9717ff799a751cdf053c04d8ac8a9bcf5
parent 795819 36a2e8e9d816b8d626105c1ba130a1b7010c5685
child 795821 405890b036d5a61fa5c183f082d4622bb65b0b46
push id110091
push userbmo:dmose@mozilla.org
push dateWed, 16 May 2018 16:58:05 +0000
milestone62.0a1
Bug ACTIVITY-STREAM: Import activity-stream from 306bd7e2
browser/extensions/activity-stream/package.json
browser/extensions/activity-stream/system-addon/content-src/.eslintrc.js
browser/extensions/activity-stream/system-addon/content-src/activity-stream-prerender.jsx
browser/extensions/activity-stream/system-addon/content-src/activity-stream.jsx
browser/extensions/activity-stream/system-addon/content-src/components/Base/Base.jsx
browser/extensions/activity-stream/system-addon/content-src/components/Base/_Base.scss
browser/extensions/activity-stream/system-addon/content-src/components/Card/Card.jsx
browser/extensions/activity-stream/system-addon/content-src/components/Card/_Card.scss
browser/extensions/activity-stream/system-addon/content-src/components/Card/types.js
browser/extensions/activity-stream/system-addon/content-src/components/CollapsibleSection/CollapsibleSection.jsx
browser/extensions/activity-stream/system-addon/content-src/components/CollapsibleSection/_CollapsibleSection.scss
browser/extensions/activity-stream/system-addon/content-src/components/ComponentPerfTimer/ComponentPerfTimer.jsx
browser/extensions/activity-stream/system-addon/content-src/components/ConfirmDialog/ConfirmDialog.jsx
browser/extensions/activity-stream/system-addon/content-src/components/ConfirmDialog/_ConfirmDialog.scss
browser/extensions/activity-stream/system-addon/content-src/components/ContextMenu/ContextMenu.jsx
browser/extensions/activity-stream/system-addon/content-src/components/ContextMenu/_ContextMenu.scss
browser/extensions/activity-stream/system-addon/content-src/components/ErrorBoundary/ErrorBoundary.jsx
browser/extensions/activity-stream/system-addon/content-src/components/ErrorBoundary/_ErrorBoundary.scss
browser/extensions/activity-stream/system-addon/content-src/components/LinkMenu/LinkMenu.jsx
browser/extensions/activity-stream/system-addon/content-src/components/ManualMigration/ManualMigration.jsx
browser/extensions/activity-stream/system-addon/content-src/components/ManualMigration/_ManualMigration.scss
browser/extensions/activity-stream/system-addon/content-src/components/MessageCenterAdmin/MessageCenterAdmin.jsx
browser/extensions/activity-stream/system-addon/content-src/components/MessageCenterAdmin/MessageCenterAdmin.scss
browser/extensions/activity-stream/system-addon/content-src/components/Search/Search.jsx
browser/extensions/activity-stream/system-addon/content-src/components/Search/_Search.scss
browser/extensions/activity-stream/system-addon/content-src/components/SectionMenu/SectionMenu.jsx
browser/extensions/activity-stream/system-addon/content-src/components/Sections/Sections.jsx
browser/extensions/activity-stream/system-addon/content-src/components/Sections/_Sections.scss
browser/extensions/activity-stream/system-addon/content-src/components/TopSites/TopSite.jsx
browser/extensions/activity-stream/system-addon/content-src/components/TopSites/TopSiteForm.jsx
browser/extensions/activity-stream/system-addon/content-src/components/TopSites/TopSiteFormInput.jsx
browser/extensions/activity-stream/system-addon/content-src/components/TopSites/TopSites.jsx
browser/extensions/activity-stream/system-addon/content-src/components/TopSites/TopSitesConstants.js
browser/extensions/activity-stream/system-addon/content-src/components/TopSites/_TopSites.scss
browser/extensions/activity-stream/system-addon/content-src/components/Topics/Topics.jsx
browser/extensions/activity-stream/system-addon/content-src/components/Topics/_Topics.scss
browser/extensions/activity-stream/system-addon/content-src/lib/constants.js
browser/extensions/activity-stream/system-addon/content-src/lib/detect-user-session-start.js
browser/extensions/activity-stream/system-addon/content-src/lib/init-store.js
browser/extensions/activity-stream/system-addon/content-src/lib/link-menu-options.js
browser/extensions/activity-stream/system-addon/content-src/lib/section-menu-options.js
browser/extensions/activity-stream/system-addon/content-src/lib/snippets.js
browser/extensions/activity-stream/system-addon/content-src/message-center/components/Button.jsx
browser/extensions/activity-stream/system-addon/content-src/message-center/components/SnippetBase.jsx
browser/extensions/activity-stream/system-addon/content-src/message-center/message-center-content.jsx
browser/extensions/activity-stream/system-addon/content-src/message-center/templates/SimpleSnippet.jsx
browser/extensions/activity-stream/system-addon/content-src/styles/_activity-stream.scss
browser/extensions/activity-stream/system-addon/content-src/styles/_icons.scss
browser/extensions/activity-stream/system-addon/content-src/styles/_normalize.scss
browser/extensions/activity-stream/system-addon/content-src/styles/_theme.scss
browser/extensions/activity-stream/system-addon/content-src/styles/_variables.scss
browser/extensions/activity-stream/system-addon/content-src/styles/activity-stream-linux.scss
browser/extensions/activity-stream/system-addon/content-src/styles/activity-stream-mac.scss
browser/extensions/activity-stream/system-addon/content-src/styles/activity-stream-windows.scss
browser/extensions/activity-stream/webpack.prerender.config.js
browser/extensions/activity-stream/webpack.system-addon.config.js
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/package.json
@@ -0,0 +1,136 @@
+{
+  "name": "activity-streams",
+  "description": "A rich visual history feed and a reimagined home page make it easier than ever to find exactly what you're looking for in Firefox.\n\nLearn more about this Test Pilot experiment at https://testpilot.firefox.com/.",
+  "version": "1.14.3",
+  "author": "Mozilla (https://mozilla.org/)",
+  "bugs": {
+    "url": "https://github.com/mozilla/activity-stream/issues"
+  },
+  "dependencies": {
+    "prop-types": "15.6.0",
+    "react": "16.2.0",
+    "react-dom": "16.2.0",
+    "react-intl": "2.4.0",
+    "react-redux": "5.0.6",
+    "redux": "3.6.0"
+  },
+  "devDependencies": {
+    "@octokit/rest": "14.0.8",
+    "babel-core": "6.26.0",
+    "babel-loader": "7.1.2",
+    "babel-plugin-jsm-to-commonjs": "0.4.0",
+    "babel-plugin-jsm-to-esmodules": "0.4.0",
+    "babel-plugin-transform-async-to-module-method": "6.24.1",
+    "babel-plugin-transform-es2015-modules-commonjs": "6.26.0",
+    "babel-preset-react": "6.24.1",
+    "chai": "4.1.2",
+    "co-task": "1.0.0",
+    "cpx": "1.5.0",
+    "enzyme": "3.3.0",
+    "enzyme-adapter-react-16": "1.1.1",
+    "eslint": "4.17.0",
+    "eslint-plugin-import": "2.8.0",
+    "eslint-plugin-json": "1.2.0",
+    "eslint-plugin-mozilla": "0.8.1",
+    "eslint-plugin-no-unsanitized": "2.0.2",
+    "eslint-plugin-promise": "3.6.0",
+    "eslint-plugin-react": "7.6.1",
+    "eslint-watch": "3.1.3",
+    "husky": "0.14.3",
+    "istanbul-instrumenter-loader": "0.2.0",
+    "joi-browser": "13.0.1",
+    "karma": "1.7.1",
+    "karma-chai": "0.1.0",
+    "karma-coverage": "1.1.1",
+    "karma-firefox-launcher": "1.1.0",
+    "karma-mocha": "1.3.0",
+    "karma-mocha-reporter": "2.2.5",
+    "karma-sinon": "1.0.5",
+    "karma-sourcemap-loader": "0.3.7",
+    "karma-webpack": "2.0.9",
+    "loader-utils": "0.2.16",
+    "minimist": "1.2.0",
+    "mocha": "5.0.0",
+    "mock-raf": "1.0.0",
+    "node-sass": "4.7.2",
+    "npm-run-all": "4.1.2",
+    "pontoon-to-json": "2.0.0",
+    "react-test-renderer": "16.2.0",
+    "rimraf": "2.6.2",
+    "sass-lint": "1.12.1",
+    "shelljs": "0.8.1",
+    "simple-git": "1.89.0",
+    "sinon": "4.2.2",
+    "webpack": "3.10.0",
+    "yamscripts": "0.0.3"
+  },
+  "engines": {
+    "firefox": ">=45.0 <=*",
+    "//": "when changing node versions, also edit .travis.yml and .nvmrc",
+    "node": "7.*"
+  },
+  "homepage": "https://github.com/mozilla/activity-stream",
+  "keywords": [
+    "mozilla",
+    "firefox",
+    "activity-stream"
+  ],
+  "license": "MPL-2.0",
+  "main": "system-addon/bootstrap.js",
+  "repository": "mozilla/activity-stream",
+  "config": {
+    "mc_dir": "../mozilla-central"
+  },
+  "scripts": {
+    "mochitest": "(cd $npm_package_config_mc_dir && ./mach mochitest browser/extensions/activity-stream/test/functional/mochitest )",
+    "mochitest-debug": "(cd $npm_package_config_mc_dir && ./mach mochitest --jsdebugger browser/extensions/activity-stream/test/functional/mochitest )",
+    "cleanmc": "rimraf $npm_package_config_mc_dir/browser/extensions/activity-stream",
+    "buildmc": "npm-run-all buildmc:*",
+    "prebuildmc": "npm run cleanmc",
+    "buildmc:locales": "pontoon-to-json --src locales --dest system-addon/data",
+    "buildmc:webpack": "webpack --config webpack.system-addon.config.js --display-optimization-bailout",
+    "buildmc:css": "node-sass --source-map true --source-map-contents system-addon/content-src/styles -o system-addon/css",
+    "buildmc:html": "rimraf system-addon/prerendered && webpack --config webpack.prerender.config.js && node ./bin/render-activity-stream-html.js",
+    "buildmc:copy": "cpx \"system-addon/**/{,.}*\" $npm_package_config_mc_dir/browser/extensions/activity-stream",
+    "buildmc:version": "node ./bin/update-version.js $npm_package_config_mc_dir/browser/extensions/activity-stream",
+    "buildmc:copyPingCentre": "cpx \"system-addon/ping-centre/PingCentre.jsm\" $npm_package_config_mc_dir/browser/modules",
+    "buildmc:rmextra": "rimraf \"$npm_package_config_mc_dir/browser/extensions/activity-stream/{{,test/unit/}{content-src,ping-centre},data/locales.json}\"",
+    "packagemc": "npm-run-all packagemc:*",
+    "prepackagemc": "npm run cleanmc",
+    "packagemc:build": "npm run buildmc:webpack && npm run buildmc:css && npm run buildmc:locales",
+    "packagemc:copy": "cpx \"system-addon/**\" dist/system-addon",
+    "packagemc:version": "node ./bin/update-version.js dist/system-addon",
+    "packagemc:process": "node ./bin/process-system-addon-for-package.js dist/system-addon",
+    "packagemc:xpi": "(cd dist/system-addon && zip -r ../activity-stream-system-addon.xpi * )",
+    "startmc": "npm-run-all --parallel startmc:*",
+    "prestartmc": "npm run prebuildmc",
+    "startmc:copy": "npm run buildmc:copy -- -w",
+    "startmc:copyPingCentre": "npm run buildmc:copyPingCentre -- -w",
+    "startmc:webpack": "npm run buildmc:webpack -- -w",
+    "startmc:css": "npm run buildmc:css && npm run buildmc:css -- -w",
+    "importmc": "cpx \"$npm_package_config_mc_dir/browser/extensions/activity-stream/**/*\" system-addon/",
+    "testmc": "npm-run-all testmc:*",
+    "testmc:lint": "npm run lint",
+    "testmc:build": "npm run buildmc:webpack && npm run buildmc:locales",
+    "testmc:unit": "karma start karma.mc.config.js || (cat logs/coverage/system-addon/text.txt && exit 2)",
+    "posttestmc": "cat logs/coverage/system-addon/text-summary.txt",
+    "tddmc": "karma start karma.mc.config.js --tdd",
+    "debugcoverage": "open logs/coverage/system-addon/report-html/index.html",
+    "lint": "npm-run-all lint:*",
+    "lint:eslint": "esw --ext=.js,.jsm,.json,.jsx .",
+    "lint:sasslint": "sass-lint -v -q",
+    "strings-export": "node ./bin/strings-export.js ../activity-stream-l10n",
+    "strings-import": "node ./bin/strings-import.js ../activity-stream-l10n",
+    "test": "npm run testmc",
+    "tdd": "npm run tddmc",
+    "prepush": "npm run lint && npm run yamscripts",
+    "help": "yamscripts help",
+    "yamscripts": "yamscripts compile",
+    "__": "# NOTE: THESE SCRIPTS ARE COMPILED!!! EDIT yamscripts.yml instead!!!"
+  },
+  "title": "Activity Stream",
+  "permissions": {
+    "multiprocess": true,
+    "private-browsing": true
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/.eslintrc.js
@@ -0,0 +1,5 @@
+module.exports = {
+  rules: {
+    "import/no-commonjs": 2
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/activity-stream-prerender.jsx
@@ -0,0 +1,45 @@
+import {INITIAL_STATE, reducers} from "common/Reducers.jsm";
+import {actionTypes as at} from "common/Actions.jsm";
+import {Base} from "content-src/components/Base/Base";
+import {initStore} from "content-src/lib/init-store";
+import {PrerenderData} from "common/PrerenderData.jsm";
+import {Provider} from "react-redux";
+import React from "react";
+import ReactDOMServer from "react-dom/server";
+
+/**
+ * prerenderStore - Generate a store with the initial state required for a prerendered page
+ *
+ * @return {obj}         A store
+ */
+export function prerenderStore() {
+  const store = initStore(reducers, INITIAL_STATE);
+  store.dispatch({type: at.PREFS_INITIAL_VALUES, data: PrerenderData.initialPrefs});
+  PrerenderData.initialSections.forEach(data => store.dispatch({type: at.SECTION_REGISTER, data}));
+  return store;
+}
+
+export function prerender(locale, strings,
+                          renderToString = ReactDOMServer.renderToString) {
+  const store = prerenderStore();
+
+  const html = renderToString(
+    <Provider store={store}>
+      <Base
+        isPrerendered={true}
+        locale={locale}
+        strings={strings} />
+    </Provider>);
+
+  // If this happens, it means pre-rendering is effectively disabled, so we
+  // need to sound the alarms:
+  if (!html || !html.length) {
+    throw new Error("no HTML returned");
+  }
+
+  return {
+    html,
+    state: store.getState(),
+    store
+  };
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/activity-stream.jsx
@@ -0,0 +1,29 @@
+import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
+import {addSnippetsSubscriber} from "content-src/lib/snippets";
+import {Base} from "content-src/components/Base/Base";
+import {DetectUserSessionStart} from "content-src/lib/detect-user-session-start";
+import {initStore} from "content-src/lib/init-store";
+import {Provider} from "react-redux";
+import React from "react";
+import ReactDOM from "react-dom";
+import {reducers} from "common/Reducers.jsm";
+
+const store = initStore(reducers, global.gActivityStreamPrerenderedState);
+
+new DetectUserSessionStart(store).sendEventOrAddListener();
+
+// If we are starting in a prerendered state, we must wait until the first render
+// to request state rehydration (see Base.jsx). If we are NOT in a prerendered state,
+// we can request it immedately.
+if (!global.gActivityStreamPrerenderedState) {
+  store.dispatch(ac.AlsoToMain({type: at.NEW_TAB_STATE_REQUEST}));
+}
+
+ReactDOM.hydrate(<Provider store={store}>
+  <Base
+    isPrerendered={!!global.gActivityStreamPrerenderedState}
+    locale={global.document.documentElement.lang}
+    strings={global.gActivityStreamStrings} />
+</Provider>, document.getElementById("root"));
+
+addSnippetsSubscriber(store);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/Base/Base.jsx
@@ -0,0 +1,144 @@
+import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
+import {addLocaleData, injectIntl, IntlProvider} from "react-intl";
+import {ConfirmDialog} from "content-src/components/ConfirmDialog/ConfirmDialog";
+import {connect} from "react-redux";
+import {ErrorBoundary} from "content-src/components/ErrorBoundary/ErrorBoundary";
+import {ManualMigration} from "content-src/components/ManualMigration/ManualMigration";
+import {MessageCenterAdmin} from "content-src/components/MessageCenterAdmin/MessageCenterAdmin";
+import {PrerenderData} from "common/PrerenderData.jsm";
+import React from "react";
+import {Search} from "content-src/components/Search/Search";
+import {Sections} from "content-src/components/Sections/Sections";
+
+const PrefsButton = injectIntl(props => (
+  <div className="prefs-button">
+    <button className="icon icon-settings" onClick={props.onClick} title={props.intl.formatMessage({id: "settings_pane_button_label"})} />
+  </div>
+));
+
+// 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(locale) {
+  addLocaleData([{locale, parentLocale: "en"}]);
+}
+
+export class _Base extends React.PureComponent {
+  componentWillMount() {
+    const {App, locale, Theme} = this.props;
+    if (Theme.className) {
+      this.updateTheme(Theme);
+    }
+    this.sendNewTabRehydrated(App);
+    addLocaleDataForReactIntl(locale);
+  }
+
+  componentDidMount() {
+    // Request state AFTER the first render to ensure we don't cause the
+    // prerendered DOM to be unmounted. Otherwise, NEW_TAB_STATE_REQUEST is
+    // dispatched right after the store is ready.
+    if (this.props.isPrerendered) {
+      this.props.dispatch(ac.AlsoToMain({type: at.NEW_TAB_STATE_REQUEST}));
+      this.props.dispatch(ac.AlsoToMain({type: at.PAGE_PRERENDERED}));
+    }
+  }
+
+  componentWillUnmount() {
+    this.updateTheme({className: ""});
+  }
+
+  componentWillUpdate({App, Theme}) {
+    this.updateTheme(Theme);
+    this.sendNewTabRehydrated(App);
+  }
+
+  updateTheme(Theme) {
+    const bodyClassName = [
+      "activity-stream",
+      Theme.className
+    ].filter(v => v).join(" ");
+    global.document.body.className = bodyClassName;
+  }
+
+  // The NEW_TAB_REHYDRATED event is used to inform feeds that their
+  // data has been consumed e.g. for counting the number of tabs that
+  // have rendered that data.
+  sendNewTabRehydrated(App) {
+    if (App && App.initialized && !this.renderNotified) {
+      this.props.dispatch(ac.AlsoToMain({type: at.NEW_TAB_REHYDRATED, data: {}}));
+      this.renderNotified = true;
+    }
+  }
+
+  render() {
+    const {props} = this;
+    const {App, locale, strings} = props;
+    const {initialized} = App;
+
+    if (props.Prefs.values.messageCenterExperimentEnabled && window.location.hash === "#message-center-admin") {
+      return (<MessageCenterAdmin />);
+    }
+
+    if (!props.isPrerendered && !initialized) {
+      return null;
+    }
+
+    return (<IntlProvider locale={locale} messages={strings}>
+        <ErrorBoundary className="base-content-fallback">
+          <BaseContent {...this.props} />
+        </ErrorBoundary>
+      </IntlProvider>);
+  }
+}
+
+export class BaseContent extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.openPreferences = this.openPreferences.bind(this);
+  }
+
+  openPreferences() {
+    this.props.dispatch(ac.OnlyToMain({type: at.SETTINGS_OPEN}));
+    this.props.dispatch(ac.UserEvent({event: "OPEN_NEWTAB_PREFS"}));
+  }
+
+  render() {
+    const {props} = this;
+    const {App} = props;
+    const {initialized} = App;
+    const prefs = props.Prefs.values;
+
+    const shouldBeFixedToTop = PrerenderData.arePrefsValid(name => prefs[name]);
+
+    const outerClassName = [
+      "outer-wrapper",
+      shouldBeFixedToTop && "fixed-to-top",
+      prefs.enableWideLayout ? "wide-layout-enabled" : "wide-layout-disabled"
+    ].filter(v => v).join(" ");
+
+    return (
+        <div className={outerClassName}>
+          <main>
+            {prefs.showSearch &&
+              <div className="non-collapsible-section">
+                <ErrorBoundary>
+                  <Search />
+                </ErrorBoundary>
+              </div>
+            }
+            <div className={`body-wrapper${(initialized ? " on" : "")}`}>
+              {!prefs.migrationExpired &&
+                <div className="non-collapsible-section">
+                  <ManualMigration />
+                </div>
+                }
+              <Sections />
+              <PrefsButton onClick={this.openPreferences} />
+            </div>
+            <ConfirmDialog />
+          </main>
+        </div>);
+  }
+}
+
+export const Base = connect(state => ({App: state.App, Prefs: state.Prefs, Theme: state.Theme}))(_Base);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/Base/_Base.scss
@@ -0,0 +1,100 @@
+.outer-wrapper {
+  color: var(--newtab-text-primary-color);
+  display: flex;
+  flex-grow: 1;
+  min-height: 100vh;
+  padding: ($section-spacing + $section-vertical-padding) $base-gutter $base-gutter;
+
+  &.fixed-to-top {
+    display: block;
+  }
+
+  a {
+    color: var(--newtab-link-primary-color);
+  }
+}
+
+main {
+  margin: auto;
+  // Offset the snippets container so things at the bottom of the page are still
+  // visible when snippets / onboarding are visible. Adjust for other spacing.
+  padding-bottom: $snippets-container-height - $section-spacing - $base-gutter;
+  width: $wrapper-default-width;
+
+  @media (min-width: $break-point-small) {
+    width: $wrapper-max-width-small;
+  }
+
+  @media (min-width: $break-point-medium) {
+    width: $wrapper-max-width-medium;
+  }
+
+  @media (min-width: $break-point-large) {
+    width: $wrapper-max-width-large;
+  }
+
+  section {
+    margin-bottom: $section-spacing;
+    position: relative;
+  }
+}
+
+.wide-layout-enabled {
+  main {
+    @media (min-width: $break-point-widest) {
+      width: $wrapper-max-width-widest;
+    }
+  }
+}
+
+.base-content-fallback {
+  // Make the error message be centered against the viewport
+  height: 100vh;
+}
+
+.body-wrapper {
+  // Hide certain elements so the page structure is fixed, e.g., placeholders,
+  // while avoiding flashes of changing content, e.g., icons and text
+  $selectors-to-hide: '
+    .section-title,
+    .sections-list .section:last-of-type,
+    .topic
+  ';
+
+  #{$selectors-to-hide} {
+    opacity: 0;
+  }
+
+  &.on {
+    #{$selectors-to-hide} {
+      opacity: 1;
+    }
+  }
+}
+
+.non-collapsible-section {
+  padding: 0 $section-horizontal-padding;
+}
+
+.prefs-button {
+  button {
+    background-color: transparent;
+    border: 0;
+    cursor: pointer;
+    fill: var(--newtab-icon-primary-color);
+    offset-inline-end: 15px;
+    padding: 15px;
+    position: fixed;
+    top: 15px;
+    z-index: 12001;
+
+    &:hover,
+    &:focus {
+      background-color: var(--newtab-element-hover-color);
+    }
+
+    &:active {
+      background-color: var(--newtab-element-active-color);
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/Card/Card.jsx
@@ -0,0 +1,199 @@
+import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
+import {cardContextTypes} from "./types";
+import {connect} from "react-redux";
+import {FormattedMessage} from "react-intl";
+import {GetPlatformString} from "content-src/lib/link-menu-options";
+import {LinkMenu} from "content-src/components/LinkMenu/LinkMenu";
+import React from "react";
+
+// Keep track of pending image loads to only request once
+const gImageLoading = new Map();
+
+/**
+ * Card component.
+ * Cards are found within a Section component and contain information about a link such
+ * as preview image, page title, page description, and some context about if the page
+ * was visited, bookmarked, trending etc...
+ * Each Section can make an unordered list of Cards which will create one instane of
+ * this class. Each card will then get a context menu which reflects the actions that
+ * can be done on this Card.
+ */
+export class _Card extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.state = {
+      activeCard: null,
+      imageLoaded: false,
+      showContextMenu: false
+    };
+    this.onMenuButtonClick = this.onMenuButtonClick.bind(this);
+    this.onMenuUpdate = this.onMenuUpdate.bind(this);
+    this.onLinkClick = this.onLinkClick.bind(this);
+  }
+
+  /**
+   * Helper to conditionally load an image and update state when it loads.
+   */
+  async maybeLoadImage() {
+    // No need to load if it's already loaded or no image
+    const {image} = this.props.link;
+    if (!this.state.imageLoaded && image) {
+      // Initialize a promise to share a load across multiple card updates
+      if (!gImageLoading.has(image)) {
+        const loaderPromise = new Promise((resolve, reject) => {
+          const loader = new Image();
+          loader.addEventListener("load", resolve);
+          loader.addEventListener("error", reject);
+          loader.src = image;
+        });
+
+        // Save and remove the promise only while it's pending
+        gImageLoading.set(image, loaderPromise);
+        loaderPromise.catch(ex => ex).then(() => gImageLoading.delete(image)).catch();
+      }
+
+      // Wait for the image whether just started loading or reused promise
+      await gImageLoading.get(image);
+
+      // Only update state if we're still waiting to load the original image
+      if (this.props.link.image === image && !this.state.imageLoaded) {
+        this.setState({imageLoaded: true});
+      }
+    }
+  }
+
+  onMenuButtonClick(event) {
+    event.preventDefault();
+    this.setState({
+      activeCard: this.props.index,
+      showContextMenu: true
+    });
+  }
+
+  /**
+   * Report to telemetry additional information about the item.
+   */
+  _getTelemetryInfo() {
+    // Filter out "history" type for being the default
+    if (this.props.link.type !== "history") {
+      return {value: {card_type: this.props.link.type}};
+    }
+
+    return null;
+  }
+
+  onLinkClick(event) {
+    event.preventDefault();
+    if (this.props.link.type === "download") {
+      this.props.dispatch(ac.OnlyToMain({
+        type: at.SHOW_DOWNLOAD_FILE,
+        data: this.props.link
+      }));
+    } else {
+      const {altKey, button, ctrlKey, metaKey, shiftKey} = event;
+      this.props.dispatch(ac.OnlyToMain({
+        type: at.OPEN_LINK,
+        data: Object.assign(this.props.link, {event: {altKey, button, ctrlKey, metaKey, shiftKey}})
+      }));
+    }
+    if (this.props.isWebExtension) {
+      this.props.dispatch(ac.WebExtEvent(at.WEBEXT_CLICK, {
+        source: this.props.eventSource,
+        url: this.props.link.url,
+        action_position: this.props.index
+      }));
+    } else {
+      this.props.dispatch(ac.UserEvent(Object.assign({
+        event: "CLICK",
+        source: this.props.eventSource,
+        action_position: this.props.index
+      }, this._getTelemetryInfo())));
+
+      if (this.props.shouldSendImpressionStats) {
+        this.props.dispatch(ac.ImpressionStats({
+          source: this.props.eventSource,
+          click: 0,
+          tiles: [{id: this.props.link.guid, pos: this.props.index}]
+        }));
+      }
+    }
+  }
+
+  onMenuUpdate(showContextMenu) {
+    this.setState({showContextMenu});
+  }
+
+  componentDidMount() {
+    this.maybeLoadImage();
+  }
+
+  componentDidUpdate() {
+    this.maybeLoadImage();
+  }
+
+  componentWillReceiveProps(nextProps) {
+    // Clear the image state if changing images
+    if (nextProps.link.image !== this.props.link.image) {
+      this.setState({imageLoaded: false});
+    }
+  }
+
+  render() {
+    const {index, link, dispatch, contextMenuOptions, eventSource, shouldSendImpressionStats} = this.props;
+    const {props} = this;
+    const isContextMenuOpen = this.state.showContextMenu && this.state.activeCard === index;
+    // Display "now" as "trending" until we have new strings #3402
+    const {icon, intlID} = cardContextTypes[link.type === "now" ? "trending" : link.type] || {};
+    const hasImage = link.image || link.hasImage;
+    const imageStyle = {backgroundImage: link.image ? `url(${link.image})` : "none"};
+
+    return (<li className={`card-outer${isContextMenuOpen ? " active" : ""}${props.placeholder ? " placeholder" : ""}`}>
+      <a href={link.type === "pocket" ? link.open_url : link.url} onClick={!props.placeholder ? this.onLinkClick : undefined}>
+        <div className="card">
+          {hasImage && <div className="card-preview-image-outer">
+            <div className={`card-preview-image${this.state.imageLoaded ? " loaded" : ""}`} style={imageStyle} />
+          </div>}
+          <div className={`card-details${hasImage ? "" : " no-image"}`}>
+            {link.type === "download" && <div className="card-download-icon icon icon-download-folder" />}
+            {link.type === "download" && <div className="card-host-name alternate"><FormattedMessage id={GetPlatformString(this.props.platform)} /></div>}
+            {link.hostname && <div className="card-host-name">{link.hostname}</div>}
+            <div className={[
+              "card-text",
+              icon ? "" : "no-context",
+              link.description ? "" : "no-description",
+              link.hostname ? "" : "no-host-name",
+              hasImage ? "" : "no-image"
+            ].join(" ")}>
+              <h4 className="card-title" dir="auto">{link.title}</h4>
+              <p className="card-description" dir="auto">{link.description}</p>
+            </div>
+            <div className="card-context">
+              {icon && !link.context && <span className={`card-context-icon icon icon-${icon}`} />}
+              {link.icon && link.context && <span className="card-context-icon icon" style={{backgroundImage: `url('${link.icon}')`}} />}
+              {intlID && !link.context && <div className="card-context-label"><FormattedMessage id={intlID} defaultMessage="Visited" /></div>}
+              {link.context && <div className="card-context-label">{link.context}</div>}
+            </div>
+          </div>
+        </div>
+      </a>
+      {!props.placeholder && <button className="context-menu-button icon"
+        onClick={this.onMenuButtonClick}>
+        <span className="sr-only">{`Open context menu for ${link.title}`}</span>
+      </button>}
+      {isContextMenuOpen &&
+        <LinkMenu
+          dispatch={dispatch}
+          index={index}
+          source={eventSource}
+          onUpdate={this.onMenuUpdate}
+          options={link.contextMenuOptions || contextMenuOptions}
+          site={link}
+          siteInfo={this._getTelemetryInfo()}
+          shouldSendImpressionStats={shouldSendImpressionStats} />
+      }
+   </li>);
+  }
+}
+_Card.defaultProps = {link: {}};
+export const Card = connect(state => ({platform: state.Prefs.values.platform}))(_Card);
+export const PlaceholderCard = () => <Card placeholder={true} />;
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/Card/_Card.scss
@@ -0,0 +1,249 @@
+.card-outer {
+  @include context-menu-button;
+  background: var(--newtab-card-background-color);
+  border-radius: $border-radius;
+  display: inline-block;
+  height: $card-height;
+  margin-inline-end: $base-gutter;
+  position: relative;
+  width: 100%;
+
+  &.placeholder {
+    background: transparent;
+
+    .card {
+      box-shadow: inset $inner-box-shadow;
+    }
+  }
+
+  .card {
+    border-radius: $border-radius;
+    box-shadow: var(--newtab-card-shadow);
+    height: 100%;
+  }
+
+  > a {
+    color: inherit;
+    display: block;
+    height: 100%;
+    outline: none;
+    position: absolute;
+    width: 100%;
+
+    &:-moz-any(.active, :focus) {
+      .card {
+        @include fade-in-card;
+      }
+
+      .card-title {
+        color: var(--newtab-link-primary-color);
+      }
+    }
+  }
+
+  &:-moz-any(:hover, :focus, .active):not(.placeholder) {
+    @include fade-in-card;
+    @include context-menu-button-hover;
+    outline: none;
+
+    .card-title {
+      color: var(--newtab-link-primary-color);
+    }
+
+    .alternate ~ .card-host-name {
+      display: none;
+    }
+
+    .card-host-name.alternate {
+      display: block;
+    }
+  }
+
+  .card-preview-image-outer {
+    border-radius: $border-radius $border-radius 0 0;
+    height: $card-preview-image-height;
+    overflow: hidden;
+    position: relative;
+
+    &::after {
+      border-bottom: 1px solid var(--newtab-card-hairline-color);
+      bottom: 0;
+      content: '';
+      position: absolute;
+      width: 100%;
+    }
+
+    .card-preview-image {
+      background-position: center;
+      background-repeat: no-repeat;
+      background-size: cover;
+      height: 100%;
+      opacity: 0;
+      transition: opacity 1s $photon-easing;
+      width: 100%;
+
+      &.loaded {
+        opacity: 1;
+      }
+    }
+  }
+
+  .card-details {
+    padding: 15px 16px 12px;
+
+    &.no-image {
+      padding-top: 16px;
+    }
+  }
+
+  .card-text {
+    max-height: 4 * $card-text-line-height + $card-title-margin;
+    overflow: hidden;
+
+    &.no-image {
+      max-height: 10 * $card-text-line-height + $card-title-margin;
+    }
+
+    &.no-host-name,
+    &.no-context {
+      max-height: 5 * $card-text-line-height + $card-title-margin;
+    }
+
+    &.no-image.no-host-name,
+    &.no-image.no-context {
+      max-height: 11 * $card-text-line-height + $card-title-margin;
+    }
+
+    &.no-host-name.no-context {
+      max-height: 6 * $card-text-line-height + $card-title-margin;
+    }
+
+    &.no-image.no-host-name.no-context {
+      max-height: 12 * $card-text-line-height + $card-title-margin;
+    }
+
+    &:not(.no-description) .card-title {
+      max-height: 3 * $card-text-line-height;
+      overflow: hidden;
+    }
+  }
+
+  .card-host-name {
+    color: var(--newtab-text-secondary-color);
+    font-size: 10px;
+    overflow: hidden;
+    padding-bottom: 4px;
+    text-overflow: ellipsis;
+    text-transform: uppercase;
+  }
+
+  .card-host-name.alternate { display: none; }
+
+  .card-download-icon {
+    float: inline-end;
+    margin-inline-start: 15px;
+    margin-top: 2px;
+
+    &.icon-download-folder {
+      height: $small-download-folder-icon-size;
+      width: $small-download-folder-icon-size;
+    }
+  }
+
+  .card-title {
+    font-size: 14px;
+    font-weight: 600;
+    line-height: $card-text-line-height;
+    margin: 0 0 $card-title-margin;
+    word-wrap: break-word;
+  }
+
+  .card-description {
+    font-size: 12px;
+    line-height: $card-text-line-height;
+    margin: 0;
+    overflow: hidden;
+    word-wrap: break-word;
+  }
+
+  .card-context {
+    bottom: 0;
+    color: var(--newtab-text-tertiary-color);
+    display: flex;
+    font-size: 11px;
+    left: 0;
+    padding: 9px 16px 9px 14px;
+    position: absolute;
+    right: 0;
+  }
+
+  .card-context-icon {
+    fill: var(--newtab-icon-tertiary-color);
+    height: 22px;
+    margin-inline-end: 6px;
+  }
+
+  .card-context-label {
+    flex-grow: 1;
+    line-height: 22px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+}
+
+.wide-layout-enabled {
+  $line-height: 23px;
+
+  .card-outer {
+    @media (min-width: $break-point-widest) {
+      height: $card-height-large;
+
+      .card-preview-image-outer {
+        height: $card-preview-image-height-large;
+      }
+
+      .card-details {
+        padding: 13px 16px 12px;
+      }
+
+      .card-text {
+        max-height: 6 * $line-height + $card-title-margin;
+      }
+
+      .card-host-name {
+        font-size: 12px;
+        padding-bottom: 5px;
+      }
+
+      .card-download-icon {
+        &.icon-download-folder {
+          height: $large-download-folder-icon-size;
+          width: $large-download-folder-icon-size;
+        }
+      }
+
+      .card-title {
+        font-size: 17px;
+        line-height: $line-height;
+        margin-bottom: 0;
+      }
+
+      .card-text:not(.no-description) {
+        .card-title {
+          max-height: 3 * $line-height;
+        }
+      }
+
+      .card-description {
+        font-size: 15px;
+        line-height: $line-height;
+      }
+
+      .card-context {
+        bottom: 4px;
+        font-size: 14px;
+      }
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/Card/types.js
@@ -0,0 +1,26 @@
+export const cardContextTypes = {
+  history: {
+    intlID: "type_label_visited",
+    icon: "historyItem"
+  },
+  bookmark: {
+    intlID: "type_label_bookmarked",
+    icon: "bookmark-added"
+  },
+  trending: {
+    intlID: "type_label_recommended",
+    icon: "trending"
+  },
+  now: {
+    intlID: "type_label_now",
+    icon: "now"
+  },
+  pocket: {
+    intlID: "type_label_pocket",
+    icon: "pocket"
+  },
+  download: {
+    intlID: "type_label_downloaded",
+    icon: "download"
+  }
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/CollapsibleSection/CollapsibleSection.jsx
@@ -0,0 +1,215 @@
+import {FormattedMessage, injectIntl} from "react-intl";
+import {actionCreators as ac} from "common/Actions.jsm";
+import {ErrorBoundary} from "content-src/components/ErrorBoundary/ErrorBoundary";
+import React from "react";
+import {SectionMenu} from "content-src/components/SectionMenu/SectionMenu";
+import {SectionMenuOptions} from "content-src/lib/section-menu-options";
+
+const VISIBLE = "visible";
+const VISIBILITY_CHANGE_EVENT = "visibilitychange";
+
+function getFormattedMessage(message) {
+  return typeof message === "string" ? <span>{message}</span> : <FormattedMessage {...message} />;
+}
+
+export class Disclaimer extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onAcknowledge = this.onAcknowledge.bind(this);
+  }
+
+  onAcknowledge() {
+    this.props.dispatch(ac.SetPref(this.props.disclaimerPref, false));
+    this.props.dispatch(ac.UserEvent({event: "DISCLAIMER_ACKED", source: this.props.eventSource}));
+  }
+
+  render() {
+    const {disclaimer} = this.props;
+    return (
+      <div className="section-disclaimer">
+          <div className="section-disclaimer-text">
+            {getFormattedMessage(disclaimer.text)}
+            {disclaimer.link &&
+              <a href={disclaimer.link.href} target="_blank" rel="noopener noreferrer">
+                {getFormattedMessage(disclaimer.link.title || disclaimer.link)}
+              </a>
+            }
+          </div>
+
+          <button onClick={this.onAcknowledge}>
+            {getFormattedMessage(disclaimer.button)}
+          </button>
+      </div>
+    );
+  }
+}
+
+export const DisclaimerIntl = injectIntl(Disclaimer);
+
+export class _CollapsibleSection extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onBodyMount = this.onBodyMount.bind(this);
+    this.onHeaderClick = this.onHeaderClick.bind(this);
+    this.onTransitionEnd = this.onTransitionEnd.bind(this);
+    this.enableOrDisableAnimation = this.enableOrDisableAnimation.bind(this);
+    this.onMenuButtonClick = this.onMenuButtonClick.bind(this);
+    this.onMenuButtonMouseEnter = this.onMenuButtonMouseEnter.bind(this);
+    this.onMenuButtonMouseLeave = this.onMenuButtonMouseLeave.bind(this);
+    this.onMenuUpdate = this.onMenuUpdate.bind(this);
+    this.state = {enableAnimation: true, isAnimating: false, menuButtonHover: false, showContextMenu: false};
+  }
+
+  componentWillMount() {
+    this.props.document.addEventListener(VISIBILITY_CHANGE_EVENT, this.enableOrDisableAnimation);
+  }
+
+  componentWillUpdate(nextProps) {
+    // Check if we're about to go from expanded to collapsed
+    if (!this.props.collapsed && nextProps.collapsed) {
+      // This next line forces a layout flush of the section body, which has a
+      // max-height style set, so that the upcoming collapse animation can
+      // animate from that height to the collapsed height. Without this, the
+      // update is coalesced and there's no animation from no-max-height to 0.
+      this.sectionBody.scrollHeight; // eslint-disable-line no-unused-expressions
+    }
+  }
+
+  componentWillUnmount() {
+    this.props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this.enableOrDisableAnimation);
+  }
+
+  enableOrDisableAnimation() {
+    // Only animate the collapse/expand for visible tabs.
+    const visible = this.props.document.visibilityState === VISIBLE;
+    if (this.state.enableAnimation !== visible) {
+      this.setState({enableAnimation: visible});
+    }
+  }
+
+  onBodyMount(node) {
+    this.sectionBody = node;
+  }
+
+  onHeaderClick() {
+    // If this.sectionBody is unset, it means that we're in some sort of error
+    // state, probably displaying the error fallback, so we won't be able to
+    // compute the height, and we don't want to persist the preference.
+    // If props.collapsed is undefined handler shouldn't do anything.
+    if (!this.sectionBody || this.props.collapsed === undefined) {
+      return;
+    }
+
+    // Get the current height of the body so max-height transitions can work
+    this.setState({
+      isAnimating: true,
+      maxHeight: `${this.sectionBody.scrollHeight}px`
+    });
+    const {action, userEvent} = SectionMenuOptions.CheckCollapsed(this.props);
+    this.props.dispatch(action);
+    this.props.dispatch(ac.UserEvent({
+      event: userEvent,
+      source: this.props.source
+    }));
+  }
+
+  onTransitionEnd(event) {
+    // Only update the animating state for our own transition (not a child's)
+    if (event.target === event.currentTarget) {
+      this.setState({isAnimating: false});
+    }
+  }
+
+  renderIcon() {
+    const {icon} = this.props;
+    if (icon && icon.startsWith("moz-extension://")) {
+      return <span className="icon icon-small-spacer" style={{backgroundImage: `url('${icon}')`}} />;
+    }
+    return <span className={`icon icon-small-spacer icon-${icon || "webextension"}`} />;
+  }
+
+  onMenuButtonClick(event) {
+    event.preventDefault();
+    this.setState({showContextMenu: true});
+  }
+
+  onMenuButtonMouseEnter() {
+    this.setState({menuButtonHover: true});
+  }
+
+  onMenuButtonMouseLeave() {
+    this.setState({menuButtonHover: false});
+  }
+
+  onMenuUpdate(showContextMenu) {
+    this.setState({showContextMenu});
+  }
+
+  render() {
+    const isCollapsible = this.props.collapsed !== undefined;
+    const {enableAnimation, isAnimating, maxHeight, menuButtonHover, showContextMenu} = this.state;
+    const {id, eventSource, collapsed, disclaimer, title, extraMenuOptions, showPrefName, privacyNoticeURL, dispatch, isFirst, isLast} = this.props;
+    const disclaimerPref = `section.${id}.showDisclaimer`;
+    const needsDisclaimer = disclaimer && this.props.Prefs.values[disclaimerPref];
+    const active = menuButtonHover || showContextMenu;
+
+    return (
+      <section className={`collapsible-section ${this.props.className}${enableAnimation ? " animation-enabled" : ""}${collapsed ? " collapsed" : ""}${active ? " active" : ""}`}>
+        <div className="section-top-bar">
+          <h3 className="section-title">
+            <span className="click-target" onClick={this.onHeaderClick}>
+              {this.renderIcon()}
+              {getFormattedMessage(title)}
+              {isCollapsible && <span className={`collapsible-arrow icon ${collapsed ? "icon-arrowhead-forward-small" : "icon-arrowhead-down-small"}`} />}
+            </span>
+          </h3>
+          <div>
+            <button
+              className="context-menu-button icon"
+              onClick={this.onMenuButtonClick}
+              onMouseEnter={this.onMenuButtonMouseEnter}
+              onMouseLeave={this.onMenuButtonMouseLeave}>
+              <span className="sr-only">
+                <FormattedMessage id="section_context_menu_button_sr" />
+              </span>
+            </button>
+            {showContextMenu &&
+              <SectionMenu
+                id={id}
+                extraOptions={extraMenuOptions}
+                eventSource={eventSource}
+                showPrefName={showPrefName}
+                privacyNoticeURL={privacyNoticeURL}
+                collapsed={collapsed}
+                onUpdate={this.onMenuUpdate}
+                isFirst={isFirst}
+                isLast={isLast}
+                dispatch={dispatch} />
+            }
+          </div>
+        </div>
+        <ErrorBoundary className="section-body-fallback">
+          <div
+            className={`section-body${isAnimating ? " animating" : ""}`}
+            onTransitionEnd={this.onTransitionEnd}
+            ref={this.onBodyMount}
+            style={isAnimating && !collapsed ? {maxHeight} : null}>
+            {needsDisclaimer && <DisclaimerIntl disclaimerPref={disclaimerPref} disclaimer={disclaimer} eventSource={eventSource} dispatch={this.props.dispatch} />}
+            {this.props.children}
+          </div>
+        </ErrorBoundary>
+      </section>
+    );
+  }
+}
+
+_CollapsibleSection.defaultProps = {
+  document: global.document || {
+    addEventListener: () => {},
+    removeEventListener: () => {},
+    visibilityState: "hidden"
+  },
+  Prefs: {values: {}}
+};
+
+export const CollapsibleSection = injectIntl(_CollapsibleSection);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/CollapsibleSection/_CollapsibleSection.scss
@@ -0,0 +1,166 @@
+.collapsible-section {
+  padding: $section-vertical-padding $section-horizontal-padding;
+  transition-delay: 100ms;
+  transition-duration: 100ms;
+  transition-property: background-color;
+
+  .section-title {
+    font-size: $section-title-font-size;
+    font-weight: bold;
+    margin: 0;
+    text-transform: uppercase;
+
+    span {
+      color: var(--newtab-section-header-text-color);
+      display: inline-block;
+      fill: var(--newtab-section-header-text-color);
+      vertical-align: middle;
+    }
+
+    .click-target {
+      cursor: pointer;
+      vertical-align: top;
+      white-space: nowrap;
+    }
+
+    .collapsible-arrow {
+      margin-inline-start: 8px;
+      margin-top: -1px;
+    }
+  }
+
+  .section-top-bar {
+    height: 19px;
+    margin-bottom: 13px;
+    position: relative;
+
+    .context-menu-button {
+      background: url('chrome://browser/skin/page-action.svg') no-repeat right center;
+      border: 0;
+      cursor: pointer;
+      fill: var(--newtab-section-header-text-color);
+      height: 100%;
+      offset-inline-end: 0;
+      opacity: 0;
+      position: absolute;
+      top: 0;
+      transition-duration: 200ms;
+      transition-property: opacity;
+      width: $context-menu-button-size;
+
+      &:-moz-any(:active, :focus, :hover) {
+        fill: $grey-90;
+        opacity: 1;
+      }
+    }
+
+    .context-menu {
+      top: 16px;
+    }
+
+    @media (max-width: $break-point-widest + $card-width * 1.5) {
+      @include context-menu-open-left;
+    }
+  }
+
+  &:hover,
+  &.active {
+    .section-top-bar {
+      .context-menu-button {
+        opacity: 1;
+      }
+    }
+  }
+
+  &.active {
+    background: var(--newtab-element-hover-color);
+    border-radius: 4px;
+
+    .section-top-bar {
+      .context-menu-button {
+        fill: var(--newtab-section-active-contextmenu-color);
+      }
+    }
+  }
+
+  .section-disclaimer {
+    $max-button-width: 130px;
+    $min-button-height: 26px;
+
+    color: var(--newtab-text-conditional-color);
+    font-size: 13px;
+    margin-bottom: 16px;
+    position: relative;
+
+    .section-disclaimer-text {
+      display: inline-block;
+      min-height: $min-button-height;
+      width: calc(100% - #{$max-button-width});
+
+      @media (max-width: $break-point-medium) {
+        width: $card-width;
+      }
+    }
+
+    a {
+      color: var(--newtab-link-primary-color);
+      font-weight: bold;
+      padding-left: 3px;
+    }
+
+    button {
+      background: var(--newtab-button-secondary-color);
+      border: 1px solid $grey-40;
+      border-radius: 4px;
+      cursor: pointer;
+      margin-top: 2px;
+      max-width: $max-button-width;
+      min-height: $min-button-height;
+      offset-inline-end: 0;
+
+      &:hover:not(.dismiss) {
+        box-shadow: $shadow-primary;
+        transition: box-shadow 150ms;
+      }
+
+      @media (min-width: $break-point-small) {
+        position: absolute;
+      }
+    }
+  }
+
+  .section-body-fallback {
+    height: $card-height;
+  }
+
+  .section-body {
+    // This is so the top sites favicon and card dropshadows don't get clipped during animation:
+    $horizontal-padding: 7px;
+    margin: 0 (-$horizontal-padding);
+    padding: 0 $horizontal-padding;
+
+    &.animating {
+      overflow: hidden;
+      pointer-events: none;
+    }
+  }
+
+  &.animation-enabled {
+    .section-title {
+      .collapsible-arrow {
+        transition: transform 0.5s $photon-easing;
+      }
+    }
+
+    .section-body {
+      transition: max-height 0.5s $photon-easing;
+    }
+  }
+
+  &.collapsed {
+    .section-body {
+      max-height: 0;
+      overflow: hidden;
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/ComponentPerfTimer/ComponentPerfTimer.jsx
@@ -0,0 +1,163 @@
+import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
+import {perfService as perfSvc} from "common/PerfService.jsm";
+import React from "react";
+
+// Currently record only a fixed set of sections. This will prevent data
+// from custom sections from showing up or from topstories.
+const RECORDED_SECTIONS = ["highlights", "topsites"];
+
+export class ComponentPerfTimer extends React.Component {
+  constructor(props) {
+    super(props);
+    // Just for test dependency injection:
+    this.perfSvc = this.props.perfSvc || perfSvc;
+
+    this._sendBadStateEvent = this._sendBadStateEvent.bind(this);
+    this._sendPaintedEvent = this._sendPaintedEvent.bind(this);
+    this._reportMissingData = false;
+    this._timestampHandled = false;
+    this._recordedFirstRender = false;
+  }
+
+  componentDidMount() {
+    if (!RECORDED_SECTIONS.includes(this.props.id)) {
+      return;
+    }
+
+    this._maybeSendPaintedEvent();
+  }
+
+  componentDidUpdate() {
+    if (!RECORDED_SECTIONS.includes(this.props.id)) {
+      return;
+    }
+
+    this._maybeSendPaintedEvent();
+  }
+
+  /**
+   * Call the given callback after the upcoming frame paints.
+   *
+   * @note Both setTimeout and requestAnimationFrame are throttled when the page
+   * is hidden, so this callback may get called up to a second or so after the
+   * requestAnimationFrame "paint" for hidden tabs.
+   *
+   * Newtabs hidden while loading will presumably be fairly rare (other than
+   * preloaded tabs, which we will be filtering out on the server side), so such
+   * cases should get lost in the noise.
+   *
+   * If we decide that it's important to find out when something that's hidden
+   * has "painted", however, another option is to post a message to this window.
+   * That should happen even faster than setTimeout, and, at least as of this
+   * writing, it's not throttled in hidden windows in Firefox.
+   *
+   * @param {Function} callback
+   *
+   * @returns void
+   */
+  _afterFramePaint(callback) {
+    requestAnimationFrame(() => setTimeout(callback, 0));
+  }
+
+  _maybeSendBadStateEvent() {
+    // Follow up bugs:
+    // https://github.com/mozilla/activity-stream/issues/3691
+    if (!this.props.initialized) {
+      // Remember to report back when data is available.
+      this._reportMissingData = true;
+    } else if (this._reportMissingData) {
+      this._reportMissingData = false;
+      // Report how long it took for component to become initialized.
+      this._sendBadStateEvent();
+    }
+  }
+
+  _maybeSendPaintedEvent() {
+    // If we've already handled a timestamp, don't do it again.
+    if (this._timestampHandled || !this.props.initialized) {
+      return;
+    }
+
+    // And if we haven't, we're doing so now, so remember that. Even if
+    // something goes wrong in the callback, we can't try again, as we'd be
+    // sending back the wrong data, and we have to do it here, so that other
+    // calls to this method while waiting for the next frame won't also try to
+    // handle it.
+    this._timestampHandled = true;
+    this._afterFramePaint(this._sendPaintedEvent);
+  }
+
+  /**
+   * Triggered by call to render. Only first call goes through due to
+   * `_recordedFirstRender`.
+   */
+  _ensureFirstRenderTsRecorded() {
+    // Used as t0 for recording how long component took to initialize.
+    if (!this._recordedFirstRender) {
+      this._recordedFirstRender = true;
+      // topsites_first_render_ts, highlights_first_render_ts.
+      const key = `${this.props.id}_first_render_ts`;
+      this.perfSvc.mark(key);
+    }
+  }
+
+  /**
+   * Creates `TELEMETRY_UNDESIRED_EVENT` with timestamp in ms
+   * of how much longer the data took to be ready for display than it would
+   * have been the ideal case.
+   * https://github.com/mozilla/ping-centre/issues/98
+   */
+  _sendBadStateEvent() {
+    // highlights_data_ready_ts, topsites_data_ready_ts.
+    const dataReadyKey = `${this.props.id}_data_ready_ts`;
+    this.perfSvc.mark(dataReadyKey);
+
+    try {
+      const firstRenderKey = `${this.props.id}_first_render_ts`;
+      // value has to be Int32.
+      const value = parseInt(this.perfSvc.getMostRecentAbsMarkStartByName(dataReadyKey) -
+                             this.perfSvc.getMostRecentAbsMarkStartByName(firstRenderKey), 10);
+      this.props.dispatch(ac.OnlyToMain({
+        type: at.SAVE_SESSION_PERF_DATA,
+        // highlights_data_late_by_ms, topsites_data_late_by_ms.
+        data: {[`${this.props.id}_data_late_by_ms`]: value}
+      }));
+    } catch (ex) {
+      // If this failed, it's likely because the `privacy.resistFingerprinting`
+      // pref is true.
+    }
+  }
+
+  _sendPaintedEvent() {
+    // Record first_painted event but only send if topsites.
+    if (this.props.id !== "topsites") {
+      return;
+    }
+
+    // topsites_first_painted_ts.
+    const key = `${this.props.id}_first_painted_ts`;
+    this.perfSvc.mark(key);
+
+    try {
+      const data = {};
+      data[key] = this.perfSvc.getMostRecentAbsMarkStartByName(key);
+
+      this.props.dispatch(ac.OnlyToMain({
+        type: at.SAVE_SESSION_PERF_DATA,
+        data
+      }));
+    } catch (ex) {
+      // If this failed, it's likely because the `privacy.resistFingerprinting`
+      // pref is true.  We should at least not blow up, and should continue
+      // to set this._timestampHandled to avoid going through this again.
+    }
+  }
+
+  render() {
+    if (RECORDED_SECTIONS.includes(this.props.id)) {
+      this._ensureFirstRenderTsRecorded();
+      this._maybeSendBadStateEvent();
+    }
+    return this.props.children;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/ConfirmDialog/ConfirmDialog.jsx
@@ -0,0 +1,78 @@
+import {actionCreators as ac, actionTypes} from "common/Actions.jsm";
+import {connect} from "react-redux";
+import {FormattedMessage} from "react-intl";
+import React from "react";
+
+/**
+ * ConfirmDialog component.
+ * One primary action button, one cancel button.
+ *
+ * Content displayed is controlled by `data` prop the component receives.
+ * Example:
+ * data: {
+ *   // Any sort of data needed to be passed around by actions.
+ *   payload: site.url,
+ *   // Primary button AlsoToMain action.
+ *   action: "DELETE_HISTORY_URL",
+ *   // Primary button USerEvent action.
+ *   userEvent: "DELETE",
+ *   // Array of locale ids to display.
+ *   message_body: ["confirm_history_delete_p1", "confirm_history_delete_notice_p2"],
+ *   // Text for primary button.
+ *   confirm_button_string_id: "menu_action_delete"
+ * },
+ */
+export class _ConfirmDialog extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this._handleCancelBtn = this._handleCancelBtn.bind(this);
+    this._handleConfirmBtn = this._handleConfirmBtn.bind(this);
+  }
+
+  _handleCancelBtn() {
+    this.props.dispatch({type: actionTypes.DIALOG_CANCEL});
+    this.props.dispatch(ac.UserEvent({event: actionTypes.DIALOG_CANCEL, source: this.props.data.eventSource}));
+  }
+
+  _handleConfirmBtn() {
+    this.props.data.onConfirm.forEach(this.props.dispatch);
+  }
+
+  _renderModalMessage() {
+    const message_body = this.props.data.body_string_id;
+
+    if (!message_body) {
+      return null;
+    }
+
+    return (<span>
+      {message_body.map(msg => <p key={msg}><FormattedMessage id={msg} /></p>)}
+    </span>);
+  }
+
+  render() {
+    if (!this.props.visible) {
+      return null;
+    }
+
+    return (<div className="confirmation-dialog">
+      <div className="modal-overlay" onClick={this._handleCancelBtn} />
+      <div className="modal">
+        <section className="modal-message">
+          {this.props.data.icon && <span className={`icon icon-spacer icon-${this.props.data.icon}`} />}
+          {this._renderModalMessage()}
+        </section>
+        <section className="actions">
+          <button onClick={this._handleCancelBtn}>
+            <FormattedMessage id={this.props.data.cancel_button_string_id} />
+          </button>
+          <button className="done" onClick={this._handleConfirmBtn}>
+            <FormattedMessage id={this.props.data.confirm_button_string_id} />
+          </button>
+        </section>
+      </div>
+    </div>);
+  }
+}
+
+export const ConfirmDialog = connect(state => state.Dialog)(_ConfirmDialog);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/ConfirmDialog/_ConfirmDialog.scss
@@ -0,0 +1,67 @@
+.confirmation-dialog {
+  .modal {
+    box-shadow: 0 2px 2px 0 $black-10;
+    left: 50%;
+    margin-left: -200px;
+    position: fixed;
+    top: 20%;
+    width: 400px;
+  }
+
+  section {
+    margin: 0;
+  }
+
+  .modal-message {
+    display: flex;
+    padding: 16px;
+    padding-bottom: 0;
+
+    p {
+      margin: 0;
+      margin-bottom: 16px;
+    }
+  }
+
+  .actions {
+    border: 0;
+    display: flex;
+    flex-wrap: nowrap;
+    padding: 0 16px;
+
+    button {
+      margin-inline-end: 16px;
+      padding-inline-end: 18px;
+      padding-inline-start: 18px;
+      white-space: normal;
+      width: 50%;
+
+      &.done {
+        margin-inline-end: 0;
+        margin-inline-start: 0;
+      }
+    }
+  }
+
+  .icon {
+    margin-inline-end: 16px;
+  }
+}
+
+.modal-overlay {
+  background: var(--newtab-overlay-color);
+  height: 100%;
+  left: 0;
+  position: fixed;
+  top: 0;
+  width: 100%;
+  z-index: 11001;
+}
+
+.modal {
+  background: var(--newtab-modal-color);
+  border: $border-secondary;
+  border-radius: 5px;
+  font-size: 15px;
+  z-index: 11002;
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/ContextMenu/ContextMenu.jsx
@@ -0,0 +1,83 @@
+import React from "react";
+
+export class ContextMenu extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.hideContext = this.hideContext.bind(this);
+    this.onClick = this.onClick.bind(this);
+  }
+
+  hideContext() {
+    this.props.onUpdate(false);
+  }
+
+  componentDidMount() {
+    setTimeout(() => {
+      global.addEventListener("click", this.hideContext);
+    }, 0);
+  }
+
+  componentWillUnmount() {
+    global.removeEventListener("click", this.hideContext);
+  }
+
+  onClick(event) {
+    // Eat all clicks on the context menu so they don't bubble up to window.
+    // This prevents the context menu from closing when clicking disabled items
+    // or the separators.
+    event.stopPropagation();
+  }
+
+  render() {
+    return (<span className="context-menu" onClick={this.onClick}>
+      <ul role="menu" className="context-menu-list">
+        {this.props.options.map((option, i) => (option.type === "separator" ?
+          (<li key={i} className="separator" />) :
+          (option.type !== "empty" && <ContextMenuItem key={i} option={option} hideContext={this.hideContext} />)
+        ))}
+      </ul>
+    </span>);
+  }
+}
+
+export class ContextMenuItem extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onClick = this.onClick.bind(this);
+    this.onKeyDown = this.onKeyDown.bind(this);
+  }
+
+  onClick() {
+    this.props.hideContext();
+    this.props.option.onClick();
+  }
+
+  onKeyDown(event) {
+    const {option} = this.props;
+    switch (event.key) {
+      case "Tab":
+        // tab goes down in context menu, shift + tab goes up in context menu
+        // if we're on the last item, one more tab will close the context menu
+        // similarly, if we're on the first item, one more shift + tab will close it
+        if ((event.shiftKey && option.first) || (!event.shiftKey && option.last)) {
+          this.props.hideContext();
+        }
+        break;
+      case "Enter":
+        this.props.hideContext();
+        option.onClick();
+        break;
+    }
+  }
+
+  render() {
+    const {option} = this.props;
+    return (
+      <li role="menuitem" className="context-menu-item">
+        <a onClick={this.onClick} onKeyDown={this.onKeyDown} tabIndex="0" className={option.disabled ? "disabled" : ""}>
+          {option.icon && <span className={`icon icon-spacer icon-${option.icon}`} />}
+          {option.label}
+        </a>
+      </li>);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/ContextMenu/_ContextMenu.scss
@@ -0,0 +1,52 @@
+.context-menu {
+  background: var(--newtab-contextmenu-background-color);
+  border-radius: $context-menu-border-radius;
+  box-shadow: $context-menu-shadow;
+  display: block;
+  font-size: $context-menu-font-size;
+  margin-inline-start: 5px;
+  offset-inline-start: 100%;
+  position: absolute;
+  top: ($context-menu-button-size / 4);
+  z-index: 10000;
+
+  > ul {
+    list-style: none;
+    margin: 0;
+    padding: $context-menu-outer-padding 0;
+
+    > li {
+      margin: 0;
+      width: 100%;
+
+      &.separator {
+        border-bottom: $border-secondary;
+        margin: $context-menu-outer-padding 0;
+      }
+
+      > a {
+        align-items: center;
+        color: inherit;
+        cursor: pointer;
+        display: flex;
+        line-height: 16px;
+        outline: none;
+        padding: $context-menu-item-padding;
+        white-space: nowrap;
+
+        &:-moz-any(:focus, :hover) {
+          background: var(--newtab-element-hover-color);
+        }
+
+        &:active {
+          background: var(--newtab-element-active-color);
+        }
+
+        &.disabled {
+          opacity: 0.4;
+          pointer-events: none;
+        }
+      }
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/ErrorBoundary/ErrorBoundary.jsx
@@ -0,0 +1,68 @@
+import {FormattedMessage} from "react-intl";
+import React from "react";
+
+export class ErrorBoundaryFallback extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.windowObj = this.props.windowObj || window;
+    this.onClick = this.onClick.bind(this);
+  }
+
+  /**
+   * Since we only get here if part of the page has crashed, do a
+   * forced reload to give us the best chance at recovering.
+   */
+  onClick() {
+    this.windowObj.location.reload(true);
+  }
+
+  render() {
+    const defaultClass = "as-error-fallback";
+    let className;
+    if ("className" in this.props) {
+      className = `${this.props.className} ${defaultClass}`;
+    } else {
+      className = defaultClass;
+    }
+
+    // href="#" to force normal link styling stuff (eg cursor on hover)
+    return (
+      <div className={className}>
+        <div>
+          <FormattedMessage
+            defaultMessage="Oops, something went wrong loading this content."
+            id="error_fallback_default_info" />
+        </div>
+        <span>
+          <a href="#" className="reload-button" onClick={this.onClick}>
+            <FormattedMessage
+              defaultMessage="Refresh page to try again."
+              id="error_fallback_default_refresh_suggestion" />
+          </a>
+        </span>
+      </div>
+    );
+  }
+}
+ErrorBoundaryFallback.defaultProps = {className: "as-error-fallback"};
+
+export class ErrorBoundary extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.state = {hasError: false};
+  }
+
+  componentDidCatch(error, info) {
+    this.setState({hasError: true});
+  }
+
+  render() {
+    if (!this.state.hasError) {
+      return (this.props.children);
+    }
+
+    return <this.props.FallbackComponent className={this.props.className} />;
+  }
+}
+
+ErrorBoundary.defaultProps = {FallbackComponent: ErrorBoundaryFallback};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/ErrorBoundary/_ErrorBoundary.scss
@@ -0,0 +1,17 @@
+.as-error-fallback {
+  align-items: center;
+  border-radius: $border-radius;
+  box-shadow: inset $inner-box-shadow;
+  color: var(--newtab-text-conditional-color);
+  display: flex;
+  flex-direction: column;
+  font-size: $error-fallback-font-size;
+  justify-content: center;
+  justify-items: center;
+  line-height: $error-fallback-line-height;
+
+  a {
+    color: var(--newtab-text-conditional-color);
+    text-decoration: underline;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/LinkMenu/LinkMenu.jsx
@@ -0,0 +1,56 @@
+import {actionCreators as ac} from "common/Actions.jsm";
+import {connect} from "react-redux";
+import {ContextMenu} from "content-src/components/ContextMenu/ContextMenu";
+import {injectIntl} from "react-intl";
+import {LinkMenuOptions} from "content-src/lib/link-menu-options";
+import React from "react";
+
+const DEFAULT_SITE_MENU_OPTIONS = ["CheckPinTopSite", "EditTopSite", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl"];
+
+export class _LinkMenu extends React.PureComponent {
+  getOptions() {
+    const {props} = this;
+    const {site, index, source, isPrivateBrowsingEnabled, siteInfo, platform} = props;
+
+    // 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, source, isPrivateBrowsingEnabled, siteInfo, platform)).map(option => {
+      const {action, impression, id, string_id, type, userEvent} = option;
+      if (!type && id) {
+        option.label = props.intl.formatMessage({id: string_id || id});
+        option.onClick = () => {
+          props.dispatch(action);
+          if (userEvent) {
+            const userEventData = Object.assign({
+              event: userEvent,
+              source,
+              action_position: index
+            }, siteInfo);
+            props.dispatch(ac.UserEvent(userEventData));
+          }
+          if (impression && props.shouldSendImpressionStats) {
+            props.dispatch(impression);
+          }
+        };
+      }
+      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 (<ContextMenu
+      onUpdate={this.props.onUpdate}
+      options={this.getOptions()} />);
+  }
+}
+
+const getState = state => ({isPrivateBrowsingEnabled: state.Prefs.values.isPrivateBrowsingEnabled, platform: state.Prefs.values.platform});
+export const LinkMenu = connect(getState)(injectIntl(_LinkMenu));
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/ManualMigration/ManualMigration.jsx
@@ -0,0 +1,49 @@
+import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
+import {connect} from "react-redux";
+import {FormattedMessage} from "react-intl";
+import React from "react";
+
+/**
+ * Manual migration component used to start the profile import wizard.
+ * Message is presented temporarily and will go away if:
+ * 1.  User clicks "No Thanks"
+ * 2.  User completed the data import
+ * 3.  After 3 active days
+ * 4.  User clicks "Cancel" on the import wizard (currently not implemented).
+ */
+export class _ManualMigration extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onLaunchTour = this.onLaunchTour.bind(this);
+    this.onCancelTour = this.onCancelTour.bind(this);
+  }
+
+  onLaunchTour() {
+    this.props.dispatch(ac.AlsoToMain({type: at.MIGRATION_START}));
+    this.props.dispatch(ac.UserEvent({event: at.MIGRATION_START}));
+  }
+
+  onCancelTour() {
+    this.props.dispatch(ac.AlsoToMain({type: at.MIGRATION_CANCEL}));
+    this.props.dispatch(ac.UserEvent({event: at.MIGRATION_CANCEL}));
+  }
+
+  render() {
+    return (<div className="manual-migration-container">
+        <p>
+          <span className="icon icon-import" />
+          <FormattedMessage id="manual_migration_explanation2" />
+        </p>
+        <div className="manual-migration-actions actions">
+          <button className="dismiss" onClick={this.onCancelTour}>
+            <FormattedMessage id="manual_migration_cancel_button" />
+          </button>
+          <button onClick={this.onLaunchTour}>
+            <FormattedMessage id="manual_migration_import_button" />
+          </button>
+        </div>
+    </div>);
+  }
+}
+
+export const ManualMigration = connect()(_ManualMigration);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/ManualMigration/_ManualMigration.scss
@@ -0,0 +1,52 @@
+.manual-migration-container {
+  color: var(--newtab-text-conditional-color);
+  font-size: 13px;
+  line-height: 15px;
+  margin-bottom: $section-spacing;
+  text-align: center;
+
+  @media (min-width: $break-point-medium) {
+    display: flex;
+    justify-content: space-between;
+    text-align: left;
+  }
+
+  p {
+    margin: 0;
+    @media (min-width: $break-point-medium) {
+      align-self: center;
+      display: flex;
+      justify-content: space-between;
+    }
+  }
+
+  .icon {
+    display: none;
+    @media (min-width: $break-point-medium) {
+      align-self: center;
+      display: block;
+      fill: var(--newtab-icon-secondary-color);
+      margin-inline-end: 6px;
+    }
+  }
+}
+
+.manual-migration-actions {
+  border: 0;
+  display: block;
+  flex-wrap: nowrap;
+
+  @media (min-width: $break-point-medium) {
+    display: flex;
+    justify-content: space-between;
+    padding: 0;
+  }
+
+  button {
+    align-self: center;
+    height: 26px;
+    margin: 0;
+    margin-inline-start: 20px;
+    padding: 0 12px;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/MessageCenterAdmin/MessageCenterAdmin.jsx
@@ -0,0 +1,70 @@
+import {MessageCenterUtils} from "../../message-center/message-center-content";
+import React from "react";
+
+export class MessageCenterAdmin extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onMessage = this.onMessage.bind(this);
+    this.state = {};
+  }
+
+  onMessage({data: action}) {
+    if (action.type === "ADMIN_SET_STATE") {
+      this.setState(action.data);
+    }
+  }
+
+  componentWillMount() {
+    MessageCenterUtils.sendMessage({type: "ADMIN_CONNECT_STATE"});
+    MessageCenterUtils.addListener(this.onMessage);
+  }
+
+  componentWillUnmount() {
+    MessageCenterUtils.removeListener(this.onMessage);
+  }
+
+  handleBlock(id) {
+    return () => MessageCenterUtils.blockById(id);
+  }
+
+  handleUnblock(id) {
+    return () => MessageCenterUtils.unblockById(id);
+  }
+
+  renderMessageItem(msg) {
+    const isCurrent = msg.id === this.state.currentId;
+    const isBlocked = this.state.blockList[msg.id];
+
+    let itemClassName = "message-item";
+    if (isCurrent) { itemClassName += " current"; }
+    if (isBlocked) { itemClassName += " blocked"; }
+
+    return (<tr className={itemClassName} key={msg.id}>
+      <td className="message-id"><span>{msg.id}</span></td>
+      <td>
+        <button className={`button ${(isBlocked ? "" : " primary")}`} onClick={isBlocked ? this.handleUnblock(msg.id) : this.handleBlock(msg.id)}>{isBlocked ? "Unblock" : "Block"}</button>
+      </td>
+      <td className="message-summary">
+        <pre>{JSON.stringify(msg, null, 2)}</pre>
+      </td>
+    </tr>);
+  }
+
+  renderMessages() {
+    if (!this.state.messages) {
+      return null;
+    }
+    return (<table><tbody>
+      {this.state.messages.map(msg => this.renderMessageItem(msg))}
+    </tbody></table>);
+  }
+
+  render() {
+    return (<div className="messages-admin outer-wrapper">
+      <h1>Messages Admin</h1>
+      <button className="button primary" onClick={MessageCenterUtils.getNextMessage}>Refresh Current Message</button>
+      <h2>Messages</h2>
+      {this.renderMessages()}
+    </div>);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/MessageCenterAdmin/MessageCenterAdmin.scss
@@ -0,0 +1,73 @@
+
+.messages-admin {
+  $monospace: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Droid Sans Mono', 'Source Code Pro', monospace;
+  max-width: 996px;
+  margin: 0 auto;
+  font-size: 14px;
+  // Reset .outer-wrapper styles
+  display: inherit;
+  padding: 0;
+
+  h1 {
+    font-weight: 200;
+    font-size: 32px;
+  }
+
+  table {
+    border-collapse: collapse;
+    width: 100%;
+  }
+
+  .message-item {
+    &:first-child td {
+      border-top: 1px solid $black-10;
+    }
+
+    td {
+      vertical-align: top;
+      border-bottom: 1px solid $black-10;
+      padding: 8px;
+
+      &:first-child {
+        border-left: 1px solid $black-10;
+      }
+
+      &:last-child {
+        border-right: 1px solid $black-10;
+      }
+    }
+
+    &.current {
+      .message-id span {
+        background: $yellow-50;
+        padding: 2px 5px;
+      }
+    }
+
+    &.blocked {
+      .message-id,
+      .message-summary {
+        opacity: 0.5;
+      }
+
+      .message-id {
+        color: $grey-90;
+      }
+    }
+
+    .message-id {
+      font-family: $monospace;
+      font-size: 12px;
+    }
+  }
+
+  pre {
+    background: $white;
+    margin: 0;
+    padding: 8px;
+    font-size: 12px;
+    max-width: 750px;
+    overflow: auto;
+    font-family: $monospace;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/Search/Search.jsx
@@ -0,0 +1,88 @@
+/* globals ContentSearchUIController */
+"use strict";
+
+import {FormattedMessage, injectIntl} from "react-intl";
+import {actionCreators as ac} from "common/Actions.jsm";
+import {connect} from "react-redux";
+import {IS_NEWTAB} from "content-src/lib/constants";
+import React from "react";
+
+export class _Search extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onClick = this.onClick.bind(this);
+    this.onInputMount = this.onInputMount.bind(this);
+  }
+
+  handleEvent(event) {
+    // Also track search events with our own telemetry
+    if (event.detail.type === "Search") {
+      this.props.dispatch(ac.UserEvent({event: "SEARCH"}));
+    }
+  }
+
+  onClick(event) {
+    window.gContentSearchController.search(event);
+  }
+
+  componentWillUnmount() {
+    delete window.gContentSearchController;
+  }
+
+  onInputMount(input) {
+    if (input) {
+      // The "healthReportKey" and needs to be "newtab" or "abouthome" so that
+      // BrowserUsageTelemetry.jsm knows to handle events with this name, and
+      // can add the appropriate telemetry probes for search. Without the correct
+      // name, certain tests like browser_UsageTelemetry_content.js will fail
+      // (See github ticket #2348 for more details)
+      const healthReportKey = IS_NEWTAB ? "newtab" : "abouthome";
+
+      // The "searchSource" needs to be "newtab" or "homepage" and is sent with
+      // the search data and acts as context for the search request (See
+      // nsISearchEngine.getSubmission). It is necessary so that search engine
+      // plugins can correctly atribute referrals. (See github ticket #3321 for
+      // more details)
+      const searchSource = IS_NEWTAB ? "newtab" : "homepage";
+
+      // gContentSearchController needs to exist as a global so that tests for
+      // the existing about:home can find it; and so it allows these tests to pass.
+      // In the future, when activity stream is default about:home, this can be renamed
+      window.gContentSearchController = new ContentSearchUIController(input, input.parentNode,
+        healthReportKey, searchSource);
+      addEventListener("ContentSearchClient", this);
+    } else {
+      window.gContentSearchController = null;
+      removeEventListener("ContentSearchClient", this);
+    }
+  }
+
+  /*
+   * Do not change the ID on the input field, as legacy newtab code
+   * specifically looks for the id 'newtab-search-text' on input fields
+   * in order to execute searches in various tests
+   */
+  render() {
+    return (<div className="search-wrapper">
+      <label htmlFor="newtab-search-text" className="search-label">
+        <span className="sr-only"><FormattedMessage id="search_web_placeholder" /></span>
+      </label>
+      <input
+        id="newtab-search-text"
+        maxLength="256"
+        placeholder={this.props.intl.formatMessage({id: "search_web_placeholder"})}
+        ref={this.onInputMount}
+        title={this.props.intl.formatMessage({id: "search_web_placeholder"})}
+        type="search" />
+      <button
+        id="searchSubmit"
+        className="search-button"
+        onClick={this.onClick}
+        title={this.props.intl.formatMessage({id: "search_button"})}>
+        <span className="sr-only"><FormattedMessage id="search_button" /></span>
+      </button>
+    </div>);
+  }
+}
+
+export const Search = connect()(injectIntl(_Search));
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/Search/_Search.scss
@@ -0,0 +1,158 @@
+.search-wrapper {
+  $search-height: 35px;
+  $search-icon-size: 18px;
+  $search-icon-padding: 8px;
+  $search-icon-width: 2 * $search-icon-padding + $search-icon-size;
+  $search-input-left-label-width: 35px;
+  $search-button-width: 36px;
+  $glyph-forward: url('chrome://browser/skin/forward.svg');
+
+  cursor: default;
+  display: flex;
+  height: $search-height;
+  margin-bottom: $section-spacing;
+  position: relative;
+  width: 100%;
+
+  input {
+    background: var(--newtab-textbox-background-color) var(--newtab-search-icon) $search-icon-padding center / $search-icon-size no-repeat;
+    border: solid 1px var(--newtab-search-border-color);
+    box-shadow: $shadow-secondary, 0 0 0 1px $black-15;
+    font-size: 15px;
+    -moz-context-properties: fill;
+    fill: var(--newtab-search-icon-color);
+    padding: 0;
+    padding-inline-end: $search-button-width;
+    padding-inline-start: $search-icon-width;
+    width: 100%;
+
+    &:dir(rtl) {
+      background-position-x: right $search-icon-padding;
+    }
+  }
+
+  &:hover input {
+    box-shadow: $shadow-secondary, 0 0 0 1px $black-25;
+  }
+
+  &:active input,
+  input:focus {
+    border: $input-border-active;
+    box-shadow: var(--newtab-textbox-focus-boxshadow);
+  }
+
+  .search-button {
+    background: $glyph-forward no-repeat center center;
+    background-size: 16px 16px;
+    border: 0;
+    border-radius: 0 $border-radius $border-radius 0;
+    -moz-context-properties: fill;
+    fill: var(--newtab-search-icon-color);
+    height: 100%;
+    offset-inline-end: 0;
+    position: absolute;
+    width: $search-button-width;
+
+    &:focus,
+    &:hover {
+      background-color: $grey-90-10;
+      cursor: pointer;
+    }
+
+    &:active {
+      background-color: $grey-90-20;
+    }
+
+    &:dir(rtl) {
+      transform: scaleX(-1);
+    }
+  }
+}
+
+@at-root {
+  // Adjust the style of the contentSearchUI-generated table
+  // sass-lint:disable-block class-name-format
+  .contentSearchSuggestionTable {
+    background-color: var(--newtab-search-dropdown-color);
+    border: 0;
+    box-shadow: $context-menu-shadow;
+    transform: translateY($textbox-shadow-size);
+
+    .contentSearchHeader {
+      background-color: var(--newtab-search-dropdown-header-color);
+      color: var(--newtab-text-secondary-color);
+    }
+
+    .contentSearchHeader,
+    .contentSearchSettingsButton {
+      border-color: var(--newtab-border-secondary-color);
+    }
+
+    .contentSearchSuggestionsList {
+      border: 0;
+    }
+
+    .contentSearchOneOffsTable {
+      background-color: var(--newtab-search-dropdown-header-color);
+      border-top: solid 1px var(--newtab-border-secondary-color);
+    }
+
+    .contentSearchSearchWithHeaderSearchText {
+      color: var(--newtab-text-primary-color);
+    }
+
+    .contentSearchSuggestionsContainer {
+      background-color: var(--newtab-search-dropdown-color);
+    }
+
+    .contentSearchSuggestionRow {
+      &.selected {
+        background: var(--newtab-element-hover-color);
+        color: var(--newtab-text-primary-color);
+
+        &:active {
+          background: var(--newtab-element-active-color);
+        }
+
+        .historyIcon {
+          fill: var(--newtab-icon-secondary-color);
+        }
+      }
+    }
+
+    .contentSearchOneOffsTable {
+      .contentSearchSuggestionsContainer {
+        background-color: var(--newtab-search-dropdown-header-color);
+      }
+    }
+
+    .contentSearchOneOffItem {
+      background-image: none;
+      position: relative;
+
+      &.selected {
+        background: var(--newtab-element-hover-color);
+      }
+
+      &:active {
+        background: var(--newtab-element-active-color);
+      }
+
+      &::after {
+        border-right: solid 1px var(--newtab-border-secondary-color);
+        content: '';
+        height: 22px;
+        offset-inline-end: 0;
+        position: absolute;
+        top: 5px;
+      }
+    }
+
+    .contentSearchSettingsButton {
+      &:hover {
+        background: var(--newtab-element-hover-color);
+        color: var(--newtab-text-primary-color);
+      }
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/SectionMenu/SectionMenu.jsx
@@ -0,0 +1,55 @@
+import {actionCreators as ac} from "common/Actions.jsm";
+import {ContextMenu} from "content-src/components/ContextMenu/ContextMenu";
+import {injectIntl} from "react-intl";
+import React from "react";
+import {SectionMenuOptions} from "content-src/lib/section-menu-options";
+
+const DEFAULT_SECTION_MENU_OPTIONS = ["MoveUp", "MoveDown", "Separator", "RemoveSection", "CheckCollapsed", "Separator", "ManageSection"];
+
+export class _SectionMenu extends React.PureComponent {
+  getOptions() {
+    const {props} = this;
+
+    const propOptions = Array.from(DEFAULT_SECTION_MENU_OPTIONS);
+    // Prepend custom options and a separator
+    if (props.extraOptions) {
+      propOptions.splice(0, 0, ...props.extraOptions, "Separator");
+    }
+    // Insert privacy notice before the last option ("ManageSection")
+    if (props.privacyNoticeURL) {
+      propOptions.splice(-1, 0, "PrivacyNotice");
+    }
+
+    const options = propOptions.map(o => SectionMenuOptions[o](props)).map(option => {
+      const {action, id, type, userEvent} = option;
+      if (!type && id) {
+        option.label = props.intl.formatMessage({id});
+        option.onClick = () => {
+          props.dispatch(action);
+          if (userEvent) {
+            props.dispatch(ac.UserEvent({
+              event: userEvent,
+              source: props.source
+            }));
+          }
+        };
+      }
+      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 (<ContextMenu
+      onUpdate={this.props.onUpdate}
+      options={this.getOptions()} />);
+  }
+}
+
+export const SectionMenu = injectIntl(_SectionMenu);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/Sections/Sections.jsx
@@ -0,0 +1,227 @@
+import {Card, PlaceholderCard} from "content-src/components/Card/Card";
+import {FormattedMessage, injectIntl} from "react-intl";
+import {actionCreators as ac} from "common/Actions.jsm";
+import {CollapsibleSection} from "content-src/components/CollapsibleSection/CollapsibleSection";
+import {ComponentPerfTimer} from "content-src/components/ComponentPerfTimer/ComponentPerfTimer";
+import {connect} from "react-redux";
+import React from "react";
+import {Topics} from "content-src/components/Topics/Topics";
+import {TopSites} from "content-src/components/TopSites/TopSites";
+
+const VISIBLE = "visible";
+const VISIBILITY_CHANGE_EVENT = "visibilitychange";
+const CARDS_PER_ROW = 3;
+
+function getFormattedMessage(message) {
+  return typeof message === "string" ? <span>{message}</span> : <FormattedMessage {...message} />;
+}
+
+export class Section extends React.PureComponent {
+  _dispatchImpressionStats() {
+    const {props} = this;
+    const maxCards = 3 * props.maxRows;
+    const cards = props.rows.slice(0, maxCards);
+
+    if (this.needsImpressionStats(cards)) {
+      props.dispatch(ac.ImpressionStats({
+        source: props.eventSource,
+        tiles: cards.map(link => ({id: link.guid}))
+      }));
+      this.impressionCardGuids = cards.map(link => link.guid);
+    }
+  }
+
+  // This sends an event when a user sees a set of new content. If content
+  // changes while the page is hidden (i.e. preloaded or on a hidden tab),
+  // only send the event if the page becomes visible again.
+  sendImpressionStatsOrAddListener() {
+    const {props} = this;
+
+    if (!props.shouldSendImpressionStats || !props.dispatch) {
+      return;
+    }
+
+    if (props.document.visibilityState === VISIBLE) {
+      this._dispatchImpressionStats();
+    } else {
+      // We should only ever send the latest impression stats ping, so remove any
+      // older listeners.
+      if (this._onVisibilityChange) {
+        props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
+      }
+
+      // When the page becomes visible, send the impression stats ping if the section isn't collapsed.
+      this._onVisibilityChange = () => {
+        if (props.document.visibilityState === VISIBLE) {
+          if (!this.props.pref.collapsed) {
+            this._dispatchImpressionStats();
+          }
+          props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
+        }
+      };
+      props.document.addEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
+    }
+  }
+
+  componentDidMount() {
+    if (this.props.rows.length && !this.props.pref.collapsed) {
+      this.sendImpressionStatsOrAddListener();
+    }
+  }
+
+  componentDidUpdate(prevProps) {
+    const {props} = this;
+    const isCollapsed = props.pref.collapsed;
+    const wasCollapsed = prevProps.pref.collapsed;
+    if (
+      // Don't send impression stats for the empty state
+      props.rows.length &&
+      (
+        // We only want to send impression stats if the content of the cards has changed
+        // and the section is not collapsed...
+        (props.rows !== prevProps.rows && !isCollapsed) ||
+        // or if we are expanding a section that was collapsed.
+        (wasCollapsed && !isCollapsed)
+      )
+    ) {
+      this.sendImpressionStatsOrAddListener();
+    }
+  }
+
+  componentWillUnmount() {
+    if (this._onVisibilityChange) {
+      this.props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
+    }
+  }
+
+  needsImpressionStats(cards) {
+    if (!this.impressionCardGuids || (this.impressionCardGuids.length !== cards.length)) {
+      return true;
+    }
+
+    for (let i = 0; i < cards.length; i++) {
+      if (cards[i].guid !== this.impressionCardGuids[i]) {
+        return true;
+      }
+    }
+
+    return false;
+  }
+
+  numberOfPlaceholders(items) {
+    if (items === 0) {
+      return CARDS_PER_ROW;
+    }
+    const remainder = items % CARDS_PER_ROW;
+    if (remainder === 0) {
+      return 0;
+    }
+    return CARDS_PER_ROW - remainder;
+  }
+
+  render() {
+    const {
+      id, eventSource, title, icon, rows,
+      emptyState, dispatch, maxRows,
+      contextMenuOptions, initialized, disclaimer,
+      pref, privacyNoticeURL, isFirst, isLast
+    } = this.props;
+    const maxCards = CARDS_PER_ROW * maxRows;
+
+    // Show topics only for top stories and if it's not initialized yet (so
+    // content doesn't shift when it is loaded) or has loaded with topics
+    const shouldShowTopics = (id === "topstories" &&
+      (!this.props.topics || this.props.topics.length > 0));
+
+    const realRows = rows.slice(0, maxCards);
+    const placeholders = this.numberOfPlaceholders(realRows.length);
+
+    // The empty state should only be shown after we have initialized and there is no content.
+    // Otherwise, we should show placeholders.
+    const shouldShowEmptyState = initialized && !rows.length;
+
+    // <Section> <-- React component
+    // <section> <-- HTML5 element
+    return (<ComponentPerfTimer {...this.props}>
+      <CollapsibleSection className="section" icon={icon}
+        title={title}
+        id={id}
+        eventSource={eventSource}
+        disclaimer={disclaimer}
+        collapsed={this.props.pref.collapsed}
+        showPrefName={(pref && pref.feed) || id}
+        privacyNoticeURL={privacyNoticeURL}
+        Prefs={this.props.Prefs}
+        isFirst={isFirst}
+        isLast={isLast}
+        dispatch={this.props.dispatch}>
+
+        {!shouldShowEmptyState && (<ul className="section-list" style={{padding: 0}}>
+          {realRows.map((link, index) => link &&
+            <Card key={index} index={index} dispatch={dispatch} link={link} contextMenuOptions={contextMenuOptions}
+              eventSource={eventSource} shouldSendImpressionStats={this.props.shouldSendImpressionStats} isWebExtension={this.props.isWebExtension} />)}
+          {placeholders > 0 && [...new Array(placeholders)].map((_, i) => <PlaceholderCard key={i} />)}
+        </ul>)}
+        {shouldShowEmptyState &&
+          <div className="section-empty-state">
+            <div className="empty-state">
+              {emptyState.icon && emptyState.icon.startsWith("moz-extension://") ?
+                <img className="empty-state-icon icon" style={{"background-image": `url('${emptyState.icon}')`}} /> :
+                <img className={`empty-state-icon icon icon-${emptyState.icon}`} />}
+              <p className="empty-state-message">
+                {getFormattedMessage(emptyState.message)}
+              </p>
+            </div>
+          </div>}
+        {shouldShowTopics && <Topics topics={this.props.topics} read_more_endpoint={this.props.read_more_endpoint} />}
+      </CollapsibleSection>
+    </ComponentPerfTimer>);
+  }
+}
+
+Section.defaultProps = {
+  document: global.document,
+  rows: [],
+  emptyState: {},
+  pref: {},
+  title: ""
+};
+
+export const SectionIntl = connect(state => ({Prefs: state.Prefs}))(injectIntl(Section));
+
+export class _Sections extends React.PureComponent {
+  renderSections() {
+    const sections = [];
+    const enabledSections = this.props.Sections.filter(section => section.enabled);
+    const {sectionOrder, "feeds.topsites": showTopSites} = this.props.Prefs.values;
+    // Enabled sections doesn't include Top Sites, so we add it if enabled.
+    const expectedCount = enabledSections.length + ~~showTopSites;
+
+    for (const sectionId of sectionOrder.split(",")) {
+      const commonProps = {
+        key: sectionId,
+        isFirst: sections.length === 0,
+        isLast: sections.length === expectedCount - 1
+      };
+      if (sectionId === "topsites" && showTopSites) {
+        sections.push(<TopSites {...commonProps} />);
+      } else {
+        const section = enabledSections.find(s => s.id === sectionId);
+        if (section) {
+          sections.push(<SectionIntl {...section} {...commonProps} />);
+        }
+      }
+    }
+    return sections;
+  }
+
+  render() {
+    return (
+      <div className="sections-list">
+        {this.renderSections()}
+      </div>
+    );
+  }
+}
+
+export const Sections = connect(state => ({Sections: state.Sections, Prefs: state.Prefs}))(_Sections);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/Sections/_Sections.scss
@@ -0,0 +1,76 @@
+.sections-list {
+  .section-list {
+    display: grid;
+    grid-gap: $base-gutter;
+    grid-template-columns: repeat(auto-fit, $card-width);
+    margin: 0;
+
+    @media (max-width: $break-point-medium) {
+      @include context-menu-open-left;
+    }
+
+    @media (min-width: $break-point-medium) and (max-width: $break-point-large) {
+      :nth-child(2n) {
+        @include context-menu-open-left;
+      }
+    }
+
+    @media (min-width: $break-point-large) and (max-width: $break-point-large + 2 * $card-width) {
+      :nth-child(3n) {
+        @include context-menu-open-left;
+      }
+    }
+  }
+
+  .section-empty-state {
+    border: $border-secondary;
+    border-radius: $border-radius;
+    display: flex;
+    height: $card-height;
+    width: 100%;
+
+    .empty-state {
+      margin: auto;
+      max-width: 350px;
+
+      .empty-state-icon {
+        background-position: center;
+        background-repeat: no-repeat;
+        background-size: 50px 50px;
+        -moz-context-properties: fill;
+        display: block;
+        fill: var(--newtab-icon-secondary-color);
+        height: 50px;
+        margin: 0 auto;
+        width: 50px;
+      }
+
+      .empty-state-message {
+        color: var(--newtab-text-primary-color);
+        font-size: 13px;
+        margin-bottom: 0;
+        text-align: center;
+      }
+    }
+  }
+}
+
+.wide-layout-enabled {
+  .sections-list {
+    .section-list {
+      @media (min-width: $break-point-widest) and (max-width: $break-point-widest + 2 * $card-width) {
+        :nth-child(3n) {
+          @include context-menu-open-left;
+        }
+      }
+
+      @media (min-width: $break-point-widest) {
+        grid-template-columns: repeat(auto-fit, $card-width-large);
+      }
+    }
+  }
+
+  .section-empty-state {
+    height: $card-height-large;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/TopSites/TopSite.jsx
@@ -0,0 +1,416 @@
+import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
+import {FormattedMessage, injectIntl} from "react-intl";
+import {
+  MIN_CORNER_FAVICON_SIZE,
+  MIN_RICH_FAVICON_SIZE,
+  TOP_SITES_CONTEXT_MENU_OPTIONS,
+  TOP_SITES_SOURCE
+} from "./TopSitesConstants";
+import {LinkMenu} from "content-src/components/LinkMenu/LinkMenu";
+import React from "react";
+import {TOP_SITES_MAX_SITES_PER_ROW} from "common/Reducers.jsm";
+
+export class TopSiteLink extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onDragEvent = this.onDragEvent.bind(this);
+  }
+
+  /*
+   * Helper to determine whether the drop zone should allow a drop. We only allow
+   * dropping top sites for now.
+   */
+  _allowDrop(e) {
+    return e.dataTransfer.types.includes("text/topsite-index");
+  }
+
+  onDragEvent(event) {
+    switch (event.type) {
+      case "click":
+        // Stop any link clicks if we started any dragging
+        if (this.dragged) {
+          event.preventDefault();
+        }
+        break;
+      case "dragstart":
+        this.dragged = true;
+        event.dataTransfer.effectAllowed = "move";
+        event.dataTransfer.setData("text/topsite-index", this.props.index);
+        event.target.blur();
+        this.props.onDragEvent(event, this.props.index, this.props.link, this.props.title);
+        break;
+      case "dragend":
+        this.props.onDragEvent(event);
+        break;
+      case "dragenter":
+      case "dragover":
+      case "drop":
+        if (this._allowDrop(event)) {
+          event.preventDefault();
+          this.props.onDragEvent(event, this.props.index);
+        }
+        break;
+      case "mousedown":
+        // Reset at the first mouse event of a potential drag
+        this.dragged = false;
+        break;
+    }
+  }
+
+  render() {
+    const {children, className, defaultStyle, isDraggable, link, onClick, title} = this.props;
+    const topSiteOuterClassName = `top-site-outer${className ? ` ${className}` : ""}${link.isDragged ? " dragged" : ""}`;
+    const {tippyTopIcon, faviconSize} = link;
+    const [letterFallback] = title;
+    let imageClassName;
+    let imageStyle;
+    let showSmallFavicon = false;
+    let smallFaviconStyle;
+    let smallFaviconFallback;
+    if (defaultStyle) { // force no styles (letter fallback) even if the link has imagery
+      smallFaviconFallback = false;
+    } else if (link.customScreenshotURL) {
+      // assume high quality custom screenshot and use rich icon styles and class names
+      imageClassName = "top-site-icon rich-icon";
+      imageStyle = {
+        backgroundColor: link.backgroundColor,
+        backgroundImage: `url(${link.screenshot})`
+      };
+    } else if (tippyTopIcon || faviconSize >= MIN_RICH_FAVICON_SIZE) {
+      // styles and class names for top sites with rich icons
+      imageClassName = "top-site-icon rich-icon";
+      imageStyle = {
+        backgroundColor: link.backgroundColor,
+        backgroundImage: `url(${tippyTopIcon || link.favicon})`
+      };
+    } else {
+      // styles and class names for top sites with screenshot + small icon in top left corner
+      imageClassName = `screenshot${link.screenshot ? " active" : ""}`;
+      imageStyle = {backgroundImage: link.screenshot ? `url(${link.screenshot})` : "none"};
+
+      // only show a favicon in top left if it's greater than 16x16
+      if (faviconSize >= MIN_CORNER_FAVICON_SIZE) {
+        showSmallFavicon = true;
+        smallFaviconStyle = {backgroundImage:  `url(${link.favicon})`};
+      } else if (link.screenshot) {
+        // Don't show a small favicon if there is no screenshot, because that
+        // would result in two fallback icons
+        showSmallFavicon = true;
+        smallFaviconFallback = true;
+      }
+    }
+    let draggableProps = {};
+    if (isDraggable) {
+      draggableProps = {
+        onClick: this.onDragEvent,
+        onDragEnd: this.onDragEvent,
+        onDragStart: this.onDragEvent,
+        onMouseDown: this.onDragEvent
+      };
+    }
+    return (<li className={topSiteOuterClassName} onDrop={this.onDragEvent} onDragOver={this.onDragEvent} onDragEnter={this.onDragEvent} onDragLeave={this.onDragEvent} {...draggableProps}>
+      <div className="top-site-inner">
+         <a href={link.url} onClick={onClick}>
+            <div className="tile" aria-hidden={true} data-fallback={letterFallback}>
+              <div className={imageClassName} style={imageStyle} />
+              {showSmallFavicon && <div
+                className="top-site-icon default-icon"
+                data-fallback={smallFaviconFallback && letterFallback}
+                style={smallFaviconStyle} />}
+           </div>
+           <div className={`title ${link.isPinned ? "pinned" : ""}`}>
+             {link.isPinned && <div className="icon icon-pin-small" />}
+              <span dir="auto">{title}</span>
+           </div>
+         </a>
+         {children}
+      </div>
+    </li>);
+  }
+}
+TopSiteLink.defaultProps = {
+  title: "",
+  link: {},
+  isDraggable: true
+};
+
+export class TopSite extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.state = {showContextMenu: false};
+    this.onLinkClick = this.onLinkClick.bind(this);
+    this.onMenuButtonClick = this.onMenuButtonClick.bind(this);
+    this.onMenuUpdate = this.onMenuUpdate.bind(this);
+  }
+
+  /**
+   * Report to telemetry additional information about the item.
+   */
+  _getTelemetryInfo() {
+    const value = {icon_type: this.props.link.iconType};
+    // Filter out "not_pinned" type for being the default
+    if (this.props.link.isPinned) {
+      value.card_type = "pinned";
+    }
+    return {value};
+  }
+
+  userEvent(event) {
+    this.props.dispatch(ac.UserEvent(Object.assign({
+      event,
+      source: TOP_SITES_SOURCE,
+      action_position: this.props.index
+    }, this._getTelemetryInfo())));
+  }
+
+  onLinkClick(ev) {
+    this.userEvent("CLICK");
+  }
+
+  onMenuButtonClick(event) {
+    event.preventDefault();
+    this.props.onActivate(this.props.index);
+    this.setState({showContextMenu: true});
+  }
+
+  onMenuUpdate(showContextMenu) {
+    this.setState({showContextMenu});
+  }
+
+  render() {
+    const {props} = this;
+    const {link} = props;
+    const isContextMenuOpen = this.state.showContextMenu && props.activeIndex === props.index;
+    const title = link.label || link.hostname;
+    return (<TopSiteLink {...props} onClick={this.onLinkClick} onDragEvent={this.props.onDragEvent} className={`${props.className || ""}${isContextMenuOpen ? " active" : ""}`} title={title}>
+        <div>
+          <button className="context-menu-button icon" onClick={this.onMenuButtonClick}>
+            <span className="sr-only">
+              <FormattedMessage id="context_menu_button_sr" values={{title}} />
+            </span>
+          </button>
+          {isContextMenuOpen &&
+            <LinkMenu
+              dispatch={props.dispatch}
+              index={props.index}
+              onUpdate={this.onMenuUpdate}
+              options={TOP_SITES_CONTEXT_MENU_OPTIONS}
+              site={link}
+              siteInfo={this._getTelemetryInfo()}
+              source={TOP_SITES_SOURCE} />
+          }
+        </div>
+    </TopSiteLink>);
+  }
+}
+TopSite.defaultProps = {
+  link: {},
+  onActivate() {}
+};
+
+export class TopSitePlaceholder extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onEditButtonClick = this.onEditButtonClick.bind(this);
+  }
+
+  onEditButtonClick() {
+    this.props.dispatch(
+      {type: at.TOP_SITES_EDIT, data: {index: this.props.index}});
+  }
+
+  render() {
+    return (<TopSiteLink {...this.props} className={`placeholder ${this.props.className || ""}`} isDraggable={false}>
+      <button className="context-menu-button edit-button icon"
+       title={this.props.intl.formatMessage({id: "edit_topsites_edit_button"})}
+       onClick={this.onEditButtonClick} />
+    </TopSiteLink>);
+  }
+}
+
+export class _TopSiteList extends React.PureComponent {
+  static get DEFAULT_STATE() {
+    return {
+      activeIndex: null,
+      draggedIndex: null,
+      draggedSite: null,
+      draggedTitle: null,
+      topSitesPreview: null
+    };
+  }
+
+  constructor(props) {
+    super(props);
+    this.state = _TopSiteList.DEFAULT_STATE;
+    this.onDragEvent = this.onDragEvent.bind(this);
+    this.onActivate = this.onActivate.bind(this);
+  }
+
+  componentWillReceiveProps(nextProps) {
+    if (this.state.draggedSite) {
+      const prevTopSites = this.props.TopSites && this.props.TopSites.rows;
+      const newTopSites = nextProps.TopSites && nextProps.TopSites.rows;
+      if (prevTopSites && prevTopSites[this.state.draggedIndex] &&
+        prevTopSites[this.state.draggedIndex].url === this.state.draggedSite.url &&
+        (!newTopSites[this.state.draggedIndex] || newTopSites[this.state.draggedIndex].url !== this.state.draggedSite.url)) {
+        // We got the new order from the redux store via props. We can clear state now.
+        this.setState(_TopSiteList.DEFAULT_STATE);
+      }
+    }
+  }
+
+  userEvent(event, index) {
+    this.props.dispatch(ac.UserEvent({
+      event,
+      source: TOP_SITES_SOURCE,
+      action_position: index
+    }));
+  }
+
+  onDragEvent(event, index, link, title) {
+    switch (event.type) {
+      case "dragstart":
+        this.dropped = false;
+        this.setState({
+          draggedIndex: index,
+          draggedSite: link,
+          draggedTitle: title,
+          activeIndex: null
+        });
+        this.userEvent("DRAG", index);
+        break;
+      case "dragend":
+        if (!this.dropped) {
+          // If there was no drop event, reset the state to the default.
+          this.setState(_TopSiteList.DEFAULT_STATE);
+        }
+        break;
+      case "dragenter":
+        if (index === this.state.draggedIndex) {
+          this.setState({topSitesPreview: null});
+        } else {
+          this.setState({topSitesPreview: this._makeTopSitesPreview(index)});
+        }
+        break;
+      case "drop":
+        if (index !== this.state.draggedIndex) {
+          this.dropped = true;
+          this.props.dispatch(ac.AlsoToMain({
+            type: at.TOP_SITES_INSERT,
+            data: {
+              site: {
+                url: this.state.draggedSite.url,
+                label: this.state.draggedTitle,
+                customScreenshotURL: this.state.draggedSite.customScreenshotURL
+              },
+              index,
+              draggedFromIndex: this.state.draggedIndex
+            }
+          }));
+          this.userEvent("DROP", index);
+        }
+        break;
+    }
+  }
+
+  _getTopSites() {
+    // Make a copy of the sites to truncate or extend to desired length
+    let topSites = this.props.TopSites.rows.slice();
+    topSites.length = this.props.TopSitesRows * TOP_SITES_MAX_SITES_PER_ROW;
+    return topSites;
+  }
+
+  /**
+   * Make a preview of the topsites that will be the result of dropping the currently
+   * dragged site at the specified index.
+   */
+  _makeTopSitesPreview(index) {
+    const topSites = this._getTopSites();
+    topSites[this.state.draggedIndex] = null;
+    const pinnedOnly = topSites.map(site => ((site && site.isPinned) ? site : null));
+    const unpinned = topSites.filter(site => site && !site.isPinned);
+    const siteToInsert = Object.assign({}, this.state.draggedSite, {isPinned: true, isDragged: true});
+    if (!pinnedOnly[index]) {
+      pinnedOnly[index] = siteToInsert;
+    } else {
+      // Find the hole to shift the pinned site(s) towards. We shift towards the
+      // hole left by the site being dragged.
+      let holeIndex = index;
+      const indexStep = index > this.state.draggedIndex ? -1 : 1;
+      while (pinnedOnly[holeIndex]) {
+        holeIndex += indexStep;
+      }
+
+      // Shift towards the hole.
+      const shiftingStep = index > this.state.draggedIndex ? 1 : -1;
+      while (holeIndex !== index) {
+        const nextIndex = holeIndex + shiftingStep;
+        pinnedOnly[holeIndex] = pinnedOnly[nextIndex];
+        holeIndex = nextIndex;
+      }
+      pinnedOnly[index] = siteToInsert;
+    }
+
+    // Fill in the remaining holes with unpinned sites.
+    const preview = pinnedOnly;
+    for (let i = 0; i < preview.length; i++) {
+      if (!preview[i]) {
+        preview[i] = unpinned.shift() || null;
+      }
+    }
+
+    return preview;
+  }
+
+  onActivate(index) {
+    this.setState({activeIndex: index});
+  }
+
+  render() {
+    const {props} = this;
+    const topSites = this.state.topSitesPreview || this._getTopSites();
+    const topSitesUI = [];
+    const commonProps = {
+      onDragEvent: this.onDragEvent,
+      dispatch: props.dispatch,
+      intl: props.intl
+    };
+    // We assign a key to each placeholder slot. We need it to be independent
+    // of the slot index (i below) so that the keys used stay the same during
+    // drag and drop reordering and the underlying DOM nodes are reused.
+    // This mostly (only?) affects linux so be sure to test on linux before changing.
+    let holeIndex = 0;
+
+    // On narrow viewports, we only show 6 sites per row. We'll mark the rest as
+    // .hide-for-narrow to hide in CSS via @media query.
+    const maxNarrowVisibleIndex = props.TopSitesRows * 6;
+
+    for (let i = 0, l = topSites.length; i < l; i++) {
+      const link = topSites[i] && Object.assign({}, topSites[i], {iconType: this.props.topSiteIconType(topSites[i])});
+      const slotProps = {
+        key: link ? link.url : holeIndex++,
+        index: i
+      };
+      if (i >= maxNarrowVisibleIndex) {
+        slotProps.className = "hide-for-narrow";
+      }
+      topSitesUI.push(!link ? (
+        <TopSitePlaceholder
+          {...slotProps}
+          {...commonProps} />
+      ) : (
+        <TopSite
+          link={link}
+          activeIndex={this.state.activeIndex}
+          onActivate={this.onActivate}
+          {...slotProps}
+          {...commonProps} />
+      ));
+    }
+    return (<ul className={`top-sites-list${this.state.draggedSite ? " dnd-active" : ""}`}>
+      {topSitesUI}
+    </ul>);
+  }
+}
+
+export const TopSiteList = injectIntl(_TopSiteList);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/TopSites/TopSiteForm.jsx
@@ -0,0 +1,251 @@
+import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
+import {FormattedMessage} from "react-intl";
+import React from "react";
+import {TOP_SITES_SOURCE} from "./TopSitesConstants";
+import {TopSiteFormInput} from "./TopSiteFormInput";
+import {TopSiteLink} from "./TopSite";
+
+export class TopSiteForm extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    const {site} = props;
+    this.state = {
+      label: site ? (site.label || site.hostname) : "",
+      url: site ? site.url : "",
+      validationError: false,
+      customScreenshotUrl: site ? site.customScreenshotURL : "",
+      showCustomScreenshotForm: site ? site.customScreenshotURL : false
+    };
+    this.onClearScreenshotInput = this.onClearScreenshotInput.bind(this);
+    this.onLabelChange = this.onLabelChange.bind(this);
+    this.onUrlChange = this.onUrlChange.bind(this);
+    this.onCancelButtonClick = this.onCancelButtonClick.bind(this);
+    this.onClearUrlClick = this.onClearUrlClick.bind(this);
+    this.onDoneButtonClick = this.onDoneButtonClick.bind(this);
+    this.onCustomScreenshotUrlChange = this.onCustomScreenshotUrlChange.bind(this);
+    this.onPreviewButtonClick = this.onPreviewButtonClick.bind(this);
+    this.onEnableScreenshotUrlForm = this.onEnableScreenshotUrlForm.bind(this);
+    this.validateUrl = this.validateUrl.bind(this);
+  }
+
+  onLabelChange(event) {
+    this.setState({"label": event.target.value});
+  }
+
+  onUrlChange(event) {
+    this.setState({
+      url: event.target.value,
+      validationError: false
+    });
+  }
+
+  onClearUrlClick() {
+    this.setState({
+      url: "",
+      validationError: false
+    });
+  }
+
+  onEnableScreenshotUrlForm() {
+    this.setState({showCustomScreenshotForm: true});
+  }
+
+  _updateCustomScreenshotInput(customScreenshotUrl) {
+    this.setState({
+      customScreenshotUrl,
+      validationError: false
+    });
+    this.props.dispatch({type: at.PREVIEW_REQUEST_CANCEL});
+  }
+
+  onCustomScreenshotUrlChange(event) {
+    this._updateCustomScreenshotInput(event.target.value);
+  }
+
+  onClearScreenshotInput() {
+    this._updateCustomScreenshotInput("");
+  }
+
+  onCancelButtonClick(ev) {
+    ev.preventDefault();
+    this.props.onClose();
+  }
+
+  onDoneButtonClick(ev) {
+    ev.preventDefault();
+
+    if (this.validateForm()) {
+      const site = {url: this.cleanUrl(this.state.url)};
+      const {index} = this.props;
+      if (this.state.label !== "") {
+        site.label = this.state.label;
+      }
+
+      if (this.state.customScreenshotUrl) {
+        site.customScreenshotURL = this.cleanUrl(this.state.customScreenshotUrl);
+      } else if (this.props.site && this.props.site.customScreenshotURL) {
+        // Used to flag that previously cached screenshot should be removed
+        site.customScreenshotURL = null;
+      }
+      this.props.dispatch(ac.AlsoToMain({
+        type: at.TOP_SITES_PIN,
+        data: {site, index}
+      }));
+      this.props.dispatch(ac.UserEvent({
+        source: TOP_SITES_SOURCE,
+        event: "TOP_SITES_EDIT",
+        action_position: index
+      }));
+
+      this.props.onClose();
+    }
+  }
+
+  onPreviewButtonClick(event) {
+    event.preventDefault();
+    if (this.validateForm()) {
+      this.props.dispatch(ac.AlsoToMain({
+        type: at.PREVIEW_REQUEST,
+        data: {url: this.cleanUrl(this.state.customScreenshotUrl)}
+      }));
+      this.props.dispatch(ac.UserEvent({
+        source: TOP_SITES_SOURCE,
+        event: "PREVIEW_REQUEST"
+      }));
+    }
+  }
+
+  cleanUrl(url) {
+    // If we are missing a protocol, prepend http://
+    if (!url.startsWith("http:") && !url.startsWith("https:")) {
+      return `http://${url}`;
+    }
+    return url;
+  }
+
+  _tryParseUrl(url) {
+    try {
+      return new URL(url);
+    } catch (e) {
+      return null;
+    }
+  }
+
+  validateUrl(url) {
+    const validProtocols = ["http:", "https:"];
+    const urlObj = this._tryParseUrl(url) || this._tryParseUrl(this.cleanUrl(url));
+
+    return urlObj && validProtocols.includes(urlObj.protocol);
+  }
+
+  validateCustomScreenshotUrl() {
+    const {customScreenshotUrl} = this.state;
+    return !customScreenshotUrl || this.validateUrl(customScreenshotUrl);
+  }
+
+  validateForm() {
+    const validate = this.validateUrl(this.state.url) && this.validateCustomScreenshotUrl();
+
+    if (!validate) {
+      this.setState({validationError: true});
+    }
+
+    return validate;
+  }
+
+  _renderCustomScreenshotInput() {
+    const {customScreenshotUrl} = this.state;
+    const requestFailed = this.props.previewResponse === "";
+    const validationError = (this.state.validationError && !this.validateCustomScreenshotUrl()) || requestFailed;
+    // Set focus on error if the url field is valid or when the input is first rendered and is empty
+    const shouldFocus = (validationError && this.validateUrl(this.state.url)) || !customScreenshotUrl;
+    const isLoading = this.props.previewResponse === null &&
+      customScreenshotUrl && this.props.previewUrl === this.cleanUrl(customScreenshotUrl);
+
+    if (!this.state.showCustomScreenshotForm) {
+      return (<a className="enable-custom-image-input" onClick={this.onEnableScreenshotUrlForm}>
+        <FormattedMessage id="topsites_form_use_image_link" />
+      </a>);
+    }
+    return (<div className="custom-image-input-container">
+      <TopSiteFormInput
+        errorMessageId={requestFailed ? "topsites_form_image_validation" : "topsites_form_url_validation"}
+        loading={isLoading}
+        onChange={this.onCustomScreenshotUrlChange}
+        onClear={this.onClearScreenshotInput}
+        shouldFocus={shouldFocus}
+        typeUrl={true}
+        value={customScreenshotUrl}
+        validationError={validationError}
+        titleId="topsites_form_image_url_label"
+        placeholderId="topsites_form_url_placeholder"
+        intl={this.props.intl} />
+    </div>);
+  }
+
+  render() {
+    const {customScreenshotUrl} = this.state;
+    const requestFailed = this.props.previewResponse === "";
+    // For UI purposes, editing without an existing link is "add"
+    const showAsAdd = !this.props.site;
+    const previous = (this.props.site && this.props.site.customScreenshotURL) || "";
+    const changed = customScreenshotUrl && this.cleanUrl(customScreenshotUrl) !== previous;
+    // Preview mode if changes were made to the custom screenshot URL and no preview was received yet
+    // or the request failed
+    const previewMode = changed && !this.props.previewResponse;
+    const previewLink = Object.assign({}, this.props.site);
+    if (this.props.previewResponse) {
+      previewLink.screenshot = this.props.previewResponse;
+      previewLink.customScreenshotURL = this.props.previewUrl;
+    }
+    return (
+      <form className="topsite-form">
+        <div className="form-input-container">
+          <h3 className="section-title">
+            <FormattedMessage id={showAsAdd ? "topsites_form_add_header" : "topsites_form_edit_header"} />
+          </h3>
+          <div className="fields-and-preview">
+            <div className="form-wrapper">
+              <TopSiteFormInput onChange={this.onLabelChange}
+                value={this.state.label}
+                titleId="topsites_form_title_label"
+                placeholderId="topsites_form_title_placeholder"
+                intl={this.props.intl} />
+              <TopSiteFormInput onChange={this.onUrlChange}
+                shouldFocus={this.state.validationError && !this.validateUrl(this.state.url)}
+                value={this.state.url}
+                onClear={this.onClearUrlClick}
+                validationError={this.state.validationError && !this.validateUrl(this.state.url)}
+                titleId="topsites_form_url_label"
+                typeUrl={true}
+                placeholderId="topsites_form_url_placeholder"
+                errorMessageId="topsites_form_url_validation"
+                intl={this.props.intl} />
+              {this._renderCustomScreenshotInput()}
+            </div>
+            <TopSiteLink link={previewLink}
+              defaultStyle={requestFailed}
+              title={this.state.label} />
+          </div>
+        </div>
+        <section className="actions">
+          <button className="cancel" type="button" onClick={this.onCancelButtonClick}>
+            <FormattedMessage id="topsites_form_cancel_button" />
+          </button>
+          {previewMode ?
+            <button className="done preview" type="submit" onClick={this.onPreviewButtonClick}>
+              <FormattedMessage id="topsites_form_preview_button" />
+            </button> :
+            <button className="done" type="submit" onClick={this.onDoneButtonClick}>
+              <FormattedMessage id={showAsAdd ? "topsites_form_add_button" : "topsites_form_save_button"} />
+            </button>}
+        </section>
+      </form>
+    );
+  }
+}
+
+TopSiteForm.defaultProps = {
+  site: null,
+  index: -1
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/TopSites/TopSiteFormInput.jsx
@@ -0,0 +1,66 @@
+import {FormattedMessage} from "react-intl";
+import React from "react";
+
+export class TopSiteFormInput extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.state = {validationError: this.props.validationError};
+    this.onChange = this.onChange.bind(this);
+    this.onMount = this.onMount.bind(this);
+  }
+
+  componentWillReceiveProps(nextProps) {
+    if (nextProps.shouldFocus && !this.props.shouldFocus) {
+      this.input.focus();
+    }
+    if (nextProps.validationError && !this.props.validationError) {
+      this.setState({validationError: true});
+    }
+    // If the component is in an error state but the value was cleared by the parent
+    if (this.state.validationError && !nextProps.value) {
+      this.setState({validationError: false});
+    }
+  }
+
+  onChange(ev) {
+    if (this.state.validationError) {
+      this.setState({validationError: false});
+    }
+    this.props.onChange(ev);
+  }
+
+  onMount(input) {
+    this.input = input;
+  }
+
+  render() {
+    const showClearButton = this.props.value && this.props.onClear;
+    const {typeUrl} = this.props;
+    const {validationError} = this.state;
+
+    return (<label><FormattedMessage id={this.props.titleId} />
+      <div className={`field ${typeUrl ? "url" : ""}${validationError ? " invalid" : ""}`}>
+        {this.props.loading ?
+          <div className="loading-container"><div className="loading-animation" /></div> :
+          showClearButton && <div className="icon icon-clear-input" onClick={this.props.onClear} />}
+        <input type="text"
+          value={this.props.value}
+          ref={this.onMount}
+          onChange={this.onChange}
+          placeholder={this.props.intl.formatMessage({id: this.props.placeholderId})}
+          autoFocus={this.props.shouldFocus}
+          disabled={this.props.loading} />
+        {validationError &&
+          <aside className="error-tooltip">
+            <FormattedMessage id={this.props.errorMessageId} />
+          </aside>}
+      </div>
+    </label>);
+  }
+}
+
+TopSiteFormInput.defaultProps = {
+  showClearButton: false,
+  value: "",
+  validationError: false
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/TopSites/TopSites.jsx
@@ -0,0 +1,143 @@
+import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
+import {MIN_CORNER_FAVICON_SIZE, MIN_RICH_FAVICON_SIZE, TOP_SITES_SOURCE} from "./TopSitesConstants";
+import {CollapsibleSection} from "content-src/components/CollapsibleSection/CollapsibleSection";
+import {ComponentPerfTimer} from "content-src/components/ComponentPerfTimer/ComponentPerfTimer";
+import {connect} from "react-redux";
+import {injectIntl} from "react-intl";
+import React from "react";
+import {TOP_SITES_MAX_SITES_PER_ROW} from "common/Reducers.jsm";
+import {TopSiteForm} from "./TopSiteForm";
+import {TopSiteList} from "./TopSite";
+
+function topSiteIconType(link) {
+  if (link.customScreenshotURL) {
+    return "custom_screenshot";
+  }
+  if (link.tippyTopIcon || link.faviconRef === "tippytop") {
+    return "tippytop";
+  }
+  if (link.faviconSize >= MIN_RICH_FAVICON_SIZE) {
+    return "rich_icon";
+  }
+  if (link.screenshot && link.faviconSize >= MIN_CORNER_FAVICON_SIZE) {
+    return "screenshot_with_icon";
+  }
+  if (link.screenshot) {
+    return "screenshot";
+  }
+  return "no_image";
+}
+
+/**
+ * Iterates through TopSites and counts types of images.
+ * @param acc Accumulator for reducer.
+ * @param topsite Entry in TopSites.
+ */
+function countTopSitesIconsTypes(topSites) {
+  const countTopSitesTypes = (acc, link) => {
+    acc[topSiteIconType(link)]++;
+    return acc;
+  };
+
+  return topSites.reduce(countTopSitesTypes, {
+    "custom_screenshot": 0,
+    "screenshot_with_icon": 0,
+    "screenshot": 0,
+    "tippytop": 0,
+    "rich_icon": 0,
+    "no_image": 0
+  });
+}
+
+export class _TopSites extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onFormClose = this.onFormClose.bind(this);
+  }
+
+  /**
+   * Dispatch session statistics about the quality of TopSites icons and pinned count.
+   */
+  _dispatchTopSitesStats() {
+    const topSites = this._getVisibleTopSites();
+    const topSitesIconsStats = countTopSitesIconsTypes(topSites);
+    const topSitesPinned = topSites.filter(site => !!site.isPinned).length;
+    // Dispatch telemetry event with the count of TopSites images types.
+    this.props.dispatch(ac.AlsoToMain({
+      type: at.SAVE_SESSION_PERF_DATA,
+      data: {topsites_icon_stats: topSitesIconsStats, topsites_pinned: topSitesPinned}
+    }));
+  }
+
+  /**
+   * Return the TopSites that are visible based on prefs and window width.
+   */
+  _getVisibleTopSites() {
+    // We hide 2 sites per row when not in the wide layout.
+    let sitesPerRow = TOP_SITES_MAX_SITES_PER_ROW;
+    // $break-point-widest = 1072px (from _variables.scss)
+    if (!global.matchMedia(`(min-width: 1072px)`).matches) {
+      sitesPerRow -= 2;
+    }
+    return this.props.TopSites.rows.slice(0, this.props.TopSitesRows * sitesPerRow);
+  }
+
+  componentDidUpdate() {
+    this._dispatchTopSitesStats();
+  }
+
+  componentDidMount() {
+    this._dispatchTopSitesStats();
+  }
+
+  onFormClose() {
+    this.props.dispatch(ac.UserEvent({
+      source: TOP_SITES_SOURCE,
+      event: "TOP_SITES_EDIT_CLOSE"
+    }));
+    this.props.dispatch({type: at.TOP_SITES_CANCEL_EDIT});
+  }
+
+  render() {
+    const {props} = this;
+    const {editForm} = props.TopSites;
+
+    return (<ComponentPerfTimer id="topsites" initialized={props.TopSites.initialized} dispatch={props.dispatch}>
+      <CollapsibleSection
+        className="top-sites"
+        icon="topsites"
+        id="topsites"
+        title={{id: "header_top_sites"}}
+        extraMenuOptions={["AddTopSite"]}
+        showPrefName="feeds.topsites"
+        eventSource={TOP_SITES_SOURCE}
+        collapsed={props.TopSites.pref ? props.TopSites.pref.collapsed : undefined}
+        isFirst={props.isFirst}
+        isLast={props.isLast}
+        dispatch={props.dispatch}>
+        <TopSiteList TopSites={props.TopSites} TopSitesRows={props.TopSitesRows} dispatch={props.dispatch} intl={props.intl} topSiteIconType={topSiteIconType} />
+        <div className="edit-topsites-wrapper">
+          {editForm &&
+            <div className="edit-topsites">
+              <div className="modal-overlay" onClick={this.onFormClose} />
+              <div className="modal">
+                <TopSiteForm
+                  site={props.TopSites.rows[editForm.index]}
+                  onClose={this.onFormClose}
+                  dispatch={this.props.dispatch}
+                  intl={this.props.intl}
+                  {...editForm} />
+              </div>
+            </div>
+          }
+        </div>
+      </CollapsibleSection>
+    </ComponentPerfTimer>);
+  }
+}
+
+export const TopSites = connect(state => ({
+  TopSites: state.TopSites,
+  Prefs: state.Prefs,
+  TopSitesRows: state.Prefs.values.topSitesRows
+}))(injectIntl(_TopSites));
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/TopSites/TopSitesConstants.js
@@ -0,0 +1,7 @@
+export const TOP_SITES_SOURCE = "TOP_SITES";
+export const TOP_SITES_CONTEXT_MENU_OPTIONS = ["CheckPinTopSite", "EditTopSite", "Separator",
+  "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl", "DeleteUrl"];
+// minimum size necessary to show a rich icon instead of a screenshot
+export const MIN_RICH_FAVICON_SIZE = 96;
+// minimum size necessary to show any icon in the top left corner with a screenshot
+export const MIN_CORNER_FAVICON_SIZE = 16;
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/TopSites/_TopSites.scss
@@ -0,0 +1,499 @@
+$top-sites-size: $grid-unit;
+$top-sites-border-radius: 6px;
+$top-sites-title-height: 30px;
+$top-sites-vertical-space: 8px;
+$screenshot-size: cover;
+$rich-icon-size: 96px;
+$default-icon-wrapper-size: 42px;
+$default-icon-size: 32px;
+$default-icon-offset: 6px;
+$half-base-gutter: $base-gutter / 2;
+
+.top-sites {
+  // Take back the margin from the bottom row of vertical spacing as well as the
+  // extra whitespace below the title text as it's vertically centered.
+  margin-bottom: $section-spacing - ($top-sites-vertical-space + $top-sites-title-height / 3);
+}
+
+.top-sites-list {
+  list-style: none;
+  margin: 0 (-$half-base-gutter);
+  padding: 0;
+
+  // Two columns
+  @media (max-width: $break-point-small) {
+    :nth-child(2n+1) {
+      @include context-menu-open-middle;
+    }
+
+    :nth-child(2n) {
+      @include context-menu-open-left;
+    }
+  }
+
+  // Three columns
+  @media (min-width: $break-point-small) and (max-width: $break-point-medium) {
+    :nth-child(3n+2),
+    :nth-child(3n) {
+      @include context-menu-open-left;
+    }
+  }
+
+  // Four columns
+  @media (min-width: $break-point-medium) and (max-width: $break-point-large) {
+    :nth-child(4n) {
+      @include context-menu-open-left;
+    }
+  }
+  @media (min-width: $break-point-medium) and (max-width: $break-point-medium + $card-width) {
+    :nth-child(4n+3) {
+      @include context-menu-open-left;
+    }
+  }
+
+  // Six columns
+  @media (min-width: $break-point-large) and (max-width: $break-point-large + 2 * $card-width) {
+    :nth-child(6n) {
+      @include context-menu-open-left;
+    }
+  }
+  @media (min-width: $break-point-large) and (max-width: $break-point-large + $card-width) {
+    :nth-child(6n+5) {
+      @include context-menu-open-left;
+    }
+  }
+
+  li {
+    margin: 0 0 $top-sites-vertical-space;
+  }
+
+  &:not(.dnd-active) {
+    .top-site-outer:-moz-any(.active, :focus, :hover) {
+      .tile {
+        @include fade-in;
+      }
+
+      @include context-menu-button-hover;
+    }
+  }
+}
+
+// container for drop zone
+.top-site-outer {
+  padding: 0 $half-base-gutter;
+  display: inline-block;
+
+  // container for context menu
+  .top-site-inner {
+    position: relative;
+
+    > a {
+      color: inherit;
+      display: block;
+      outline: none;
+
+      &:-moz-any(.active, :focus) {
+        .tile {
+          @include fade-in;
+        }
+      }
+    }
+  }
+
+  @include context-menu-button;
+
+  .tile { // sass-lint:disable-block property-sort-order
+    border-radius: $top-sites-border-radius;
+    box-shadow: inset $inner-box-shadow, var(--newtab-card-shadow);
+    height: $top-sites-size;
+    position: relative;
+    width: $top-sites-size;
+
+    // For letter fallback
+    align-items: center;
+    color: var(--newtab-text-secondary-color);
+    display: flex;
+    font-size: 32px;
+    font-weight: 200;
+    justify-content: center;
+    text-transform: uppercase;
+
+    &::before {
+      content: attr(data-fallback);
+    }
+  }
+
+  .screenshot {
+    background-color: $white;
+    background-position: top left;
+    background-size: $screenshot-size;
+    border-radius: $top-sites-border-radius;
+    box-shadow: inset $inner-box-shadow;
+    height: 100%;
+    left: 0;
+    opacity: 0;
+    position: absolute;
+    top: 0;
+    transition: opacity 1s;
+    width: 100%;
+
+    &.active {
+      opacity: 1;
+    }
+  }
+
+  // Some common styles for all icons (rich and default) in top sites
+  .top-site-icon {
+    background-color: var(--newtab-topsites-background-color);
+    background-position: center center;
+    background-repeat: no-repeat;
+    border-radius: $top-sites-border-radius;
+    box-shadow: inset $inner-box-shadow;
+    position: absolute;
+  }
+
+  .rich-icon {
+    background-size: cover;
+    height: 100%;
+    offset-inline-start: 0;
+    top: 0;
+    width: 100%;
+  }
+
+  .default-icon { // sass-lint:disable block property-sort-order
+    background-size: $default-icon-size;
+    bottom: -$default-icon-offset;
+    height: $default-icon-wrapper-size;
+    offset-inline-end: -$default-icon-offset;
+    width: $default-icon-wrapper-size;
+
+    // for corner letter fallback
+    align-items: center;
+    display: flex;
+    font-size: 20px;
+    justify-content: center;
+
+    &[data-fallback]::before {
+      content: attr(data-fallback);
+    }
+  }
+
+  .title {
+    color: var(--newtab-topsites-label-color);
+    font: message-box;
+    height: $top-sites-title-height;
+    line-height: $top-sites-title-height;
+    text-align: center;
+    width: $top-sites-size;
+    position: relative;
+
+    .icon {
+      fill: var(--newtab-icon-tertiary-color);
+      offset-inline-start: 0;
+      position: absolute;
+      top: 10px;
+    }
+
+    span {
+      height: $top-sites-title-height;
+      display: block;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+
+    &.pinned {
+      span {
+        padding: 0 13px;
+      }
+    }
+  }
+
+  .edit-button {
+    background-image: url('#{$image-path}glyph-edit-16.svg');
+  }
+
+  &.placeholder {
+    .tile {
+      box-shadow: inset $inner-box-shadow;
+    }
+
+    .screenshot {
+      display: none;
+    }
+  }
+
+  &.dragged {
+    .tile {
+      background: $grey-20;
+      box-shadow: none;
+
+      *,
+      &::before {
+        display: none;
+      }
+    }
+
+    .title {
+      visibility: hidden;
+    }
+  }
+}
+
+// Always hide .hide-for-narrow if wide layout is disabled
+.wide-layout-disabled {
+  .top-sites-list {
+    .hide-for-narrow {
+      display: none;
+    }
+  }
+}
+
+.wide-layout-enabled {
+  .top-sites-list {
+    // Eight columns
+    @media (min-width: $break-point-widest) and (max-width: $break-point-widest + 2 * $card-width) {
+      :nth-child(8n) {
+        @include context-menu-open-left;
+      }
+    }
+
+    @media (min-width: $break-point-widest) and (max-width: $break-point-widest + $card-width) {
+      :nth-child(8n+7) {
+        @include context-menu-open-left;
+      }
+    }
+
+    @media not all and (min-width: $break-point-widest) {
+      .hide-for-narrow {
+        display: none;
+      }
+    }
+  }
+}
+
+.edit-topsites-wrapper {
+  .modal {
+    box-shadow: $shadow-secondary;
+    left: 0;
+    margin: 0 auto;
+    position: fixed;
+    right: 0;
+    top: 40px;
+    width: $wrapper-default-width;
+
+    @media (min-width: $break-point-small) {
+      width: $wrapper-max-width-small;
+    }
+
+    @media (min-width: $break-point-medium) {
+      width: $wrapper-max-width-medium;
+    }
+
+    @media (min-width: $break-point-large) {
+      width: $wrapper-max-width-large;
+    }
+  }
+}
+
+.topsite-form {
+  $form-width: 300px;
+  $form-spacing: 32px;
+
+  .form-input-container {
+    max-width: $form-width + 3 * $form-spacing + $rich-icon-size;
+    margin: 0 auto;
+    padding: $form-spacing;
+
+    .top-site-outer {
+      padding: 0;
+      margin: 24px 0 0;
+      margin-inline-start: $form-spacing;
+      pointer-events: none;
+    }
+
+    .section-title {
+      text-transform: none;
+      font-size: 16px;
+      margin: 0 0 16px;
+    }
+  }
+
+  .fields-and-preview {
+    display: flex;
+  }
+
+  label {
+    font-size: $section-title-font-size;
+  }
+
+  .form-wrapper {
+    width: 100%;
+
+    .field {
+      position: relative;
+
+      .icon-clear-input {
+        position: absolute;
+        transform: translateY(-50%);
+        top: 50%;
+        offset-inline-end: 8px;
+      }
+    }
+
+    .url {
+      input:dir(ltr) {
+        padding-right: 32px;
+      }
+
+      input:dir(rtl) {
+        padding-left: 32px;
+
+        &:not(:placeholder-shown) {
+          direction: ltr;
+          text-align: right;
+        }
+      }
+    }
+
+    .enable-custom-image-input {
+      display: inline-block;
+      font-size: 13px;
+      margin-top: 4px;
+      cursor: pointer;
+
+      &:hover {
+        text-decoration: underline;
+      }
+    }
+
+    .custom-image-input-container {
+      margin-top: 4px;
+
+      .loading-container {
+        width: 16px;
+        height: 16px;
+        overflow: hidden;
+        position: absolute;
+        transform: translateY(-50%);
+        top: 50%;
+        offset-inline-end: 8px;
+      }
+
+      // This animation is derived from Firefox's tab loading animation
+      // See https://searchfox.org/mozilla-central/rev/b29daa46443b30612415c35be0a3c9c13b9dc5f6/browser/themes/shared/tabs.inc.css#208-216
+      .loading-animation {
+        @keyframes tab-throbber-animation {
+          100% { transform: translateX(-960px); }
+        }
+
+        @keyframes tab-throbber-animation-rtl {
+          100% { transform: translateX(960px); }
+        }
+
+        width: 960px;
+        height: 16px;
+        -moz-context-properties: fill;
+        fill: $blue-50;
+        background-image: url('chrome://browser/skin/tabbrowser/loading.svg');
+        animation: tab-throbber-animation 1.05s steps(60) infinite;
+
+        &:dir(rtl) {
+          animation-name: tab-throbber-animation-rtl;
+        }
+      }
+    }
+
+    input {
+      &[type='text'] {
+        background-color: var(--newtab-textbox-background-color);
+        border: $input-border;
+        margin: 8px 0;
+        padding: 0 8px;
+        height: 32px;
+        width: 100%;
+        font-size: 15px;
+
+        &:focus {
+          border: $input-border-active;
+          box-shadow: var(--newtab-textbox-focus-boxshadow);
+        }
+
+        &[disabled] {
+          border: $input-border;
+          box-shadow: none;
+          opacity: 0.4;
+        }
+      }
+    }
+
+    .invalid {
+      input {
+        &[type='text'] {
+          border: $input-error-border;
+          box-shadow: $input-error-boxshadow;
+        }
+      }
+    }
+
+    .error-tooltip {
+      animation: fade-up-tt 450ms;
+      background: $red-60;
+      border-radius: 2px;
+      color: $white;
+      offset-inline-start: 3px;
+      padding: 5px 12px;
+      position: absolute;
+      top: 44px;
+      z-index: 1;
+
+      // tooltip caret
+      &::before {
+        background: $red-60;
+        bottom: -8px;
+        content: '.';
+        height: 16px;
+        offset-inline-start: 12px;
+        position: absolute;
+        text-indent: -999px;
+        top: -7px;
+        transform: rotate(45deg);
+        white-space: nowrap;
+        width: 16px;
+        z-index: -1;
+      }
+    }
+  }
+
+  .actions {
+    justify-content: flex-end;
+
+    button {
+      margin-inline-start: 10px;
+      margin-inline-end: 0;
+    }
+  }
+
+  @media (max-width: $break-point-small) {
+    .fields-and-preview {
+      flex-direction: column;
+
+      .top-site-outer {
+        margin-inline-start: 0;
+      }
+    }
+  }
+}
+
+//used for tooltips below form element
+@keyframes fade-up-tt {
+  0% {
+    opacity: 0;
+    transform: translateY(15px);
+  }
+
+  100% {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/Topics/Topics.jsx
@@ -0,0 +1,25 @@
+import {FormattedMessage} from "react-intl";
+import React from "react";
+
+export class Topic extends React.PureComponent {
+  render() {
+    const {url, name} = this.props;
+    return (<li><a key={name} className="topic-link" href={url}>{name}</a></li>);
+  }
+}
+
+export class Topics extends React.PureComponent {
+  render() {
+    const {topics, read_more_endpoint} = this.props;
+    return (
+      <div className="topic">
+        <span><FormattedMessage id="pocket_read_more" /></span>
+        <ul>{topics && topics.map(t => <Topic key={t.name} url={t.url} name={t.name} />)}</ul>
+
+        {read_more_endpoint && <a className="topic-read-more" href={read_more_endpoint}>
+          <FormattedMessage id="pocket_read_even_more" />
+        </a>}
+      </div>
+    );
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/components/Topics/_Topics.scss
@@ -0,0 +1,77 @@
+.topic {
+  color: var(--newtab-section-navigation-text-color);
+  font-size: 12px;
+  line-height: 1.6;
+  margin-top: $topic-margin-top;
+
+  @media (min-width: $break-point-large) {
+    line-height: 16px;
+  }
+
+  ul {
+    margin: 0;
+    padding: 0;
+    @media (min-width: $break-point-large) {
+      display: inline;
+      padding-inline-start: 12px;
+    }
+  }
+
+
+  ul li {
+    display: inline-block;
+
+    &::after {
+      content: '•';
+      padding: 8px;
+    }
+
+    &:last-child::after {
+      content: none;
+    }
+  }
+
+  .topic-link {
+    color: var(--newtab-link-secondary-color);
+    font-weight: bold;
+  }
+
+  .topic-read-more {
+    color: var(--newtab-link-secondary-color);
+    font-weight: bold;
+
+    @media (min-width: $break-point-large) {
+      // This is floating to accomodate a very large number of topics and/or
+      // very long topic names due to l10n.
+      float: right;
+
+      &:dir(rtl) {
+        float: left;
+      }
+    }
+
+    &::after {
+      background: url('#{$image-path}topic-show-more-12.svg') no-repeat center center;
+      content: '';
+      -moz-context-properties: fill;
+      display: inline-block;
+      fill: var(--newtab-link-secondary-color);
+      height: 16px;
+      margin-inline-start: 5px;
+      vertical-align: top;
+      width: 12px;
+    }
+
+    &:dir(rtl)::after  {
+      transform: scaleX(-1);
+    }
+  }
+
+  // This is a clearfix to for the topics-read-more link which is floating and causes
+  // some jank when we set overflow:hidden for the animation.
+  &::after {
+    clear: both;
+    content: '';
+    display: table;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/lib/constants.js
@@ -0,0 +1,1 @@
+export const IS_NEWTAB = global.document && global.document.documentURI === "about:newtab";
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/lib/detect-user-session-start.js
@@ -0,0 +1,65 @@
+import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
+import {perfService as perfSvc} from "common/PerfService.jsm";
+
+const VISIBLE = "visible";
+const VISIBILITY_CHANGE_EVENT = "visibilitychange";
+
+export class DetectUserSessionStart {
+  constructor(store, options = {}) {
+    this._store = store;
+    // Overrides for testing
+    this.document = options.document || global.document;
+    this._perfService = options.perfService || perfSvc;
+    this._onVisibilityChange = this._onVisibilityChange.bind(this);
+  }
+
+  /**
+   * sendEventOrAddListener - Notify immediately if the page is already visible,
+   *                    or else set up a listener for when visibility changes.
+   *                    This is needed for accurate session tracking for telemetry,
+   *                    because tabs are pre-loaded.
+   */
+  sendEventOrAddListener() {
+    if (this.document.visibilityState === VISIBLE) {
+      // If the document is already visible, to the user, send a notification
+      // immediately that a session has started.
+      this._sendEvent();
+    } else {
+      // If the document is not visible, listen for when it does become visible.
+      this.document.addEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
+    }
+  }
+
+  /**
+   * _sendEvent - Sends a message to the main process to indicate the current
+   *              tab is now visible to the user, includes the
+   *              visibility_event_rcvd_ts time in ms from the UNIX epoch.
+   */
+  _sendEvent() {
+    this._perfService.mark("visibility_event_rcvd_ts");
+
+    try {
+      let visibility_event_rcvd_ts = this._perfService
+        .getMostRecentAbsMarkStartByName("visibility_event_rcvd_ts");
+
+      this._store.dispatch(ac.AlsoToMain({
+        type: at.SAVE_SESSION_PERF_DATA,
+        data: {visibility_event_rcvd_ts}
+      }));
+    } catch (ex) {
+      // If this failed, it's likely because the `privacy.resistFingerprinting`
+      // pref is true.  We should at least not blow up.
+    }
+  }
+
+  /**
+   * _onVisibilityChange - If the visibility has changed to visible, sends a notification
+   *                      and removes the event listener. This should only be called once per tab.
+   */
+  _onVisibilityChange() {
+    if (this.document.visibilityState === VISIBLE) {
+      this._sendEvent();
+      this.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/lib/init-store.js
@@ -0,0 +1,139 @@
+/* eslint-env mozilla/frame-script */
+
+import {actionCreators as ac, actionTypes as at, actionUtils as au} from "common/Actions.jsm";
+import {applyMiddleware, combineReducers, createStore} from "redux";
+
+export const MERGE_STORE_ACTION = "NEW_TAB_INITIAL_STATE";
+export const OUTGOING_MESSAGE_NAME = "ActivityStream:ContentToMain";
+export const INCOMING_MESSAGE_NAME = "ActivityStream:MainToContent";
+export const EARLY_QUEUED_ACTIONS = [at.SAVE_SESSION_PERF_DATA, at.PAGE_PRERENDERED];
+
+/**
+ * A higher-order function which returns a reducer that, on MERGE_STORE action,
+ * will return the action.data object merged into the previous state.
+ *
+ * For all other actions, it merely calls mainReducer.
+ *
+ * Because we want this to merge the entire state object, it's written as a
+ * higher order function which takes the main reducer (itself often a call to
+ * combineReducers) as a parameter.
+ *
+ * @param  {function} mainReducer reducer to call if action != MERGE_STORE_ACTION
+ * @return {function}             a reducer that, on MERGE_STORE_ACTION action,
+ *                                will return the action.data object merged
+ *                                into the previous state, and the result
+ *                                of calling mainReducer otherwise.
+ */
+function mergeStateReducer(mainReducer) {
+  return (prevState, action) => {
+    if (action.type === MERGE_STORE_ACTION) {
+      return Object.assign({}, prevState, action.data);
+    }
+
+    return mainReducer(prevState, action);
+  };
+}
+
+/**
+ * messageMiddleware - Middleware that looks for SentToMain type actions, and sends them if necessary
+ */
+const messageMiddleware = store => next => action => {
+  const skipLocal = action.meta && action.meta.skipLocal;
+  if (au.isSendToMain(action)) {
+    sendAsyncMessage(OUTGOING_MESSAGE_NAME, action);
+  }
+  if (!skipLocal) {
+    next(action);
+  }
+};
+
+export const rehydrationMiddleware = store => next => action => {
+  if (store._didRehydrate) {
+    return next(action);
+  }
+
+  const isMergeStoreAction = action.type === MERGE_STORE_ACTION;
+  const isRehydrationRequest = action.type === at.NEW_TAB_STATE_REQUEST;
+
+  if (isRehydrationRequest) {
+    store._didRequestInitialState = true;
+    return next(action);
+  }
+
+  if (isMergeStoreAction) {
+    store._didRehydrate = true;
+    return next(action);
+  }
+
+  // If init happened after our request was made, we need to re-request
+  if (store._didRequestInitialState && action.type === at.INIT) {
+    return next(ac.AlsoToMain({type: at.NEW_TAB_STATE_REQUEST}));
+  }
+
+  if (au.isBroadcastToContent(action) || au.isSendToOneContent(action) || au.isSendToPreloaded(action)) {
+    // Note that actions received before didRehydrate will not be dispatched
+    // because this could negatively affect preloading and the the state
+    // will be replaced by rehydration anyway.
+    return null;
+  }
+
+  return next(action);
+};
+
+/**
+ * This middleware queues up all the EARLY_QUEUED_ACTIONS until it receives
+ * the first action from main. This is useful for those actions for main which
+ * require higher reliability, i.e. the action will not be lost in the case
+ * that it gets sent before the main is ready to receive it. Conversely, any
+ * actions allowed early are accepted to be ignorable or re-sendable.
+ */
+export const queueEarlyMessageMiddleware = store => next => action => {
+  if (store._receivedFromMain) {
+    next(action);
+  } else if (au.isFromMain(action)) {
+    next(action);
+    store._receivedFromMain = true;
+    // Sending out all the early actions as main is ready now
+    if (store._earlyActionQueue) {
+      store._earlyActionQueue.forEach(next);
+      store._earlyActionQueue = [];
+    }
+  } else if (EARLY_QUEUED_ACTIONS.includes(action.type)) {
+    store._earlyActionQueue = store._earlyActionQueue || [];
+    store._earlyActionQueue.push(action);
+  } else {
+    // Let any other type of action go through
+    next(action);
+  }
+};
+
+/**
+ * initStore - Create a store and listen for incoming actions
+ *
+ * @param  {object} reducers An object containing Redux reducers
+ * @param  {object} intialState (optional) The initial state of the store, if desired
+ * @return {object}          A redux store
+ */
+export function initStore(reducers, initialState) {
+  const store = createStore(
+    mergeStateReducer(combineReducers(reducers)),
+    initialState,
+    global.addMessageListener && applyMiddleware(rehydrationMiddleware, queueEarlyMessageMiddleware, messageMiddleware)
+  );
+
+  store._didRehydrate = false;
+  store._didRequestInitialState = false;
+
+  if (global.addMessageListener) {
+    global.addMessageListener(INCOMING_MESSAGE_NAME, msg => {
+      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;
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/lib/link-menu-options.js
@@ -0,0 +1,211 @@
+import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
+
+const _OpenInPrivateWindow = site => ({
+  id: "menu_action_open_private_window",
+  icon: "new-window-private",
+  action: ac.OnlyToMain({
+    type: at.OPEN_PRIVATE_WINDOW,
+    data: {url: site.url, referrer: site.referrer}
+  }),
+  userEvent: "OPEN_PRIVATE_WINDOW"
+});
+
+export const GetPlatformString = platform => {
+  switch (platform) {
+    case "win":
+      return "menu_action_show_file_windows";
+    case "macosx":
+      return "menu_action_show_file_mac_os";
+    case "linux":
+      return "menu_action_show_file_linux";
+    default:
+      return "menu_action_show_file_default";
+  }
+};
+
+/**
+ * List of functions that return items that can be included as menu options in a
+ * LinkMenu. All functions take the site as the first parameter, and optionally
+ * the index of the site.
+ */
+export const LinkMenuOptions = {
+  Separator: () => ({type: "separator"}),
+  EmptyItem: () => ({type: "empty"}),
+  RemoveBookmark: site => ({
+    id: "menu_action_remove_bookmark",
+    icon: "bookmark-added",
+    action: ac.AlsoToMain({
+      type: at.DELETE_BOOKMARK_BY_ID,
+      data: site.bookmarkGuid
+    }),
+    userEvent: "BOOKMARK_DELETE"
+  }),
+  AddBookmark: site => ({
+    id: "menu_action_bookmark",
+    icon: "bookmark-hollow",
+    action: ac.AlsoToMain({
+      type: at.BOOKMARK_URL,
+      data: {url: site.url, title: site.title, type: site.type}
+    }),
+    userEvent: "BOOKMARK_ADD"
+  }),
+  OpenInNewWindow: site => ({
+    id: "menu_action_open_new_window",
+    icon: "new-window",
+    action: ac.AlsoToMain({
+      type: at.OPEN_NEW_WINDOW,
+      data: {url: site.url, referrer: site.referrer}
+    }),
+    userEvent: "OPEN_NEW_WINDOW"
+  }),
+  BlockUrl: (site, index, eventSource) => ({
+    id: "menu_action_dismiss",
+    icon: "dismiss",
+    action: ac.AlsoToMain({
+      type: at.BLOCK_URL,
+      data: {url: site.url, pocket_id: site.pocket_id}
+    }),
+    impression: ac.ImpressionStats({
+      source: eventSource,
+      block: 0,
+      tiles: [{id: site.guid, pos: index}]
+    }),
+    userEvent: "BLOCK"
+  }),
+
+  // This is an option for web extentions which will result in remove items from
+  // memory and notify the web extenion, rather than using the built-in block list.
+  WebExtDismiss: (site, index, eventSource) => ({
+    id: "menu_action_webext_dismiss",
+    string_id: "menu_action_dismiss",
+    icon: "dismiss",
+    action: ac.WebExtEvent(at.WEBEXT_DISMISS, {
+      source: eventSource,
+      url: site.url,
+      action_position: index
+    })
+  }),
+  DeleteUrl: (site, index, eventSource, isEnabled, siteInfo) => ({
+    id: "menu_action_delete",
+    icon: "delete",
+    action: {
+      type: at.DIALOG_OPEN,
+      data: {
+        onConfirm: [
+          ac.AlsoToMain({type: at.DELETE_HISTORY_URL, data: {url: site.url, pocket_id: site.pocket_id, forceBlock: site.bookmarkGuid}}),
+          ac.UserEvent(Object.assign({event: "DELETE", source: eventSource, action_position: index}, siteInfo))
+        ],
+        eventSource,
+        body_string_id: ["confirm_history_delete_p1", "confirm_history_delete_notice_p2"],
+        confirm_button_string_id: "menu_action_delete",
+        cancel_button_string_id: "topsites_form_cancel_button",
+        icon: "modal-delete"
+      }
+    },
+    userEvent: "DIALOG_OPEN"
+  }),
+  ShowFile: (site, index, eventSource, isEnabled, siteInfo, platform) => ({
+    id: GetPlatformString(platform),
+    icon: "search",
+    action: ac.OnlyToMain({
+      type: at.SHOW_DOWNLOAD_FILE,
+      data: {url: site.url}
+    })
+  }),
+  OpenFile: site => ({
+    id: "menu_action_open_file",
+    icon: "open-file",
+    action: ac.OnlyToMain({
+      type: at.OPEN_DOWNLOAD_FILE,
+      data: {url: site.url}
+    })
+  }),
+  CopyDownloadLink: site => ({
+    id: "menu_action_copy_download_link",
+    icon: "copy",
+    action: ac.OnlyToMain({
+      type: at.COPY_DOWNLOAD_LINK,
+      data: {url: site.url}
+    })
+  }),
+  GoToDownloadPage: site => ({
+    id: "menu_action_go_to_download_page",
+    icon: "download",
+    action: ac.OnlyToMain({
+      type: at.GO_TO_DOWNLOAD_PAGE,
+      data: {url: site.url}
+    }),
+    disabled: !site.referrer
+  }),
+  RemoveDownload: site => ({
+    id: "menu_action_remove_download",
+    icon: "delete",
+    action: ac.OnlyToMain({
+      type: at.REMOVE_DOWNLOAD_FILE,
+      data: {url: site.url}
+    })
+  }),
+  PinTopSite: (site, index) => ({
+    id: "menu_action_pin",
+    icon: "pin",
+    action: ac.AlsoToMain({
+      type: at.TOP_SITES_PIN,
+      data: {site: {url: site.url}, index}
+    }),
+    userEvent: "PIN"
+  }),
+  UnpinTopSite: site => ({
+    id: "menu_action_unpin",
+    icon: "unpin",
+    action: ac.AlsoToMain({
+      type: at.TOP_SITES_UNPIN,
+      data: {site: {url: site.url}}
+    }),
+    userEvent: "UNPIN"
+  }),
+  SaveToPocket: (site, index, eventSource) => ({
+    id: "menu_action_save_to_pocket",
+    icon: "pocket",
+    action: ac.AlsoToMain({
+      type: at.SAVE_TO_POCKET,
+      data: {site: {url: site.url, title: site.title}}
+    }),
+    impression: ac.ImpressionStats({
+      source: eventSource,
+      pocket: 0,
+      tiles: [{id: site.guid, pos: index}]
+    }),
+    userEvent: "SAVE_TO_POCKET"
+  }),
+  DeleteFromPocket: site => ({
+    id: "menu_action_delete_pocket",
+    icon: "delete",
+    action: ac.AlsoToMain({
+      type: at.DELETE_FROM_POCKET,
+      data: {pocket_id: site.pocket_id}
+    }),
+    userEvent: "DELETE_FROM_POCKET"
+  }),
+  ArchiveFromPocket: site => ({
+    id: "menu_action_archive_pocket",
+    icon: "check",
+    action: ac.AlsoToMain({
+      type: at.ARCHIVE_FROM_POCKET,
+      data: {pocket_id: site.pocket_id}
+    }),
+    userEvent: "ARCHIVE_FROM_POCKET"
+  }),
+  EditTopSite: (site, index) => ({
+    id: "edit_topsites_button_text",
+    icon: "edit",
+    action: {
+      type: at.TOP_SITES_EDIT,
+      data: {index}
+    }
+  }),
+  CheckBookmark: site => (site.bookmarkGuid ? LinkMenuOptions.RemoveBookmark(site) : LinkMenuOptions.AddBookmark(site)),
+  CheckPinTopSite: (site, index) => (site.isPinned ? LinkMenuOptions.UnpinTopSite(site) : LinkMenuOptions.PinTopSite(site, index)),
+  CheckSavedToPocket: (site, index) => (site.pocket_id ? LinkMenuOptions.DeleteFromPocket(site) : LinkMenuOptions.SaveToPocket(site, index)),
+  CheckBookmarkOrArchive: site => (site.pocket_id ? LinkMenuOptions.ArchiveFromPocket(site) : LinkMenuOptions.CheckBookmark(site)),
+  OpenInPrivateWindow: (site, index, eventSource, isEnabled) => (isEnabled ? _OpenInPrivateWindow(site) : LinkMenuOptions.EmptyItem())
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/lib/section-menu-options.js
@@ -0,0 +1,69 @@
+import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
+
+/**
+ * List of functions that return items that can be included as menu options in a
+ * SectionMenu. All functions take the section as the only parameter.
+ */
+export const SectionMenuOptions = {
+  Separator: () => ({type: "separator"}),
+  MoveUp: section => ({
+    id: "section_menu_action_move_up",
+    icon: "arrowhead-up",
+    action: ac.OnlyToMain({
+      type: at.SECTION_MOVE,
+      data: {id: section.id, direction: -1}
+    }),
+    userEvent: "MENU_MOVE_UP",
+    disabled: !!section.isFirst
+  }),
+  MoveDown: section => ({
+    id: "section_menu_action_move_down",
+    icon: "arrowhead-down",
+    action: ac.OnlyToMain({
+      type: at.SECTION_MOVE,
+      data: {id: section.id, direction: +1}
+    }),
+    userEvent: "MENU_MOVE_DOWN",
+    disabled: !!section.isLast
+  }),
+  RemoveSection: section => ({
+    id: "section_menu_action_remove_section",
+    icon: "dismiss",
+    action: ac.SetPref(section.showPrefName, false),
+    userEvent: "MENU_REMOVE"
+  }),
+  CollapseSection: section => ({
+    id: "section_menu_action_collapse_section",
+    icon: "minimize",
+    action: ac.OnlyToMain({type: at.UPDATE_SECTION_PREFS, data: {id: section.id, value: {collapsed: true}}}),
+    userEvent: "MENU_COLLAPSE"
+  }),
+  ExpandSection: section => ({
+    id: "section_menu_action_expand_section",
+    icon: "maximize",
+    action: ac.OnlyToMain({type: at.UPDATE_SECTION_PREFS, data: {id: section.id, value: {collapsed: false}}}),
+    userEvent: "MENU_EXPAND"
+  }),
+  ManageSection: section => ({
+    id: "section_menu_action_manage_section",
+    icon: "settings",
+    action: ac.OnlyToMain({type: at.SETTINGS_OPEN}),
+    userEvent: "MENU_MANAGE"
+  }),
+  AddTopSite: section => ({
+    id: "section_menu_action_add_topsite",
+    icon: "add",
+    action: {type: at.TOP_SITES_EDIT, data: {index: -1}},
+    userEvent: "MENU_ADD_TOPSITE"
+  }),
+  PrivacyNotice: section => ({
+    id: "section_menu_action_privacy_notice",
+    icon: "info",
+    action: ac.OnlyToMain({
+      type: at.OPEN_LINK,
+      data: {url: section.privacyNoticeURL}
+    }),
+    userEvent: "MENU_PRIVACY_NOTICE"
+  }),
+  CheckCollapsed: section => (section.collapsed ? SectionMenuOptions.ExpandSection(section) : SectionMenuOptions.CollapseSection(section))
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/lib/snippets.js
@@ -0,0 +1,403 @@
+const DATABASE_NAME = "snippets_db";
+const DATABASE_VERSION = 1;
+const SNIPPETS_OBJECTSTORE_NAME = "snippets";
+export const SNIPPETS_UPDATE_INTERVAL_MS = 14400000; // 4 hours.
+
+const SNIPPETS_ENABLED_EVENT = "Snippets:Enabled";
+const SNIPPETS_DISABLED_EVENT = "Snippets:Disabled";
+
+import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
+import {initMessageCenter} from "content-src/message-center/message-center-content";
+
+/**
+ * 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.
+ *
+ */
+export class SnippetsMap extends Map {
+  constructor(dispatch) {
+    super();
+    this._db = null;
+    this._dispatch = dispatch;
+  }
+
+  set(key, value) {
+    super.set(key, value);
+    return this._dbTransaction(db => db.put(value, key));
+  }
+
+  delete(key) {
+    super.delete(key);
+    return this._dbTransaction(db => db.delete(key));
+  }
+
+  clear() {
+    super.clear();
+    this._dispatch(ac.OnlyToMain({type: at.SNIPPETS_BLOCKLIST_CLEARED}));
+    return this._dbTransaction(db => db.clear());
+  }
+
+  get blockList() {
+    return this.get("blockList") || [];
+  }
+
+  /**
+   * blockSnippetById - Blocks a snippet given an id
+   *
+   * @param  {str|int} id   The id of the snippet
+   * @return {Promise}      Resolves when the id has been written to indexedDB,
+   *                        or immediately if the snippetMap is not connected
+   */
+  async blockSnippetById(id) {
+    if (!id) {
+      return;
+    }
+    const {blockList} = this;
+    if (!blockList.includes(id)) {
+      blockList.push(id);
+      this._dispatch(ac.AlsoToMain({type: at.SNIPPETS_BLOCKLIST_UPDATED, data: id}));
+      await this.set("blockList", blockList);
+    }
+  }
+
+  disableOnboarding() {
+    this._dispatch(ac.AlsoToMain({type: at.DISABLE_ONBOARDING}));
+  }
+
+  showFirefoxAccounts() {
+    this._dispatch(ac.AlsoToMain({type: at.SHOW_FIREFOX_ACCOUNTS}));
+  }
+
+  getTotalBookmarksCount() {
+    return new Promise(resolve => {
+      this._dispatch(ac.OnlyToMain({type: at.TOTAL_BOOKMARKS_REQUEST}));
+      global.addMessageListener("ActivityStream:MainToContent", function onMessage({data: action}) {
+        if (action.type === at.TOTAL_BOOKMARKS_RESPONSE) {
+          resolve(action.data);
+          global.removeMessageListener("ActivityStream:MainToContent", onMessage);
+        }
+      });
+    });
+  }
+
+  /**
+   * 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) {
+          if (cursor.value !== "blockList") {
+            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.
+ */
+export class SnippetsProvider {
+  constructor(dispatch) {
+    // Initialize the Snippets Map and attaches it to a global so that
+    // the snippet payload can interact with it.
+    global.gSnippetsMap = new SnippetsMap(dispatch);
+    this._onAction = this._onAction.bind(this);
+  }
+
+  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.appData.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.appData.snippetsURL) {
+      this.snippetsMap.set("snippets-last-update", Date.now());
+      try {
+        const response = await fetch(this.appData.snippetsURL);
+        if (response.status === 200) {
+          const payload = await response.text();
+
+          this.snippetsMap.set("snippets", payload);
+          this.snippetsMap.set("snippets-cached-version", this.appData.version);
+        }
+      } catch (e) {
+        console.error(e); // eslint-disable-line no-console
+      }
+    }
+  }
+
+  _noSnippetFallback() {
+    // TODO
+  }
+
+  _forceOnboardingVisibility(shouldBeVisible) {
+    const onboardingEl = document.getElementById("onboarding-notification-bar");
+
+    if (onboardingEl) {
+      onboardingEl.style.display = shouldBeVisible ? "" : "none";
+    }
+  }
+
+  _showRemoteSnippets() {
+    const snippetsEl = document.getElementById(this.elementId);
+    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.");
+    }
+
+    if (typeof payload !== "string") {
+      throw new Error("Snippet payload was incorrectly formatted");
+    }
+
+    // Note that injecting snippets can throw if they're invalid XML.
+    // eslint-disable-next-line no-unsanitized/property
+    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);
+    }
+  }
+
+  _onAction(msg) {
+    if (msg.data.type === at.SNIPPET_BLOCKED) {
+      if (!this.snippetsMap.blockList.includes(msg.data.data)) {
+        this.snippetsMap.set("blockList", this.snippetsMap.blockList.concat(msg.data.data));
+        document.getElementById("snippets-container").style.display = "none";
+      }
+    }
+  }
+
+  /**
+   * init - Fetch the snippet payload and show snippets
+   *
+   * @param  {obj} options
+   * @param  {str} options.appData.snippetsURL  The URL from which we fetch snippets
+   * @param  {int} options.appData.version  The current snippets version
+   * @param  {str} options.elementId  The id of the element in which to inject snippets
+   * @param  {bool} options.connect  Should gSnippetsMap connect to indexedDB?
+   */
+  async init(options) {
+    Object.assign(this, {
+      appData: {},
+      elementId: "snippets",
+      connect: true
+    }, options);
+
+    // Add listener so we know when snippets are blocked on other pages
+    if (global.addMessageListener) {
+      global.addMessageListener("ActivityStream:MainToContent", this._onAction);
+    }
+
+    // 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
+      }
+    }
+
+    // Cache app data values so they can be accessible from gSnippetsMap
+    for (const key of Object.keys(this.appData)) {
+      if (key === "blockList") {
+        this.snippetsMap.set("blockList", this.appData[key]);
+      } else {
+        this.snippetsMap.set(`appData.${key}`, this.appData[key]);
+      }
+    }
+
+    // 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._noSnippetFallback(e);
+    }
+
+    window.dispatchEvent(new Event(SNIPPETS_ENABLED_EVENT));
+
+    this._forceOnboardingVisibility(true);
+    this.initialized = true;
+  }
+
+  uninit() {
+    window.dispatchEvent(new Event(SNIPPETS_DISABLED_EVENT));
+    this._forceOnboardingVisibility(false);
+    if (global.removeMessageListener) {
+      global.removeMessageListener("ActivityStream:MainToContent", this._onAction);
+    }
+    this.initialized = false;
+  }
+}
+
+/**
+ * addSnippetsSubscriber - Creates a SnippetsProvider that Initializes
+ *                         when the store has received the appropriate
+ *                         Snippet data.
+ *
+ * @param  {obj} store   The redux store
+ * @return {obj}         Returns the snippets instance and unsubscribe function
+ */
+export function addSnippetsSubscriber(store) {
+  const snippets = new SnippetsProvider(store.dispatch);
+
+  let initializing = false;
+
+  store.subscribe(async () => {
+    const state = store.getState();
+    // state.Prefs.values["feeds.snippets"]:  Should snippets be shown?
+    // state.Snippets.initialized             Is the snippets data initialized?
+    // snippets.initialized:                  Is SnippetsProvider currently initialised?
+    if (state.Prefs.values["feeds.snippets"] &&
+      // If the message center experiment is enabled, don't show snippets
+      !state.Prefs.values.messageCenterExperimentEnabled &&
+      !state.Prefs.values.disableSnippets &&
+      state.Snippets.initialized &&
+      !snippets.initialized &&
+      // Don't call init multiple times
+      !initializing
+    ) {
+      initializing = true;
+      await snippets.init({appData: state.Snippets});
+      initializing = false;
+    } else if (
+      (state.Prefs.values["feeds.snippets"] === false ||
+        state.Prefs.values.disableSnippets === true) &&
+      snippets.initialized
+    ) {
+      snippets.uninit();
+    }
+
+    if (state.Prefs.values.messageCenterExperimentEnabled) {
+      initMessageCenter();
+    }
+  });
+
+  // These values are returned for testing purposes
+  return snippets;
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/message-center/components/Button.jsx
@@ -0,0 +1,15 @@
+import React from "react";
+
+const styles = {
+  button: {
+    border: 0,
+    backgroundColor: "#e1e1e2",
+    fontFamily: "inherit",
+    padding: "8px 15px",
+    marginLeft: "15px"
+  }
+};
+
+export const Button = props => (<button style={styles.button} {...props}>
+  {props.children}
+</button>);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/message-center/components/SnippetBase.jsx
@@ -0,0 +1,56 @@
+import React from "react";
+
+const defaultStyles = {
+  container: {
+    position: "fixed",
+    bottom: 0,
+    left: 0,
+    right: 0,
+    padding: "20px",
+    backgroundColor: "white",
+    fontSize: "12px",
+    lineHeight: "16px",
+    boxShadow: "0 -1px 4px 0 rgba(12, 12, 13, 0.1)"
+  },
+  innerWrapper: {
+    maxWidth: "992px",
+    margin: "0 auto",
+    display: "flex",
+    alignItems: "center"
+  },
+  blockButton: {
+    background: "none",
+    border: 0,
+    display: "block",
+    position: "absolute",
+    top: "50%",
+    right: "24px",
+    height: "16px",
+    width: "16px",
+    backgroundImage: "url(resource://activity-stream/data/content/assets/glyph-dismiss-16.svg)",
+    opacity: 0.5,
+    marginTop: "-8px",
+    padding: 0
+  }
+};
+
+export class SnippetBase extends React.PureComponent {
+  render() {
+    const {props} = this;
+
+    // Extend default styles
+    const styles = Object.assign({}, defaultStyles);
+    if (props.styles) {
+      Object.keys(props.styles).forEach(key => {
+        styles[key] = Object.assign({}, styles[key], props.styles[key]);
+      });
+    }
+
+    return (<div style={styles.container}>
+      <div style={styles.innerWrapper}>
+        {props.children}
+      </div>
+      <button style={styles.blockButton} onClick={props.onBlock} />
+    </div>);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/message-center/message-center-content.jsx
@@ -0,0 +1,73 @@
+import React from "react";
+import ReactDOM from "react-dom";
+import {SimpleSnippet} from "./templates/SimpleSnippet";
+
+const INCOMING_MESSAGE_NAME = "MessageCenter:parent-to-child";
+const OUTGOING_MESSAGE_NAME = "MessageCenter:child-to-parent";
+
+export const MessageCenterUtils = {
+  addListener(listener) {
+    global.addMessageListener(INCOMING_MESSAGE_NAME, listener);
+  },
+  removeListener(listener) {
+    global.removeMessageListener(INCOMING_MESSAGE_NAME, listener);
+  },
+  sendMessage(action) {
+    global.sendAsyncMessage(OUTGOING_MESSAGE_NAME, action);
+  },
+  blockById(id) {
+    MessageCenterUtils.sendMessage({type: "BLOCK_MESSAGE_BY_ID", data: {id}});
+  },
+  unblockById(id) {
+    MessageCenterUtils.sendMessage({type: "UNBLOCK_MESSAGE_BY_ID", data: {id}});
+  },
+  getNextMessage() {
+    MessageCenterUtils.sendMessage({type: "GET_NEXT_MESSAGE"});
+  }
+};
+
+class MessageCenterUISurface extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onMessageFromParent = this.onMessageFromParent.bind(this);
+    this.state = {message: {}};
+  }
+
+  onBlockById(id) {
+    return () => MessageCenterUtils.blockById(id);
+  }
+
+  onMessageFromParent({data: action}) {
+    switch (action.type) {
+      case "SET_MESSAGE":
+        this.setState({message: action.data});
+        break;
+      case "CLEAR_MESSAGE":
+        this.setState({message: {}});
+        break;
+    }
+  }
+
+  componentWillMount() {
+    MessageCenterUtils.addListener(this.onMessageFromParent);
+    MessageCenterUtils.sendMessage({type: "CONNECT_UI_REQUEST"});
+  }
+
+  componentWillUnmount() {
+    MessageCenterUtils.removeMessageListener(this.onMessageFromParent);
+  }
+
+  render() {
+    const {message} = this.state;
+    if (!message.id) { return null; }
+
+    return (<SimpleSnippet
+      {...message}
+      getNextMessage={MessageCenterUtils.getNextMessage}
+      onBlock={this.onBlockById(message.id)} />);
+  }
+}
+
+export function initMessageCenter() {
+  ReactDOM.render(<MessageCenterUISurface />, document.getElementById("snippets-container"));
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/message-center/templates/SimpleSnippet.jsx
@@ -0,0 +1,31 @@
+import {Button} from "../components/Button";
+import React from "react";
+import {SnippetBase} from "../components/SnippetBase";
+
+const styles = {
+  title: {
+    display: "inline",
+    fontSize: "inherit",
+    margin: 0
+  },
+  body: {
+    display: "inline",
+    margin: 0
+  },
+  icon: {
+    height: "42px",
+    width: "42px",
+    marginRight: "15px",
+    borderRadius: "6px",
+    backgroundColor: "rgba(0,0,0,0.1)",
+    flexShrink: 0
+  }
+};
+
+export const SimpleSnippet = props => (<SnippetBase {...props}>
+  <div style={styles.icon} />
+  <div>
+    <h3 style={styles.title}>{props.content.title}</h3> <p style={styles.body}>{props.content.body}</p>
+  </div>
+  {props.content.button ? <div><Button>{props.content.button.label}</Button></div> : null}
+</SnippetBase>);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/styles/_activity-stream.scss
@@ -0,0 +1,143 @@
+@import './normalize';
+@import './variables';
+@import './theme';
+@import './icons';
+
+html {
+  height: 100%;
+}
+
+body,
+#root { // sass-lint:disable-line no-ids
+  min-height: 100vh;
+}
+
+body {
+  background-color: var(--newtab-background-color);
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Ubuntu', 'Helvetica Neue', sans-serif;
+  font-size: 16px;
+  overflow-y: scroll;
+}
+
+h1,
+h2 {
+  font-weight: normal;
+}
+
+a {
+  text-decoration: none;
+}
+
+// For screen readers
+.sr-only {
+  border: 0;
+  clip: rect(0, 0, 0, 0);
+  height: 1px;
+  margin: -1px;
+  overflow: hidden;
+  padding: 0;
+  position: absolute;
+  width: 1px;
+}
+
+.inner-border {
+  border: $border-secondary;
+  border-radius: $border-radius;
+  height: 100%;
+  left: 0;
+  pointer-events: none;
+  position: absolute;
+  top: 0;
+  width: 100%;
+  z-index: 100;
+}
+
+@keyframes fadeIn {
+  from {
+    opacity: 0;
+  }
+
+  to {
+    opacity: 1;
+  }
+}
+
+.show-on-init {
+  opacity: 0;
+  transition: opacity 0.2s ease-in;
+
+  &.on {
+    animation: fadeIn 0.2s;
+    opacity: 1;
+  }
+}
+
+.actions {
+  border-top: $border-secondary;
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  justify-content: flex-start;
+  margin: 0;
+  padding: 15px 25px 0;
+}
+
+// Default button (grey)
+.button,
+.actions button {
+  background-color: var(--newtab-button-secondary-color);
+  border: $border-primary;
+  border-radius: 4px;
+  color: inherit;
+  cursor: pointer;
+  margin-bottom: 15px;
+  padding: 10px 30px;
+  white-space: nowrap;
+
+  &:hover:not(.dismiss) {
+    box-shadow: $shadow-primary;
+    transition: box-shadow 150ms;
+  }
+
+  &.dismiss {
+    background-color: transparent;
+    border: 0;
+    padding: 0;
+    text-decoration: underline;
+  }
+
+  // Blue button
+  &.primary,
+  &.done {
+    background-color: var(--newtab-button-primary-color);
+    border: solid 1px var(--newtab-button-primary-color);
+    color: $white;
+    margin-inline-start: auto;
+  }
+}
+
+input {
+  &[type='text'],
+  &[type='search'] {
+    border-radius: $border-radius;
+  }
+}
+
+// Make sure snippets show up above other UI elements
+#snippets-container { // sass-lint:disable-line no-ids
+  z-index: 1;
+}
+
+// Components
+@import '../components/Base/Base';
+@import '../components/ErrorBoundary/ErrorBoundary';
+@import '../components/TopSites/TopSites';
+@import '../components/Sections/Sections';
+@import '../components/Topics/Topics';
+@import '../components/Search/Search';
+@import '../components/ContextMenu/ContextMenu';
+@import '../components/ConfirmDialog/ConfirmDialog';
+@import '../components/Card/Card';
+@import '../components/ManualMigration/ManualMigration';
+@import '../components/CollapsibleSection/CollapsibleSection';
+@import '../components/MessageCenterAdmin/MessageCenterAdmin';
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/styles/_icons.scss
@@ -0,0 +1,184 @@
+.icon {
+  background-position: center center;
+  background-repeat: no-repeat;
+  background-size: $icon-size;
+  -moz-context-properties: fill;
+  display: inline-block;
+  fill: var(--newtab-icon-primary-color);
+  height: $icon-size;
+  vertical-align: middle;
+  width: $icon-size;
+
+  &.icon-spacer {
+    margin-inline-end: 8px;
+  }
+
+  &.icon-small-spacer {
+    margin-inline-end: 6px;
+  }
+
+  &.icon-bookmark-added {
+    background-image: url('chrome://browser/skin/bookmark.svg');
+  }
+
+  &.icon-bookmark-hollow {
+    background-image: url('chrome://browser/skin/bookmark-hollow.svg');
+  }
+
+  &.icon-clear-input {
+    fill: var(--newtab-icon-secondary-color);
+    background-image: url('#{$image-path}glyph-cancel-16.svg');
+  }
+
+  &.icon-delete {
+    background-image: url('#{$image-path}glyph-delete-16.svg');
+  }
+
+  &.icon-search {
+    background-image: url('chrome://browser/skin/search-glass.svg');
+  }
+
+  &.icon-modal-delete {
+    background-image: url('#{$image-path}glyph-modal-delete-32.svg');
+    background-size: $larger-icon-size;
+    height: $larger-icon-size;
+    width: $larger-icon-size;
+  }
+
+  &.icon-dismiss {
+    background-image: url('#{$image-path}glyph-dismiss-16.svg');
+  }
+
+  &.icon-info {
+    background-image: url('#{$image-path}glyph-info-16.svg');
+  }
+
+  &.icon-import {
+    background-image: url('#{$image-path}glyph-import-16.svg');
+  }
+
+  &.icon-new-window {
+    @include flip-icon;
+    background-image: url('#{$image-path}glyph-newWindow-16.svg');
+  }
+
+  &.icon-new-window-private {
+    background-image: url('chrome://browser/skin/privateBrowsing.svg');
+  }
+
+  &.icon-settings {
+    background-image: url('chrome://browser/skin/settings.svg');
+  }
+
+  &.icon-pin {
+    @include flip-icon;
+    background-image: url('#{$image-path}glyph-pin-16.svg');
+  }
+
+  &.icon-unpin {
+    @include flip-icon;
+    background-image: url('#{$image-path}glyph-unpin-16.svg');
+  }
+
+  &.icon-edit {
+    background-image: url('#{$image-path}glyph-edit-16.svg');
+  }
+
+  &.icon-pocket {
+    background-image: url('#{$image-path}glyph-pocket-16.svg');
+  }
+
+  &.icon-historyItem { // sass-lint:disable-line class-name-format
+    background-image: url('#{$image-path}glyph-historyItem-16.svg');
+  }
+
+  &.icon-trending {
+    background-image: url('#{$image-path}glyph-trending-16.svg');
+    transform: translateY(2px); // trending bolt is visually top heavy
+  }
+
+  &.icon-now {
+    background-image: url('chrome://browser/skin/history.svg');
+  }
+
+  &.icon-topsites {
+    background-image: url('#{$image-path}glyph-topsites-16.svg');
+  }
+
+  &.icon-pin-small {
+    @include flip-icon;
+    background-image: url('#{$image-path}glyph-pin-12.svg');
+    background-size: $smaller-icon-size;
+    height: $smaller-icon-size;
+    width: $smaller-icon-size;
+  }
+
+  &.icon-check {
+    background-image: url('chrome://browser/skin/check.svg');
+  }
+
+  &.icon-download {
+    background-image: url('chrome://browser/skin/downloads/download-icons.svg#arrow-with-bar');
+  }
+
+  &.icon-copy {
+    background-image: url('chrome://browser/skin/edit-copy.svg');
+  }
+
+  &.icon-open-file {
+    background-image: url('#{$image-path}glyph-open-file-16.svg');
+  }
+
+  &.icon-download-folder {
+    background-image: url('#{$image-path}glyph-download-icon.svg');
+    background-size: 100%;
+  }
+
+  &.icon-webextension {
+    background-image: url('#{$image-path}glyph-webextension-16.svg');
+  }
+
+  &.icon-highlights {
+    background-image: url('#{$image-path}glyph-highlights-16.svg');
+  }
+
+  &.icon-arrowhead-down {
+    background-image: url('#{$image-path}glyph-arrowhead-down-16.svg');
+  }
+
+  &.icon-arrowhead-down-small {
+    background-image: url('#{$image-path}glyph-arrowhead-down-12.svg');
+    background-size: $smaller-icon-size;
+    height: $smaller-icon-size;
+    width: $smaller-icon-size;
+  }
+
+  &.icon-arrowhead-forward-small {
+    background-image: url('#{$image-path}glyph-arrowhead-down-12.svg');
+    background-size: $smaller-icon-size;
+    height: $smaller-icon-size;
+    transform: rotate(-90deg);
+    width: $smaller-icon-size;
+
+    &:dir(rtl) {
+      transform: rotate(90deg);
+    }
+  }
+
+  &.icon-arrowhead-up {
+    background-image: url('#{$image-path}glyph-arrowhead-down-16.svg');
+    transform: rotate(180deg);
+  }
+
+  &.icon-add {
+    background-image: url('#{$image-path}glyph-add-16.svg');
+  }
+
+  &.icon-minimize {
+    background-image: url('#{$image-path}glyph-minimize-16.svg');
+  }
+
+  &.icon-maximize {
+    background-image: url('#{$image-path}glyph-maximize-16.svg');
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/styles/_normalize.scss
@@ -0,0 +1,29 @@
+html {
+  box-sizing: border-box;
+}
+
+*,
+*::before,
+*::after {
+  box-sizing: inherit;
+}
+
+*::-moz-focus-inner {
+  border: 0;
+}
+
+body {
+  margin: 0;
+}
+
+button,
+input {
+  background-color: inherit;
+  color: inherit;
+  font-family: inherit;
+  font-size: inherit;
+}
+
+[hidden] {
+  display: none !important; // sass-lint:disable-line no-important
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/styles/_theme.scss
@@ -0,0 +1,127 @@
+@function textbox-shadow($color) {
+  @return 0 0 0 1px $color, 0 0 0 $textbox-shadow-size rgba($color, 0.3);
+}
+
+@mixin textbox-focus($color) {
+  --newtab-textbox-focus-color: $color;
+  --newtab-textbox-focus-boxshadow: textbox-shadow($color);
+}
+
+// Default theme
+body {
+  // General styles
+  --newtab-background-color: $grey-10;
+  --newtab-border-primary-color: $grey-40;
+  --newtab-border-secondary-color: $grey-30;
+  --newtab-button-primary-color: $blue-60;
+  --newtab-button-secondary-color: inherit;
+  --newtab-element-active-color: $grey-30-60;
+  --newtab-element-hover-color: $grey-20;
+  --newtab-icon-primary-color: $grey-90-80;
+  --newtab-icon-secondary-color: $grey-90-60;
+  --newtab-icon-tertiary-color: $grey-30;
+  --newtab-inner-box-shadow-color: $black-10;
+  --newtab-link-primary-color: $blue-60;
+  --newtab-link-secondary-color: $teal-70;
+  --newtab-text-conditional-color: $grey-60;
+  --newtab-text-primary-color: $grey-90;
+  --newtab-text-secondary-color: $grey-50;
+  --newtab-text-tertiary-color: $grey-50;
+  --newtab-textbox-background-color: $white;
+  --newtab-textbox-border: $grey-90-20;
+  @include textbox-focus($blue-60); // sass-lint:disable-line mixins-before-declarations
+
+  // Context menu
+  --newtab-contextmenu-background-color: $grey-10;
+  --newtab-contextmenu-button-color: $white;
+
+  // Modal + overlay
+  --newtab-modal-color: $white;
+  --newtab-overlay-color: $grey-20-80;
+
+  // Sections
+  --newtab-section-header-text-color: $grey-50;
+  --newtab-section-navigation-text-color: $grey-50;
+  --newtab-section-active-contextmenu-color: $grey-90;
+
+  // Search
+  --newtab-search-border-color: transparent;
+  --newtab-search-dropdown-color: $white;
+  --newtab-search-dropdown-header-color: $grey-10;
+  --newtab-search-icon-color: $grey-90-40;
+
+  // Top Sites
+  --newtab-topsites-background-color: $white;
+  --newtab-topsites-label-color: inherit;
+
+  // Cards
+  --newtab-card-active-outline-color: $grey-30;
+  --newtab-card-background-color: $white;
+  --newtab-card-hairline-color: $black-10;
+  --newtab-card-shadow: 0 1px 4px 0 $grey-90-10;
+}
+
+// Dark theme
+.dark-theme {
+  // General styles
+  --newtab-background-color: $grey-80;
+  --newtab-border-primary-color: $grey-10-80;
+  --newtab-border-secondary-color: $grey-10-10;
+  --newtab-button-primary-color: $blue-60;
+  --newtab-button-secondary-color: $grey-70;
+  --newtab-element-active-color: $grey-10-20;
+  --newtab-element-hover-color: $grey-10-10;
+  --newtab-icon-primary-color: $grey-10-80;
+  --newtab-icon-secondary-color: $grey-10-40;
+  --newtab-icon-tertiary-color: $grey-10-40;
+  --newtab-inner-box-shadow-color: $grey-10-20;
+  --newtab-link-primary-color: $blue-40;
+  --newtab-link-secondary-color: $pocket-teal;
+  --newtab-text-conditional-color: $grey-10;
+  --newtab-text-primary-color: $grey-10;
+  --newtab-text-secondary-color: $grey-10-80;
+  --newtab-text-tertiary-color: $grey-10-60;
+  --newtab-textbox-background-color: $grey-70;
+  --newtab-textbox-border: $grey-10-20;
+  @include textbox-focus($blue-40); // sass-lint:disable-line mixins-before-declarations
+
+  // Context menu
+  --newtab-contextmenu-background-color: $grey-60;
+  --newtab-contextmenu-button-color: $grey-80;
+
+  // Modal + overlay
+  --newtab-modal-color: $grey-80;
+  --newtab-overlay-color: $grey-90-80;
+
+  // Sections
+  --newtab-section-header-text-color: $grey-10-80;
+  --newtab-section-navigation-text-color: $grey-10-80;
+  --newtab-section-active-contextmenu-color: $white;
+
+  // Search
+  --newtab-search-border-color: $grey-10-20;
+  --newtab-search-dropdown-color: $grey-70;
+  --newtab-search-dropdown-header-color: $grey-60;
+  --newtab-search-icon-color: $grey-10-60;
+
+  // Top Sites
+  --newtab-topsites-background-color: $grey-70;
+  --newtab-topsites-label-color: $grey-10-80;
+
+  // Cards
+  --newtab-card-active-outline-color: $grey-60;
+  --newtab-card-background-color: $grey-70;
+  --newtab-card-hairline-color: $grey-10-10;
+  --newtab-card-shadow: 0 1px 8px 0 $grey-90-20;
+}
+
+// scss variables related to the theme.
+$border-primary: 1px solid var(--newtab-border-primary-color);
+$border-secondary: 1px solid var(--newtab-border-secondary-color);
+$shadow-primary: 0 0 0 5px var(--newtab-card-active-outline-color);
+$shadow-secondary: 0 1px 4px 0 $grey-90-20;
+$input-border: solid 1px var(--newtab-textbox-border);
+$input-border-active: solid 1px var(--newtab-textbox-focus-color);
+$input-error-border: solid 1px $red-60;
+$input-error-boxshadow: textbox-shadow($red-60);
+$inner-box-shadow: 0 0 0 1px var(--newtab-inner-box-shadow-color);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/styles/_variables.scss
@@ -0,0 +1,182 @@
+// Photon colors from http://design.firefox.com/photon/visuals/color.html
+$blue-40: #45A1FF;
+$blue-50: #0A84FF;
+$blue-60: #0060DF;
+$blue-70: #003EAA;
+$blue-80: #002275;
+$grey-10: #F9F9FA;
+$grey-20: #EDEDF0;
+$grey-30: #D7D7DB;
+$grey-40: #B1B1B3;
+$grey-50: #737373;
+$grey-60: #4A4A4F;
+$grey-70: #38383D;
+$grey-80: #2A2A2E;
+$grey-90: #0C0C0D;
+$teal-70: #008EA4;
+$red-60: #D70022;
+$yellow-50: #FFE900;
+
+// Photon opacity from http://design.firefox.com/photon/visuals/color.html#opacity
+$grey-10-10: rgba($grey-10, 0.1);
+$grey-10-20: rgba($grey-10, 0.2);
+$grey-10-40: rgba($grey-10, 0.4);
+$grey-10-60: rgba($grey-10, 0.6);
+$grey-10-80: rgba($grey-10, 0.8);
+$grey-20-60: rgba($grey-20, 0.6);
+$grey-20-80: rgba($grey-20, 0.8);
+$grey-30-60: rgba($grey-30, 0.6);
+$grey-90-10: rgba($grey-90, 0.1);
+$grey-90-20: rgba($grey-90, 0.2);
+$grey-90-30: rgba($grey-90, 0.3);
+$grey-90-40: rgba($grey-90, 0.4);
+$grey-90-50: rgba($grey-90, 0.5);
+$grey-90-60: rgba($grey-90, 0.6);
+$grey-90-70: rgba($grey-90, 0.7);
+$grey-90-80: rgba($grey-90, 0.8);
+$grey-90-90: rgba($grey-90, 0.9);
+
+$black: #000;
+$black-5: rgba($black, 0.05);
+$black-10: rgba($black, 0.1);
+$black-15: rgba($black, 0.15);
+$black-20: rgba($black, 0.2);
+$black-25: rgba($black, 0.25);
+$black-30: rgba($black, 0.3);
+
+// Photon transitions from http://design.firefox.com/photon/motion/duration-and-easing.html
+$photon-easing: cubic-bezier(0.07, 0.95, 0, 1);
+
+// Other colors
+$white: #FFF;
+$pocket-teal: #50BCB6;
+
+$border-radius: 3px;
+
+// Grid related styles
+$base-gutter: 32px;
+$section-horizontal-padding: 25px;
+$section-vertical-padding: 10px;
+$section-spacing: 40px - $section-vertical-padding * 2;
+$grid-unit: 96px; // 1 top site
+
+$icon-size: 16px;
+$smaller-icon-size: 12px;
+$larger-icon-size: 32px;
+
+$small-download-folder-icon-size: 36px;
+$large-download-folder-icon-size: $small-download-folder-icon-size * 1.5;
+
+$wrapper-default-width: $grid-unit * 2 + $base-gutter * 1 + $section-horizontal-padding * 2; // 2 top sites
+$wrapper-max-width-small: $grid-unit * 3 + $base-gutter * 2 + $section-horizontal-padding * 2; // 3 top sites
+$wrapper-max-width-medium: $grid-unit * 4 + $base-gutter * 3 + $section-horizontal-padding * 2; // 4 top sites
+$wrapper-max-width-large: $grid-unit * 6 + $base-gutter * 5 + $section-horizontal-padding * 2; // 6 top sites
+$wrapper-max-width-widest: $grid-unit * 8 + $base-gutter * 7 + $section-horizontal-padding * 2; // 8 top sites
+// For the breakpoints, we need to add space for the scrollbar to avoid weird
+// layout issues when the scrollbar is visible. 16px is wide enough to cover all
+// OSes and keeps it simpler than a per-OS value.
+$scrollbar-width: 16px;
+$break-point-small: $wrapper-max-width-small + $base-gutter * 2 + $scrollbar-width;
+$break-point-medium: $wrapper-max-width-medium + $base-gutter * 2 + $scrollbar-width;
+$break-point-large: $wrapper-max-width-large + $base-gutter * 2 + $scrollbar-width;
+$break-point-widest: $wrapper-max-width-widest + $base-gutter * 2 + $scrollbar-width;
+
+$section-title-font-size: 13px;
+
+$card-width: $grid-unit * 2 + $base-gutter;
+$card-height: 266px;
+$card-preview-image-height: 122px;
+$card-title-margin: 2px;
+$card-text-line-height: 19px;
+// Larger cards for wider screens:
+$card-width-large: 309px;
+$card-height-large: 370px;
+$card-preview-image-height-large: 155px;
+
+$topic-margin-top: 12px;
+
+$context-menu-button-size: 27px;
+$context-menu-button-boxshadow: 0 2px $grey-90-10;
+$context-menu-shadow: 0 5px 10px $black-30, 0 0 0 1px $black-20;
+$context-menu-font-size: 14px;
+$context-menu-border-radius: 5px;
+$context-menu-outer-padding: 5px;
+$context-menu-item-padding: 3px 12px;
+
+$error-fallback-font-size: 12px;
+$error-fallback-line-height: 1.5;
+
+$image-path: '../data/content/assets/';
+
+$snippets-container-height: 120px;
+
+$textbox-shadow-size: 4px;
+
+@mixin fade-in {
+  box-shadow: inset $inner-box-shadow, $shadow-primary;
+  transition: box-shadow 150ms;
+}
+
+@mixin fade-in-card {
+  box-shadow: $shadow-primary;
+  transition: box-shadow 150ms;
+}
+
+@mixin context-menu-button {
+  .context-menu-button {
+    background-clip: padding-box;
+    background-color: var(--newtab-contextmenu-button-color);
+    background-image: url('chrome://browser/skin/page-action.svg');
+    background-position: 55%;
+    border: $border-primary;
+    border-radius: 100%;
+    box-shadow: $context-menu-button-boxshadow;
+    cursor: pointer;
+    fill: var(--newtab-icon-primary-color);
+    height: $context-menu-button-size;
+    offset-inline-end: -($context-menu-button-size / 2);
+    opacity: 0;
+    position: absolute;
+    top: -($context-menu-button-size / 2);
+    transform: scale(0.25);
+    transition-duration: 200ms;
+    transition-property: transform, opacity;
+    width: $context-menu-button-size;
+
+    &:-moz-any(:active, :focus) {
+      opacity: 1;
+      transform: scale(1);
+    }
+  }
+}
+
+@mixin context-menu-button-hover {
+  .context-menu-button {
+    opacity: 1;
+    transform: scale(1);
+  }
+}
+
+@mixin context-menu-open-middle {
+  .context-menu {
+    margin-inline-end: auto;
+    margin-inline-start: auto;
+    offset-inline-end: auto;
+    offset-inline-start: -$base-gutter;
+  }
+}
+
+@mixin context-menu-open-left {
+  .context-menu {
+    margin-inline-end: 5px;
+    margin-inline-start: auto;
+    offset-inline-end: 0;
+    offset-inline-start: auto;
+  }
+}
+
+@mixin flip-icon {
+  &:dir(rtl) {
+    transform: scaleX(-1);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/styles/activity-stream-linux.scss
@@ -0,0 +1,7 @@
+/* This is the linux variant */ // sass-lint:disable-line no-css-comments
+
+$os-infopanel-arrow-height: 10px;
+$os-infopanel-arrow-offset-end: 6px;
+$os-infopanel-arrow-width: 20px;
+
+@import './activity-stream';
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/styles/activity-stream-mac.scss
@@ -0,0 +1,11 @@
+/* This is the mac variant */ // sass-lint:disable-line no-css-comments
+
+$os-infopanel-arrow-height: 10px;
+$os-infopanel-arrow-offset-end: 7px;
+$os-infopanel-arrow-width: 18px;
+
+.dark-theme {
+  -moz-osx-font-smoothing: grayscale;
+}
+
+@import './activity-stream';
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/system-addon/content-src/styles/activity-stream-windows.scss
@@ -0,0 +1,7 @@
+/* This is the windows variant */ // sass-lint:disable-line no-css-comments
+
+$os-infopanel-arrow-height: 10px;
+$os-infopanel-arrow-offset-end: 6px;
+$os-infopanel-arrow-width: 20px;
+
+@import './activity-stream';
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/webpack.prerender.config.js
@@ -0,0 +1,29 @@
+const path = require("path");
+const webpackConfig = require("./webpack.system-addon.config");
+const webpack = require("webpack");
+
+const srcPath = "system-addon/content-src/activity-stream-prerender.jsx";
+
+const banner = `
+NOTE: This file is generated by webpack from ${srcPath}
+using the buildmc:html npm task.
+`;
+
+module.exports = Object.assign({}, webpackConfig, {
+  target: "node",
+  devtool: "sourcemap",
+  entry: path.join(__dirname, srcPath),
+  output: {
+    path: path.join(__dirname, "bin"),
+    filename: "prerender.js",
+    libraryTarget: "commonjs2"
+  },
+  externals: {
+    "prop-types": "commonjs prop-types",
+    "react": "commonjs react",
+    "react-dom": "commonjs react-dom"
+  },
+  plugins: [
+    new webpack.BannerPlugin(banner)
+  ]
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/webpack.system-addon.config.js
@@ -0,0 +1,49 @@
+const path = require("path");
+const webpack = require("webpack");
+
+const absolute = relPath => path.join(__dirname, "system-addon", relPath);
+
+const resourcePathRegEx = /^resource:\/\/activity-stream\//;
+
+module.exports = {
+  entry: absolute("content-src/activity-stream.jsx"),
+  output: {
+    path: absolute("data/content"),
+    filename: "activity-stream.bundle.js"
+  },
+  devtool: "source-map",
+  plugins: [new webpack.optimize.ModuleConcatenationPlugin()],
+  module: {
+    rules: [
+      {
+        test: /\.jsx?$/,
+        exclude: /node_modules/,
+        loader: "babel-loader",
+        options: {presets: ["react"]}
+      },
+      {
+        test: /\.jsm$/,
+        exclude: /node_modules/,
+        loader: "babel-loader",
+        // Converts .jsm files into common-js modules
+        options: {plugins: [["jsm-to-esmodules", {basePath: resourcePathRegEx, replace: true}]]}
+      }
+    ]
+  },
+  // This resolve config allows us to import with paths relative to the system-addon/ directory, e.g. "lib/ActivityStream.jsm"
+  resolve: {
+    extensions: [".js", ".jsx"],
+    modules: [
+      "node_modules",
+      "system-addon"
+    ]
+  },
+  externals: {
+    "prop-types": "PropTypes",
+    "react": "React",
+    "react-dom": "ReactDOM",
+    "react-intl": "ReactIntl",
+    "redux": "Redux",
+    "react-redux": "ReactRedux"
+  }
+};