Bug 1440421 part 2. Mirror https://github.com/mozilla/activity-stream repository layout in browser/extensions/activity-stream. r=k88hudson
authorEd Lee <edilee@mozilla.com>
Wed, 06 Jun 2018 10:44:53 -0700
changeset 421826 c8aff1ae632288b86206458e38d5eed3f759bc4a
parent 421825 09a74b229025e9cd883bf6e6b4dd1ed7998e7050
child 421827 1cd94ab77e2e0031d7abda6bd92068bec743e2de
push id104125
push useraciure@mozilla.com
push dateThu, 07 Jun 2018 21:57:03 +0000
treeherdermozilla-inbound@38c222c1bf73 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersk88hudson
bugs1440421
milestone62.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1440421 part 2. Mirror https://github.com/mozilla/activity-stream repository layout in browser/extensions/activity-stream. r=k88hudson MozReview-Commit-ID: HyAUvAgOVEV
browser/extensions/activity-stream/.eslintignore
browser/extensions/activity-stream/.eslintrc.js
browser/extensions/activity-stream/.mcignore
browser/extensions/activity-stream/.nvmrc
browser/extensions/activity-stream/.sass-lint.yml
browser/extensions/activity-stream/.travis.yml
browser/extensions/activity-stream/CODEOWNERS
browser/extensions/activity-stream/LICENSE
browser/extensions/activity-stream/README.md
browser/extensions/activity-stream/bin/download-firefox-artifact
browser/extensions/activity-stream/bin/download-firefox-travis.sh
browser/extensions/activity-stream/bin/prepare-mochitests-dev
browser/extensions/activity-stream/bin/process-system-addon-for-package.js
browser/extensions/activity-stream/bin/render-activity-stream-html.js
browser/extensions/activity-stream/bin/strings-import.js
browser/extensions/activity-stream/bin/test-merges.js
browser/extensions/activity-stream/bin/update-version.js
browser/extensions/activity-stream/content-src/.eslintrc.js
browser/extensions/activity-stream/content-src/activity-stream-prerender.jsx
browser/extensions/activity-stream/content-src/activity-stream.jsx
browser/extensions/activity-stream/content-src/asrouter/asrouter-content.jsx
browser/extensions/activity-stream/content-src/asrouter/components/Button/Button.jsx
browser/extensions/activity-stream/content-src/asrouter/components/Button/_Button.scss
browser/extensions/activity-stream/content-src/asrouter/components/ImpressionsWrapper/ImpressionsWrapper.jsx
browser/extensions/activity-stream/content-src/asrouter/components/ModalOverlay/ModalOverlay.jsx
browser/extensions/activity-stream/content-src/asrouter/components/ModalOverlay/_ModalOverlay.scss
browser/extensions/activity-stream/content-src/asrouter/components/SnippetBase/SnippetBase.jsx
browser/extensions/activity-stream/content-src/asrouter/components/SnippetBase/_SnippetBase.scss
browser/extensions/activity-stream/content-src/asrouter/schemas/message-format.md
browser/extensions/activity-stream/content-src/asrouter/schemas/provider-response.schema.json
browser/extensions/activity-stream/content-src/asrouter/template-utils.js
browser/extensions/activity-stream/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.jsx
browser/extensions/activity-stream/content-src/asrouter/templates/OnboardingMessage/_OnboardingMessage.scss
browser/extensions/activity-stream/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx
browser/extensions/activity-stream/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json
browser/extensions/activity-stream/content-src/asrouter/templates/SimpleSnippet/_SimpleSnippet.scss
browser/extensions/activity-stream/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx
browser/extensions/activity-stream/content-src/components/ASRouterAdmin/ASRouterAdmin.scss
browser/extensions/activity-stream/content-src/components/Base/Base.jsx
browser/extensions/activity-stream/content-src/components/Base/_Base.scss
browser/extensions/activity-stream/content-src/components/Card/Card.jsx
browser/extensions/activity-stream/content-src/components/Card/_Card.scss
browser/extensions/activity-stream/content-src/components/Card/types.js
browser/extensions/activity-stream/content-src/components/CollapsibleSection/CollapsibleSection.jsx
browser/extensions/activity-stream/content-src/components/CollapsibleSection/_CollapsibleSection.scss
browser/extensions/activity-stream/content-src/components/ComponentPerfTimer/ComponentPerfTimer.jsx
browser/extensions/activity-stream/content-src/components/ConfirmDialog/ConfirmDialog.jsx
browser/extensions/activity-stream/content-src/components/ConfirmDialog/_ConfirmDialog.scss
browser/extensions/activity-stream/content-src/components/ContextMenu/ContextMenu.jsx
browser/extensions/activity-stream/content-src/components/ContextMenu/_ContextMenu.scss
browser/extensions/activity-stream/content-src/components/ErrorBoundary/ErrorBoundary.jsx
browser/extensions/activity-stream/content-src/components/ErrorBoundary/_ErrorBoundary.scss
browser/extensions/activity-stream/content-src/components/LinkMenu/LinkMenu.jsx
browser/extensions/activity-stream/content-src/components/ManualMigration/ManualMigration.jsx
browser/extensions/activity-stream/content-src/components/ManualMigration/_ManualMigration.scss
browser/extensions/activity-stream/content-src/components/Search/Search.jsx
browser/extensions/activity-stream/content-src/components/Search/_Search.scss
browser/extensions/activity-stream/content-src/components/SectionMenu/SectionMenu.jsx
browser/extensions/activity-stream/content-src/components/Sections/Sections.jsx
browser/extensions/activity-stream/content-src/components/Sections/_Sections.scss
browser/extensions/activity-stream/content-src/components/StartupOverlay/StartupOverlay.jsx
browser/extensions/activity-stream/content-src/components/StartupOverlay/_StartupOverlay.scss
browser/extensions/activity-stream/content-src/components/TopSites/TopSite.jsx
browser/extensions/activity-stream/content-src/components/TopSites/TopSiteForm.jsx
browser/extensions/activity-stream/content-src/components/TopSites/TopSiteFormInput.jsx
browser/extensions/activity-stream/content-src/components/TopSites/TopSites.jsx
browser/extensions/activity-stream/content-src/components/TopSites/TopSitesConstants.js
browser/extensions/activity-stream/content-src/components/TopSites/_TopSites.scss
browser/extensions/activity-stream/content-src/components/Topics/Topics.jsx
browser/extensions/activity-stream/content-src/components/Topics/_Topics.scss
browser/extensions/activity-stream/content-src/lib/constants.js
browser/extensions/activity-stream/content-src/lib/detect-user-session-start.js
browser/extensions/activity-stream/content-src/lib/init-store.js
browser/extensions/activity-stream/content-src/lib/link-menu-options.js
browser/extensions/activity-stream/content-src/lib/section-menu-options.js
browser/extensions/activity-stream/content-src/lib/snippets.js
browser/extensions/activity-stream/content-src/styles/_activity-stream.scss
browser/extensions/activity-stream/content-src/styles/_icons.scss
browser/extensions/activity-stream/content-src/styles/_normalize.scss
browser/extensions/activity-stream/content-src/styles/_theme.scss
browser/extensions/activity-stream/content-src/styles/_variables.scss
browser/extensions/activity-stream/content-src/styles/activity-stream-linux.scss
browser/extensions/activity-stream/content-src/styles/activity-stream-mac.scss
browser/extensions/activity-stream/content-src/styles/activity-stream-windows.scss
browser/extensions/activity-stream/contributing.md
browser/extensions/activity-stream/data/content/activity-stream.bundle.js
browser/extensions/activity-stream/data/content/activity-stream.bundle.js.map
browser/extensions/activity-stream/docs/ISSUE_TEMPLATE.md
browser/extensions/activity-stream/docs/v2-system-addon/1.GETTING_STARTED.md
browser/extensions/activity-stream/docs/v2-system-addon/data_dictionary.md
browser/extensions/activity-stream/docs/v2-system-addon/data_events.md
browser/extensions/activity-stream/docs/v2-system-addon/mochitests.md
browser/extensions/activity-stream/docs/v2-system-addon/preferences.md
browser/extensions/activity-stream/docs/v2-system-addon/sections.md
browser/extensions/activity-stream/docs/v2-system-addon/snippets.md
browser/extensions/activity-stream/docs/v2-system-addon/telemetry.md
browser/extensions/activity-stream/docs/v2-system-addon/test-merges.md
browser/extensions/activity-stream/docs/v2-system-addon/unit_testing_guide.md
browser/extensions/activity-stream/install.rdf.in
browser/extensions/activity-stream/karma.mc.config.js
browser/extensions/activity-stream/loaders/inject-loader.js
browser/extensions/activity-stream/locales/ach/strings.properties
browser/extensions/activity-stream/locales/an/strings.properties
browser/extensions/activity-stream/locales/ar/strings.properties
browser/extensions/activity-stream/locales/ast/strings.properties
browser/extensions/activity-stream/locales/az/strings.properties
browser/extensions/activity-stream/locales/be/strings.properties
browser/extensions/activity-stream/locales/bg/strings.properties
browser/extensions/activity-stream/locales/bn-BD/strings.properties
browser/extensions/activity-stream/locales/bn-IN/strings.properties
browser/extensions/activity-stream/locales/br/strings.properties
browser/extensions/activity-stream/locales/bs/strings.properties
browser/extensions/activity-stream/locales/ca/strings.properties
browser/extensions/activity-stream/locales/cak/strings.properties
browser/extensions/activity-stream/locales/crh/strings.properties
browser/extensions/activity-stream/locales/cs/strings.properties
browser/extensions/activity-stream/locales/cy/strings.properties
browser/extensions/activity-stream/locales/da/strings.properties
browser/extensions/activity-stream/locales/de/strings.properties
browser/extensions/activity-stream/locales/dsb/strings.properties
browser/extensions/activity-stream/locales/el/strings.properties
browser/extensions/activity-stream/locales/en-CA/strings.properties
browser/extensions/activity-stream/locales/en-GB/strings.properties
browser/extensions/activity-stream/locales/en-US/strings.properties
browser/extensions/activity-stream/locales/eo/strings.properties
browser/extensions/activity-stream/locales/es-AR/strings.properties
browser/extensions/activity-stream/locales/es-CL/strings.properties
browser/extensions/activity-stream/locales/es-ES/strings.properties
browser/extensions/activity-stream/locales/es-MX/strings.properties
browser/extensions/activity-stream/locales/et/strings.properties
browser/extensions/activity-stream/locales/eu/strings.properties
browser/extensions/activity-stream/locales/fa/strings.properties
browser/extensions/activity-stream/locales/ff/strings.properties
browser/extensions/activity-stream/locales/fi/strings.properties
browser/extensions/activity-stream/locales/fr/strings.properties
browser/extensions/activity-stream/locales/fy-NL/strings.properties
browser/extensions/activity-stream/locales/ga-IE/strings.properties
browser/extensions/activity-stream/locales/gd/strings.properties
browser/extensions/activity-stream/locales/gl/strings.properties
browser/extensions/activity-stream/locales/gn/strings.properties
browser/extensions/activity-stream/locales/gu-IN/strings.properties
browser/extensions/activity-stream/locales/he/strings.properties
browser/extensions/activity-stream/locales/hi-IN/strings.properties
browser/extensions/activity-stream/locales/hr/strings.properties
browser/extensions/activity-stream/locales/hsb/strings.properties
browser/extensions/activity-stream/locales/hu/strings.properties
browser/extensions/activity-stream/locales/hy-AM/strings.properties
browser/extensions/activity-stream/locales/ia/strings.properties
browser/extensions/activity-stream/locales/id/strings.properties
browser/extensions/activity-stream/locales/it/strings.properties
browser/extensions/activity-stream/locales/ja/strings.properties
browser/extensions/activity-stream/locales/ka/strings.properties
browser/extensions/activity-stream/locales/kab/strings.properties
browser/extensions/activity-stream/locales/kk/strings.properties
browser/extensions/activity-stream/locales/km/strings.properties
browser/extensions/activity-stream/locales/kn/strings.properties
browser/extensions/activity-stream/locales/ko/strings.properties
browser/extensions/activity-stream/locales/lij/strings.properties
browser/extensions/activity-stream/locales/lo/strings.properties
browser/extensions/activity-stream/locales/lt/strings.properties
browser/extensions/activity-stream/locales/ltg/strings.properties
browser/extensions/activity-stream/locales/lv/strings.properties
browser/extensions/activity-stream/locales/mai/strings.properties
browser/extensions/activity-stream/locales/mk/strings.properties
browser/extensions/activity-stream/locales/ml/strings.properties
browser/extensions/activity-stream/locales/mr/strings.properties
browser/extensions/activity-stream/locales/ms/strings.properties
browser/extensions/activity-stream/locales/my/strings.properties
browser/extensions/activity-stream/locales/nb-NO/strings.properties
browser/extensions/activity-stream/locales/ne-NP/strings.properties
browser/extensions/activity-stream/locales/nl/strings.properties
browser/extensions/activity-stream/locales/nn-NO/strings.properties
browser/extensions/activity-stream/locales/oc/strings.properties
browser/extensions/activity-stream/locales/pa-IN/strings.properties
browser/extensions/activity-stream/locales/pl/strings.properties
browser/extensions/activity-stream/locales/pt-BR/strings.properties
browser/extensions/activity-stream/locales/pt-PT/strings.properties
browser/extensions/activity-stream/locales/rm/strings.properties
browser/extensions/activity-stream/locales/ro/strings.properties
browser/extensions/activity-stream/locales/ru/strings.properties
browser/extensions/activity-stream/locales/si/strings.properties
browser/extensions/activity-stream/locales/sk/strings.properties
browser/extensions/activity-stream/locales/sl/strings.properties
browser/extensions/activity-stream/locales/sq/strings.properties
browser/extensions/activity-stream/locales/sr/strings.properties
browser/extensions/activity-stream/locales/sv-SE/strings.properties
browser/extensions/activity-stream/locales/ta/strings.properties
browser/extensions/activity-stream/locales/te/strings.properties
browser/extensions/activity-stream/locales/th/strings.properties
browser/extensions/activity-stream/locales/tl/strings.properties
browser/extensions/activity-stream/locales/tr/strings.properties
browser/extensions/activity-stream/locales/uk/strings.properties
browser/extensions/activity-stream/locales/ur/strings.properties
browser/extensions/activity-stream/locales/uz/strings.properties
browser/extensions/activity-stream/locales/vi/strings.properties
browser/extensions/activity-stream/locales/zh-CN/strings.properties
browser/extensions/activity-stream/locales/zh-TW/strings.properties
browser/extensions/activity-stream/package-lock.json
browser/extensions/activity-stream/package.json
browser/extensions/activity-stream/test/unit/content-src/components/Base.test.jsx
browser/extensions/activity-stream/test/unit/content-src/components/Card.test.jsx
browser/extensions/activity-stream/test/unit/content-src/components/CollapsibleSection.test.jsx
browser/extensions/activity-stream/test/unit/content-src/components/ComponentPerfTimer.test.jsx
browser/extensions/activity-stream/test/unit/content-src/components/ConfirmDialog.test.jsx
browser/extensions/activity-stream/test/unit/content-src/components/ContextMenu.test.jsx
browser/extensions/activity-stream/test/unit/content-src/components/ErrorBoundary.test.jsx
browser/extensions/activity-stream/test/unit/content-src/components/LinkMenu.test.jsx
browser/extensions/activity-stream/test/unit/content-src/components/ManualMigration.test.jsx
browser/extensions/activity-stream/test/unit/content-src/components/Search.test.jsx
browser/extensions/activity-stream/test/unit/content-src/components/SectionMenu.test.jsx
browser/extensions/activity-stream/test/unit/content-src/components/Sections.test.jsx
browser/extensions/activity-stream/test/unit/content-src/components/StartupOverlay.test.jsx
browser/extensions/activity-stream/test/unit/content-src/components/TopSites.test.jsx
browser/extensions/activity-stream/test/unit/content-src/components/Topics.test.jsx
browser/extensions/activity-stream/test/unit/content-src/lib/detect-user-session-start.test.js
browser/extensions/activity-stream/test/unit/content-src/lib/snippets.test.js
browser/extensions/activity-stream/webpack.prerender.config.js
browser/extensions/activity-stream/webpack.system-addon.config.js
browser/extensions/activity-stream/yamscripts.yml
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/.eslintignore
@@ -0,0 +1,10 @@
+activity-streams-env/
+dist/
+firefox/
+logs/
+stats.json
+prerendered/
+vendor/
+data/
+bin/prerender.js
+bin/prerender.js.map
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/.eslintrc.js
@@ -0,0 +1,231 @@
+module.exports = {
+  // When adding items to this file please check for effects on sub-directories.
+  "parserOptions": {
+    "ecmaFeatures": {
+      "jsx": true
+    },
+    "sourceType": "module"
+  },
+  "env": {
+    "node": true
+  },
+  "plugins": [
+    "import", // require("eslint-plugin-import")
+    "json", // require("eslint-plugin-json")
+    "promise", // require("eslint-plugin-promise")
+    "react" // require("eslint-plugin-react")
+  ],
+  "extends": [
+    "eslint:recommended",
+    "plugin:mozilla/recommended" // require("eslint-plugin-mozilla")
+  ],
+  "overrides": [{
+    // Use a configuration that's more appropriate for JSMs
+    "files": "**/*.jsm",
+    "parserOptions": {
+      "sourceType": "script"
+    },
+    "env": {
+      "node": false
+    },
+    "rules": {
+      "no-implicit-globals": 0
+    }
+  }],
+  "rules": {
+    "promise/catch-or-return": 2,
+    "promise/param-names": 2,
+
+    "react/jsx-boolean-value": [2, "always"],
+    "react/jsx-closing-bracket-location": [2, "after-props"],
+    "react/jsx-curly-spacing": [2, "never"],
+    "react/jsx-equals-spacing": [2, "never"],
+    "react/jsx-key": 2,
+    "react/jsx-no-bind": 2,
+    "react/jsx-no-comment-textnodes": 2,
+    "react/jsx-no-duplicate-props": 2,
+    "react/jsx-no-target-blank": 2,
+    "react/jsx-no-undef": 2,
+    "react/jsx-pascal-case": 2,
+    "react/jsx-space-before-closing": [2, "always"],
+    "react/jsx-uses-react": 2,
+    "react/jsx-uses-vars": 2,
+    "react/jsx-wrap-multilines": 2,
+    "react/no-access-state-in-setstate": 2,
+    "react/no-danger": 2,
+    "react/no-deprecated": 2,
+    "react/no-did-mount-set-state": 2,
+    "react/no-did-update-set-state": 2,
+    "react/no-direct-mutation-state": 2,
+    "react/no-is-mounted": 2,
+    "react/no-unknown-property": 2,
+    "react/require-render-return": 2,
+    "react/self-closing-comp": 2,
+
+    "accessor-pairs": [2, {"setWithoutGet": true, "getWithoutSet": false}],
+    "array-bracket-newline": [2, "consistent"],
+    "array-bracket-spacing": [2, "never"],
+    "array-callback-return": 2,
+    "array-element-newline": 0,
+    "arrow-body-style": [2, "as-needed"],
+    "arrow-parens": [2, "as-needed"],
+    "block-scoped-var": 2,
+    "callback-return": 0,
+    "camelcase": 0,
+    "capitalized-comments": 0,
+    "class-methods-use-this": 0,
+    "comma-dangle": [2, "never"],
+    "consistent-this": [2, "use-bind"],
+    "curly": [2, "all"],
+    "default-case": 0,
+    "dot-location": [2, "property"],
+    "eqeqeq": 2,
+    "for-direction": 2,
+    "func-name-matching": 2,
+    "func-names": 0,
+    "func-style": 0,
+    "function-paren-newline": 0,
+    "getter-return": 2,
+    "global-require": 0,
+    "guard-for-in": 2,
+    "handle-callback-err": 2,
+    "id-blacklist": 0,
+    "id-length": 0,
+    "id-match": 0,
+    "implicit-arrow-linebreak": 0,
+    // XXX Switch back to indent once mozilla-central has decided what it is using.
+    "indent": 0,
+    "indent-legacy": ["error", 2, {"SwitchCase": 1}],
+    "init-declarations": 0,
+    "jsx-quotes": [2, "prefer-double"],
+    "line-comment-position": 0,
+    "lines-around-comment": ["error", {
+      "allowClassStart": true,
+      "allowObjectStart": true,
+      "beforeBlockComment": true
+    }],
+    "lines-between-class-members": 2,
+    "max-depth": [2, 4],
+    "max-len": 0,
+    "max-lines": 0,
+    "max-nested-callbacks": [2, 4],
+    "max-params": [2, 6],
+    "max-statements": [2, 50],
+    "max-statements-per-line": [2, {"max": 2}],
+    "multiline-comment-style": 0,
+    "multiline-ternary": 0,
+    "new-cap": [2, {"newIsCap": true, "capIsNew": false}],
+    "new-parens": 2,
+    "newline-after-var": 0,
+    "newline-before-return": 0,
+    "newline-per-chained-call": [2, {"ignoreChainWithDepth": 3}],
+    "no-alert": 2,
+    "no-await-in-loop": 0,
+    "no-bitwise": 0,
+    "no-buffer-constructor": 2,
+    "no-catch-shadow": 2,
+    "no-confusing-arrow": [2, {"allowParens": true}],
+    "no-console": 1,
+    "no-continue": 0,
+    "no-div-regex": 2,
+    "no-duplicate-imports": 2,
+    "no-empty-function": 0,
+    "no-eq-null": 2,
+    "no-extend-native": 2,
+    "no-extra-label": 2,
+    "no-extra-parens": 0,
+    "no-floating-decimal": 2,
+    "no-implicit-coercion": [2, {"allow": ["!!"]}],
+    "no-implicit-globals": 2,
+    "no-inline-comments": 0,
+    "no-invalid-this": 0,
+    "no-label-var": 2,
+    "no-loop-func": 2,
+    "no-magic-numbers": 0,
+    "no-mixed-operators": [2, {"allowSamePrecedence": true, "groups": [["&", "|", "^", "~", "<<", ">>", ">>>"], ["==", "!=", "===", "!==", ">", ">=", "<", "<="], ["&&", "||"], ["in", "instanceof"]]}],
+    "no-mixed-requires": 2,
+    "no-multi-assign": 2,
+    "no-multi-str": 2,
+    "no-multiple-empty-lines": [2, {"max": 1, "maxBOF": 0, "maxEOF": 0}],
+    "no-negated-condition": 0,
+    "no-negated-in-lhs": 2,
+    "no-new": 2,
+    "no-new-func": 2,
+    "no-new-require": 2,
+    "no-octal-escape": 2,
+    "no-param-reassign": 2,
+    "no-path-concat": 2,
+    "no-plusplus": 0,
+    "no-process-env": 0,
+    "no-process-exit": 2,
+    "no-proto": 2,
+    "no-prototype-builtins": 2,
+    "no-restricted-globals": 0,
+    "no-restricted-imports": 0,
+    "no-restricted-modules": 0,
+    "no-restricted-properties": 0,
+    "no-restricted-syntax": 0,
+    "no-return-assign": [2, "except-parens"],
+    "no-script-url": 2,
+    "no-sequences": 2,
+    "no-shadow": 2,
+    "no-spaced-func": 2,
+    "no-sync": 0,
+    "no-template-curly-in-string": 2,
+    "no-ternary": 0,
+    "no-throw-literal": 2,
+    "no-undef-init": 2,
+    "no-undefined": 0,
+    "no-underscore-dangle": 0,
+    "no-unmodified-loop-condition": 2,
+    "no-unused-expressions": 2,
+    "no-use-before-define": 2,
+    "no-useless-computed-key": 2,
+    "no-useless-constructor": 2,
+    "no-useless-rename": 2,
+    "no-var": 2,
+    "no-void": 2,
+    "no-warning-comments": 0, // TODO: Change to `1`?
+    "nonblock-statement-body-position": 2,
+    "object-curly-newline": [2, {"multiline": true}],
+    "object-curly-spacing": [2, "never"],
+    "object-property-newline": [2, {"allowMultiplePropertiesPerLine": true}],
+    "one-var": [2, "never"],
+    "one-var-declaration-per-line": [2, "initializations"],
+    "operator-assignment": [2, "always"],
+    "operator-linebreak": [2, "after"],
+    "padded-blocks": [2, "never"],
+    "padding-line-between-statements": 0,
+    "prefer-arrow-callback": ["error", {"allowNamedFunctions": true}],
+    "prefer-const": 0, // TODO: Change to `1`?
+    "prefer-destructuring": [2, {"AssignmentExpression": {"array": true}, "VariableDeclarator": {"array": true, "object": true}}],
+    "prefer-numeric-literals": 2,
+    "prefer-promise-reject-errors": 2,
+    "prefer-reflect": 0,
+    "prefer-rest-params": 2,
+    "prefer-spread": 2,
+    "prefer-template": 2,
+    "quote-props": [2, "consistent"],
+    "radix": [2, "always"],
+    "require-await": 2,
+    "require-jsdoc": 0,
+    "semi-spacing": [2, {"before": false, "after": true}],
+    "semi-style": 2,
+    "sort-imports": [2, {"ignoreCase": true}],
+    "sort-keys": 0,
+    "sort-vars": 2,
+    "space-in-parens": [2, "never"],
+    "strict": 0,
+    "switch-colon-spacing": 2,
+    "symbol-description": 2,
+    "template-curly-spacing": [2, "never"],
+    "template-tag-spacing": 2,
+    "unicode-bom": [2, "never"],
+    "valid-jsdoc": [0, {"requireReturn": false, "requireParamDescription": false, "requireReturnDescription": false}],
+    "vars-on-top": 2,
+    "wrap-iife": [2, "inside"],
+    "wrap-regex": 0,
+    "yield-star-spacing": [2, "after"],
+    "yoda": [2, "never"]
+  }
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/.mcignore
@@ -0,0 +1,18 @@
+npm-debug.log
+.DS_Store
+*.sw[po]
+*.xpi
+*.pyc
+*.update.rdf
+.gitignore
+
+/.git/
+/bin/prerender.js
+/bin/prerender.js.map
+/data/locales.json
+/dist/
+/logs/
+/node_modules/
+
+# also ignores ping centre tests
+ping-centre/
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/.nvmrc
@@ -0,0 +1,1 @@
+7.*
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/.sass-lint.yml
@@ -0,0 +1,25 @@
+options:
+  merge-default-rules: true
+
+files:
+  include: 'content-src/**/*.scss'
+
+rules:
+  class-name-format: [{convention: ["hyphenatedlowercase", "camelcase"]}]
+  extends-before-declarations: 2
+  extends-before-mixins: 2
+  force-element-nesting: 0
+  force-pseudo-nesting: 0
+  hex-notation: [2, {style: uppercase}]
+  indentation: [2, {size: 2}]
+  leading-zero: [2, {include: true}]
+  mixins-before-declarations: [2, {exclude: [breakpoint, mq]}]
+  nesting-depth: [2, {max-depth: 4}]
+  no-debug: 1
+  no-duplicate-properties: 2
+  no-misspelled-properties: [2, {extra-properties: [-moz-context-properties]}]
+  no-url-domains: 0
+  no-vendor-prefixes: 0
+  no-warn: 1
+  placeholder-in-extend: 2
+  property-sort-order: 0
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/.travis.yml
@@ -0,0 +1,33 @@
+language: node_js
+
+node_js:
+  # when changing this, be sure to edit .nvrmc and package.json too
+  - 7
+
+python:
+  - "2.7"
+
+cache:
+  directories:
+    - node_modules
+
+before_install:
+  # see https://docs.travis-ci.com/user/gui-and-headless-browsers/#Using-xvfb-to-Run-Tests-That-Require-a-GUI
+  - "export DISPLAY=:99.0"
+  - "/sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -ac -screen 0 1280x1024x16 -extension RANDR"
+  - export PATH="$PATH:$HOME/.rvm/bin"
+  - sleep 3
+
+install:
+  - npm config set spin false
+  - npm install
+
+before_script:
+  - bash bin/download-firefox-travis.sh release-linux64-add-on-devel
+  - export FIREFOX_BIN=./firefox/firefox
+
+script:
+  - npm test
+
+notifications:
+  email: false
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/CODEOWNERS
@@ -0,0 +1,2 @@
+# flod as main contact for string changes
+locales/en-US/strings.properties @flodolo
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/LICENSE
@@ -0,0 +1,374 @@
+Mozilla Public License Version 2.0
+==================================
+
+1. Definitions
+--------------
+
+1.1. "Contributor"
+    means each individual or legal entity that creates, contributes to
+    the creation of, or owns Covered Software.
+
+1.2. "Contributor Version"
+    means the combination of the Contributions of others (if any) used
+    by a Contributor and that particular Contributor's Contribution.
+
+1.3. "Contribution"
+    means Covered Software of a particular Contributor.
+
+1.4. "Covered Software"
+    means Source Code Form to which the initial Contributor has attached
+    the notice in Exhibit A, the Executable Form of such Source Code
+    Form, and Modifications of such Source Code Form, in each case
+    including portions thereof.
+
+1.5. "Incompatible With Secondary Licenses"
+    means
+
+    (a) that the initial Contributor has attached the notice described
+        in Exhibit B to the Covered Software; or
+
+    (b) that the Covered Software was made available under the terms of
+        version 1.1 or earlier of the License, but not also under the
+        terms of a Secondary License.
+
+1.6. "Executable Form"
+    means any form of the work other than Source Code Form.
+
+1.7. "Larger Work"
+    means a work that combines Covered Software with other material, in 
+    a separate file or files, that is not Covered Software.
+
+1.8. "License"
+    means this document.
+
+1.9. "Licensable"
+    means having the right to grant, to the maximum extent possible,
+    whether at the time of the initial grant or subsequently, any and
+    all of the rights conveyed by this License.
+
+1.10. "Modifications"
+    means any of the following:
+
+    (a) any file in Source Code Form that results from an addition to,
+        deletion from, or modification of the contents of Covered
+        Software; or
+
+    (b) any new file in Source Code Form that contains any Covered
+        Software.
+
+1.11. "Patent Claims" of a Contributor
+    means any patent claim(s), including without limitation, method,
+    process, and apparatus claims, in any patent Licensable by such
+    Contributor that would be infringed, but for the grant of the
+    License, by the making, using, selling, offering for sale, having
+    made, import, or transfer of either its Contributions or its
+    Contributor Version.
+
+1.12. "Secondary License"
+    means either the GNU General Public License, Version 2.0, the GNU
+    Lesser General Public License, Version 2.1, the GNU Affero General
+    Public License, Version 3.0, or any later versions of those
+    licenses.
+
+1.13. "Source Code Form"
+    means the form of the work preferred for making modifications.
+
+1.14. "You" (or "Your")
+    means an individual or a legal entity exercising rights under this
+    License. For legal entities, "You" includes any entity that
+    controls, is controlled by, or is under common control with You. For
+    purposes of this definition, "control" means (a) the power, direct
+    or indirect, to cause the direction or management of such entity,
+    whether by contract or otherwise, or (b) ownership of more than
+    fifty percent (50%) of the outstanding shares or beneficial
+    ownership of such entity.
+
+2. License Grants and Conditions
+--------------------------------
+
+2.1. Grants
+
+Each Contributor hereby grants You a world-wide, royalty-free,
+non-exclusive license:
+
+(a) under intellectual property rights (other than patent or trademark)
+    Licensable by such Contributor to use, reproduce, make available,
+    modify, display, perform, distribute, and otherwise exploit its
+    Contributions, either on an unmodified basis, with Modifications, or
+    as part of a Larger Work; and
+
+(b) under Patent Claims of such Contributor to make, use, sell, offer
+    for sale, have made, import, and otherwise transfer either its
+    Contributions or its Contributor Version.
+
+2.2. Effective Date
+
+The licenses granted in Section 2.1 with respect to any Contribution
+become effective for each Contribution on the date the Contributor first
+distributes such Contribution.
+
+2.3. Limitations on Grant Scope
+
+The licenses granted in this Section 2 are the only rights granted under
+this License. No additional rights or licenses will be implied from the
+distribution or licensing of Covered Software under this License.
+Notwithstanding Section 2.1(b) above, no patent license is granted by a
+Contributor:
+
+(a) for any code that a Contributor has removed from Covered Software;
+    or
+
+(b) for infringements caused by: (i) Your and any other third party's
+    modifications of Covered Software, or (ii) the combination of its
+    Contributions with other software (except as part of its Contributor
+    Version); or
+
+(c) under Patent Claims infringed by Covered Software in the absence of
+    its Contributions.
+
+This License does not grant any rights in the trademarks, service marks,
+or logos of any Contributor (except as may be necessary to comply with
+the notice requirements in Section 3.4).
+
+2.4. Subsequent Licenses
+
+No Contributor makes additional grants as a result of Your choice to
+distribute the Covered Software under a subsequent version of this
+License (see Section 10.2) or under the terms of a Secondary License (if
+permitted under the terms of Section 3.3).
+
+2.5. Representation
+
+Each Contributor represents that the Contributor believes its
+Contributions are its original creation(s) or it has sufficient rights
+to grant the rights to its Contributions conveyed by this License.
+
+2.6. Fair Use
+
+This License is not intended to limit any rights You have under
+applicable copyright doctrines of fair use, fair dealing, or other
+equivalents.
+
+2.7. Conditions
+
+Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
+in Section 2.1.
+
+3. Responsibilities
+-------------------
+
+3.1. Distribution of Source Form
+
+All distribution of Covered Software in Source Code Form, including any
+Modifications that You create or to which You contribute, must be under
+the terms of this License. You must inform recipients that the Source
+Code Form of the Covered Software is governed by the terms of this
+License, and how they can obtain a copy of this License. You may not
+attempt to alter or restrict the recipients' rights in the Source Code
+Form.
+
+3.2. Distribution of Executable Form
+
+If You distribute Covered Software in Executable Form then:
+
+(a) such Covered Software must also be made available in Source Code
+    Form, as described in Section 3.1, and You must inform recipients of
+    the Executable Form how they can obtain a copy of such Source Code
+    Form by reasonable means in a timely manner, at a charge no more
+    than the cost of distribution to the recipient; and
+
+(b) You may distribute such Executable Form under the terms of this
+    License, or sublicense it under different terms, provided that the
+    license for the Executable Form does not attempt to limit or alter
+    the recipients' rights in the Source Code Form under this License.
+
+3.3. Distribution of a Larger Work
+
+You may create and distribute a Larger Work under terms of Your choice,
+provided that You also comply with the requirements of this License for
+the Covered Software. If the Larger Work is a combination of Covered
+Software with a work governed by one or more Secondary Licenses, and the
+Covered Software is not Incompatible With Secondary Licenses, this
+License permits You to additionally distribute such Covered Software
+under the terms of such Secondary License(s), so that the recipient of
+the Larger Work may, at their option, further distribute the Covered
+Software under the terms of either this License or such Secondary
+License(s).
+
+3.4. Notices
+
+You may not remove or alter the substance of any license notices
+(including copyright notices, patent notices, disclaimers of warranty,
+or limitations of liability) contained within the Source Code Form of
+the Covered Software, except that You may alter any license notices to
+the extent required to remedy known factual inaccuracies.
+
+3.5. Application of Additional Terms
+
+You may choose to offer, and to charge a fee for, warranty, support,
+indemnity or liability obligations to one or more recipients of Covered
+Software. However, You may do so only on Your own behalf, and not on
+behalf of any Contributor. You must make it absolutely clear that any
+such warranty, support, indemnity, or liability obligation is offered by
+You alone, and You hereby agree to indemnify every Contributor for any
+liability incurred by such Contributor as a result of warranty, support,
+indemnity or liability terms You offer. You may include additional
+disclaimers of warranty and limitations of liability specific to any
+jurisdiction.
+
+4. Inability to Comply Due to Statute or Regulation
+---------------------------------------------------
+
+If it is impossible for You to comply with any of the terms of this
+License with respect to some or all of the Covered Software due to
+statute, judicial order, or regulation then You must: (a) comply with
+the terms of this License to the maximum extent possible; and (b)
+describe the limitations and the code they affect. Such description must
+be placed in a text file included with all distributions of the Covered
+Software under this License. Except to the extent prohibited by statute
+or regulation, such description must be sufficiently detailed for a
+recipient of ordinary skill to be able to understand it.
+
+5. Termination
+--------------
+
+5.1. The rights granted under this License will terminate automatically
+if You fail to comply with any of its terms. However, if You become
+compliant, then the rights granted under this License from a particular
+Contributor are reinstated (a) provisionally, unless and until such
+Contributor explicitly and finally terminates Your grants, and (b) on an
+ongoing basis, if such Contributor fails to notify You of the
+non-compliance by some reasonable means prior to 60 days after You have
+come back into compliance. Moreover, Your grants from a particular
+Contributor are reinstated on an ongoing basis if such Contributor
+notifies You of the non-compliance by some reasonable means, this is the
+first time You have received notice of non-compliance with this License
+from such Contributor, and You become compliant prior to 30 days after
+Your receipt of the notice.
+
+5.2. If You initiate litigation against any entity by asserting a patent
+infringement claim (excluding declaratory judgment actions,
+counter-claims, and cross-claims) alleging that a Contributor Version
+directly or indirectly infringes any patent, then the rights granted to
+You by any and all Contributors for the Covered Software under Section
+2.1 of this License shall terminate.
+
+5.3. In the event of termination under Sections 5.1 or 5.2 above, all
+end user license agreements (excluding distributors and resellers) which
+have been validly granted by You or Your distributors under this License
+prior to termination shall survive termination.
+
+************************************************************************
+*                                                                      *
+*  6. Disclaimer of Warranty                                           *
+*  -------------------------                                           *
+*                                                                      *
+*  Covered Software is provided under this License on an "as is"       *
+*  basis, without warranty of any kind, either expressed, implied, or  *
+*  statutory, including, without limitation, warranties that the       *
+*  Covered Software is free of defects, merchantable, fit for a        *
+*  particular purpose or non-infringing. The entire risk as to the     *
+*  quality and performance of the Covered Software is with You.        *
+*  Should any Covered Software prove defective in any respect, You     *
+*  (not any Contributor) assume the cost of any necessary servicing,   *
+*  repair, or correction. This disclaimer of warranty constitutes an   *
+*  essential part of this License. No use of any Covered Software is   *
+*  authorized under this License except under this disclaimer.         *
+*                                                                      *
+************************************************************************
+
+************************************************************************
+*                                                                      *
+*  7. Limitation of Liability                                          *
+*  --------------------------                                          *
+*                                                                      *
+*  Under no circumstances and under no legal theory, whether tort      *
+*  (including negligence), contract, or otherwise, shall any           *
+*  Contributor, or anyone who distributes Covered Software as          *
+*  permitted above, be liable to You for any direct, indirect,         *
+*  special, incidental, or consequential damages of any character      *
+*  including, without limitation, damages for lost profits, loss of    *
+*  goodwill, work stoppage, computer failure or malfunction, or any    *
+*  and all other commercial damages or losses, even if such party      *
+*  shall have been informed of the possibility of such damages. This   *
+*  limitation of liability shall not apply to liability for death or   *
+*  personal injury resulting from such party's negligence to the       *
+*  extent applicable law prohibits such limitation. Some               *
+*  jurisdictions do not allow the exclusion or limitation of           *
+*  incidental or consequential damages, so this exclusion and          *
+*  limitation may not apply to You.                                    *
+*                                                                      *
+************************************************************************
+
+8. Litigation
+-------------
+
+Any litigation relating to this License may be brought only in the
+courts of a jurisdiction where the defendant maintains its principal
+place of business and such litigation shall be governed by laws of that
+jurisdiction, without reference to its conflict-of-law provisions.
+Nothing in this Section shall prevent a party's ability to bring
+cross-claims or counter-claims.
+
+9. Miscellaneous
+----------------
+
+This License represents the complete agreement concerning the subject
+matter hereof. If any provision of this License is held to be
+unenforceable, such provision shall be reformed only to the extent
+necessary to make it enforceable. Any law or regulation which provides
+that the language of a contract shall be construed against the drafter
+shall not be used to construe this License against a Contributor.
+
+10. Versions of the License
+---------------------------
+
+10.1. New Versions
+
+Mozilla Foundation is the license steward. Except as provided in Section
+10.3, no one other than the license steward has the right to modify or
+publish new versions of this License. Each version will be given a
+distinguishing version number.
+
+10.2. Effect of New Versions
+
+You may distribute the Covered Software under the terms of the version
+of the License under which You originally received the Covered Software,
+or under the terms of any subsequent version published by the license
+steward.
+
+10.3. Modified Versions
+
+If you create software not governed by this License, and you want to
+create a new license for such software, you may create and use a
+modified version of this License if you rename the license and remove
+any references to the name of the license steward (except to note that
+such modified license differs from this License).
+
+10.4. Distributing Source Code Form that is Incompatible With Secondary
+Licenses
+
+If You choose to distribute Source Code Form that is Incompatible With
+Secondary Licenses under the terms of this version of the License, the
+notice described in Exhibit B of this License must be attached.
+
+Exhibit A - Source Code Form License Notice
+-------------------------------------------
+
+  This Source Code Form is subject to the terms of the Mozilla Public
+  License, v. 2.0. If a copy of the MPL was not distributed with this
+  file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+If it is not possible or desirable to put the notice in a particular
+file, then You may include the notice in a location (such as a LICENSE
+file in a relevant directory) where a recipient would be likely to look
+for such a notice.
+
+You may add additional accurate notices of copyright ownership.
+
+Exhibit B - "Incompatible With Secondary Licenses" Notice
+---------------------------------------------------------
+
+  This Source Code Form is "Incompatible With Secondary Licenses", as
+  defined by the Mozilla Public License, v. 2.0.
+
--- a/browser/extensions/activity-stream/README.md
+++ b/browser/extensions/activity-stream/README.md
@@ -2,8 +2,21 @@
 
 This system add-on replaces the new tab page in Firefox with a new design and
 functionality as part of the Activity Stream project.
 
 The files in this directory, including vendor dependencies, are imported from the
 system-addon directory in https://github.com/mozilla/activity-stream.
 
 Read [docs/v2-system-addon](https://github.com/mozilla/activity-stream/tree/master/docs/v2-system-addon/1.GETTING_STARTED.md) for more detail.
+
+## Where should I file bugs?
+
+We regularly check the ActivityStream:NewTab component on Bugzilla.
+
+## For Developers
+
+If you are interested in contributing, take a look at [this guide](contributing.md) on where to find us and how to contribute,
+and [this guide](docs/v2-system-addon/1.GETTING_STARTED.md) for getting your development environment set up.
+
+## For Localizers
+
+Activity Stream localization is managed via [Pontoon](https://pontoon.mozilla.org/projects/activity-stream-new-tab/), not direct pull requests to the repository. If you want to fix a typo, add a new language, or simply know more about localization, please get in touch with the [existing localization team](https://pontoon.mozilla.org/teams/) for your language, or Mozilla’s [l10n-drivers](https://wiki.mozilla.org/L10n:Mozilla_Team#Mozilla_Corporation) for guidance.
new file mode 100755
--- /dev/null
+++ b/browser/extensions/activity-stream/bin/download-firefox-artifact
@@ -0,0 +1,67 @@
+#!/usr/bin/env bash -x
+
+# Forked from https://github.com/devtools-html/debugger.html/blob/master/bin/download-firefox-artifact
+#
+# This looks for a mozilla-central artifact build as a sibling of the
+# activity-stream tree.  If it's not there, it creates it.  If it is there, it
+# updates it.
+
+# If AS_GIT_BIN_REPO (the git repo from which prepare-mochitests-dev and
+# friends will be executed) isn't set in the environment, just use the repo
+#  we're running from.
+if [ -z ${AS_GIT_BIN_REPO+x} ]; then
+  ROOT=`dirname $0`
+  AS_GIT_BIN_REPO="../../../../activity-stream"
+else
+  ROOT=${AS_GIT_BIN_REPO}/bin
+fi
+
+# Compute the mozilla-central path based on whether AS_PINE_TEST_DIR is set
+# (i.e. whether this script has been called from test-merges.js)
+if [ -z ${AS_PINE_TEST_DIR+x} ]; then
+  FIREFOX_PATH="$ROOT/../../mozilla-central"
+else
+  FIREFOX_PATH=${AS_PINE_TEST_DIR}/mozilla-central
+fi
+
+# check that mercurial is installed
+if [ -z "`command -v hg`" ]; then
+  echo >&2 "mercurial is required for mochitests, use 'brew install mercurial' on MacOS";
+  exit 1;
+fi
+
+if [ -d "$FIREFOX_PATH" ]; then
+    # convert path to absolute path
+    FIREFOX_PATH=$(cd "$FIREFOX_PATH"; pwd)
+
+    # If we already have Firefox locally, just update it
+    cd "$FIREFOX_PATH";
+
+    if [ -n "`hg status`" ]; then
+        read -p "There are local changes to Firefox which will be overwritten. Are you sure? [Y/n] " -r
+        if [[ $REPLY == "n" ]]; then
+            exit 0;
+        fi
+
+        hg revert -a
+    fi
+
+    hg pull
+    hg update -C
+else
+    echo "Downloading Firefox source code, requires about 10-30min depending on connection"
+    hg clone https://hg.mozilla.org/mozilla-central/ "$FIREFOX_PATH"
+    # if somebody cancels (ctrl-c) out of the long download don't continue
+    exit_code=$?
+    if [ $exit_code -ne 0 ]; then
+      exit $exit_code
+    fi
+    cd "$FIREFOX_PATH"
+
+    # Make an artifact build so it builds much faster
+    echo "
+ac_add_options --enable-artifact-builds
+mk_add_options AUTOCLOBBER=1
+mk_add_options MOZ_OBJDIR=./objdir-frontend
+" > .mozconfig
+fi
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/bin/download-firefox-travis.sh
@@ -0,0 +1,12 @@
+#!/bin/bash
+# Copied and slightly modified from https://github.com/lidel/ipfs-firefox-addon/commit/d656832eec807ebae59543982dde96932ce5bb7c
+# Licensed under Creative Commons -  CC0 1.0 Universal - https://github.com/lidel/ipfs-firefox-addon/blob/master/LICENSE
+BUILD_TYPE=${1:-$FIREFOX_RELEASE}
+echo "Looking up latest URL for $BUILD_TYPE"
+BUILD_ROOT="/pub/firefox/tinderbox-builds/mozilla-${BUILD_TYPE}/"
+ROOT="https://archive.mozilla.org"
+LATEST=$(curl -s "$ROOT$BUILD_ROOT" | grep $BUILD_TYPE | grep -Po '<a href=".+">\K[[:digit:]]+' | sort -n | tail -1)
+echo "Latest build located at $ROOT$BUILD_ROOT$LATEST"
+FILE=$(curl -s "$ROOT$BUILD_ROOT$LATEST/" | grep '.tar.' | grep -Po '<a href="\K[^"]*')
+echo "URL: $ROOT$FILE"
+wget -O "firefox-${BUILD_TYPE}.tar.bz2" "$ROOT$FILE" && tar xf "firefox-${BUILD_TYPE}.tar.bz2"
new file mode 100755
--- /dev/null
+++ b/browser/extensions/activity-stream/bin/prepare-mochitests-dev
@@ -0,0 +1,65 @@
+#!/usr/bin/env bash -x -e
+#
+# -e means "exit on error", so that we don't have to constantly
+# check exit codes
+#
+# Forked from https://github.com/devtools-html/debugger.html/blob/master/bin/prepare-mochitests-dev
+#
+# This sets up a mozilla-central build for local mochitest development with an
+# exported activity-stream tree and test directory.
+
+# If AS_GIT_BIN_REPO (the git repo from which prepare-mochitests-dev and
+# friends will be executed) isn't set in the environment, just use the repo
+#  we're running from.
+if [ -z ${AS_GIT_BIN_REPO+x} ]; then
+  ROOT=`dirname $0`
+  AS_GIT_BIN_REPO="../activity-stream" # as seen from mozilla-central
+else
+  ROOT=${AS_GIT_BIN_REPO}/bin
+fi
+
+# Compute the mozilla-central path based on whether AS_PINE_TEST_DIR is set
+# (i.e. whether this script has been called from test-merges.js)
+if [ -z ${AS_PINE_TEST_DIR+x} ]; then
+  FIREFOX_PATH="$ROOT/../../mozilla-central"
+else
+  FIREFOX_PATH=${AS_PINE_TEST_DIR}/mozilla-central
+fi
+
+MC_MODULE_PATH="$FIREFOX_PATH/browser/extensions/activity-stream"
+
+# By default, just use mozilla-central + the export.  If ENABLE_MC_AS is set to
+# 1, patch on top of mozilla-central + the export to turn on the AS pref and
+# turn on the tests.  Once AS is on by default in mozilla-central, stuff
+# related to ENABLE_MC_AS can go away entirely.
+ENABLE_MC_AS=${ENABLE_MC_AS-0}
+
+# This will either download or update the local Firefox repo
+"$ROOT/download-firefox-artifact"
+
+# blow away any old bits in order to workaround bug 1335976 for users
+# who are using the default objdir-frontend
+rm -f ${FIREFOX_PATH}/objdir-frontend/dist/bin/browser/features/@activity-streams/*
+
+# Clean, package, and copy the activity stream files.
+npm run buildmc
+
+# Patch mozilla-central (on top of the export) so that AS is preffed on, and
+# the mochitests are turned on.
+shopt -s nullglob # don't explode if there are no patches right now
+if [ $ENABLE_MC_AS ]; then
+  PATCHES=$AS_GIT_BIN_REPO/mozilla-central-patches/*.diff
+  for p in $PATCHES
+  do
+    patch --directory="$FIREFOX_PATH" -p1 --force --no-backup-if-mismatch \
+    --input=$p
+  done
+fi
+shopt -u nullglob
+
+# Be sure that we've built, and that the test glop in the objdir has been
+# created.
+#
+cd "$FIREFOX_PATH"
+./mach build
+exit $?
new file mode 100755
--- /dev/null
+++ b/browser/extensions/activity-stream/bin/process-system-addon-for-package.js
@@ -0,0 +1,21 @@
+#! /usr/bin/env node
+"use strict";
+
+const MIN_FIREFOX_VERSION = "55.0a1";
+
+/* globals cd, mv, sed */
+require("shelljs/global");
+
+cd(process.argv[2]);
+
+// Convert install.rdf.in to install.rdf without substitutions
+mv("install.rdf.in", "install.rdf");
+sed("-i", /^#filter substitution/, "", "install.rdf");
+sed("-i", /(<em:minVersion>).+(<\/em:minVersion>)/, `$1${MIN_FIREFOX_VERSION}$2`, "install.rdf");
+sed("-i", /(<em:maxVersion>).+(<\/em:maxVersion>)/, "$1*$2", "install.rdf");
+
+// Convert jar.mn to chrome.manifest with just manifest
+mv("jar.mn", "chrome.manifest");
+sed("-i", /^[^%].*$/, "", "chrome.manifest");
+sed("-i", /^% (content.*) %(.*)$/, "$1 $2", "chrome.manifest");
+sed("-i", /^% (resource.*) %.*$/, "$1 .", "chrome.manifest");
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/bin/render-activity-stream-html.js
@@ -0,0 +1,352 @@
+ /* eslint-disable no-console */
+const fs = require("fs");
+const {mkdir} = require("shelljs");
+const path = require("path");
+
+// Note: this file is generated by webpack from content-src/activity-stream-prerender.jsx
+const {prerender} = require("./prerender");
+
+const DEFAULT_LOCALE = "en-US";
+const DEFAULT_OPTIONS = {
+  addonPath: "..",
+  baseUrl: "resource://activity-stream/"
+};
+
+// This locales list is to find any similar locales that we can reuse strings
+// instead of falling back to the default, e.g., use bn-BD strings for bn-IN.
+// https://hg.mozilla.org/mozilla-central/file/tip/browser/locales/l10n.toml
+const CENTRAL_LOCALES = [
+  "ach",
+  "af",
+  "an",
+  "ar",
+  "as",
+  "ast",
+  "az",
+  "be",
+  "bg",
+  "bn-BD",
+  "bn-IN",
+  "br",
+  "bs",
+  "ca",
+  "cak",
+  "crh",
+  "cs",
+  "cy",
+  "da",
+  "de",
+  "dsb",
+  "el",
+  "en-CA",
+  "en-GB",
+  "en-ZA",
+  "eo",
+  "es-AR",
+  "es-CL",
+  "es-ES",
+  "es-MX",
+  "et",
+  "eu",
+  "fa",
+  "ff",
+  "fi",
+  "fr",
+  "fy-NL",
+  "ga-IE",
+  "gd",
+  "gl",
+  "gn",
+  "gu-IN",
+  "he",
+  "hi-IN",
+  "hr",
+  "hsb",
+  "hu",
+  "hy-AM",
+  "ia",
+  "id",
+  "is",
+  "it",
+  "ja",
+  "ja-JP-mac",
+  "ka",
+  "kab",
+  "kk",
+  "km",
+  "kn",
+  "ko",
+  "lij",
+  "lo",
+  "lt",
+  "ltg",
+  "lv",
+  "mai",
+  "mk",
+  "ml",
+  "mr",
+  "ms",
+  "my",
+  "nb-NO",
+  "ne-NP",
+  "nl",
+  "nn-NO",
+  "oc",
+  "or",
+  "pa-IN",
+  "pl",
+  "pt-BR",
+  "pt-PT",
+  "rm",
+  "ro",
+  "ru",
+  "si",
+  "sk",
+  "sl",
+  "son",
+  "sq",
+  "sr",
+  "sv-SE",
+  "ta",
+  "te",
+  "th",
+  "tl",
+  "tr",
+  "uk",
+  "ur",
+  "uz",
+  "vi",
+  "wo",
+  "xh",
+  "zh-CN",
+  "zh-TW"
+];
+
+// Locales that should be displayed RTL
+const RTL_LIST = ["ar", "he", "fa", "ur"];
+
+/**
+ * Get the language part of the locale.
+ */
+function getLanguage(locale) {
+  return locale.split("-")[0];
+}
+
+/**
+ * Get the best strings for a single provided locale using similar locales and
+ * DEFAULT_LOCALE as fallbacks.
+ */
+function getStrings(locale, allStrings) {
+  const availableLocales = Object.keys(allStrings);
+
+  const language = getLanguage(locale);
+  const similarLocales = availableLocales.filter(other =>
+    other !== locale && getLanguage(other) === language);
+
+  // Rank locales from least desired to most desired
+  const localeFallbacks = [DEFAULT_LOCALE, ...similarLocales, locale];
+
+  // Get strings from each locale replacing with those from more desired ones
+  return Object.assign({}, ...localeFallbacks.map(l => allStrings[l]));
+}
+
+/**
+ * Get the text direction of the locale.
+ */
+function getTextDirection(locale) {
+  return RTL_LIST.includes(locale.split("-")[0]) ? "rtl" : "ltr";
+}
+
+/**
+ * templateHTML - Generates HTML for activity stream, given some options and
+ * prerendered HTML if necessary.
+ *
+ * @param  {obj} options
+ *         {str} options.locale         The locale to render in lang="" attribute
+ *         {str} options.direction      The language direction to render in dir="" attribute
+ *         {str} options.baseUrl        The base URL for all local assets
+ *         {bool} options.debug         Should we use dev versions of JS libraries?
+ * @param  {str} html    The prerendered HTML created with React.renderToString (optional)
+ * @return {str}         An HTML document as a string
+ */
+function templateHTML(options, html) {
+  const isPrerendered = !!html;
+  const debugString = options.debug ? "-dev" : "";
+  const scripts = [
+    "chrome://browser/content/contentSearchUI.js",
+    `${options.baseUrl}vendor/react${debugString}.js`,
+    `${options.baseUrl}vendor/react-dom${debugString}.js`,
+    `${options.baseUrl}vendor/prop-types.js`,
+    `${options.baseUrl}vendor/react-intl.js`,
+    `${options.baseUrl}vendor/redux.js`,
+    `${options.baseUrl}vendor/react-redux.js`,
+    `${options.baseUrl}prerendered/${options.locale}/activity-stream-strings.js`,
+    `${options.baseUrl}data/content/activity-stream.bundle.js`
+  ];
+  if (isPrerendered) {
+    scripts.unshift(`${options.baseUrl}prerendered/static/activity-stream-initial-state.js`);
+  }
+  return `<!doctype html>
+<html lang="${options.locale}" dir="${options.direction}">
+  <head>
+    <meta charset="utf-8">
+    <meta http-equiv="Content-Security-Policy-Report-Only" content="script-src 'unsafe-inline'; img-src http: https: data: blob:; style-src 'unsafe-inline'; child-src 'none'; object-src 'none'; report-uri https://tiles.services.mozilla.com/v4/links/activity-stream/csp">
+    <title>${options.strings.newtab_page_title}</title>
+    <link rel="icon" type="image/png" href="chrome://branding/content/icon32.png"/>
+    <link rel="stylesheet" href="chrome://browser/content/contentSearchUI.css" />
+    <link rel="stylesheet" href="${options.baseUrl}css/activity-stream.css" />
+  </head>
+  <body class="activity-stream">
+    <div id="root">${isPrerendered ? html : ""}</div>
+    <div id="snippets-container">
+      <div id="snippets"></div>
+    </div>
+    <script>
+// Don't directly load the following scripts as part of html to let the page
+// finish loading to render the content sooner.
+for (const src of ${JSON.stringify(scripts, null, 2)}) {
+  // These dynamically inserted scripts by default are async, but we need them
+  // to load in the desired order (i.e., bundle last).
+  const script = document.body.appendChild(document.createElement("script"));
+  script.async = false;
+  script.src = src;
+}
+    </script>
+  </body>
+</html>
+`;
+}
+
+/**
+ * templateJs - Generates a js file that passes the initial state of the prerendered
+ * DOM to the React version. This is necessary to ensure the checksum matches when
+ * React mounts so that it can attach to the prerendered elements instead of blowing
+ * them away.
+ *
+ * Note that this may no longer be necessary in React 16 and we should review whether
+ * it is still necessary.
+ *
+ * @param  {string} name The name of the global to expose
+ * @param  {string} desc Extra description to include in a js comment
+ * @param  {obj}   state The data to expose as a window global
+ * @return {str}         The js file as a string
+ */
+function templateJs(name, desc, state) {
+  return `// Note - this is a generated ${desc} file.
+window.${name} = ${JSON.stringify(state, null, 2)};
+`;
+}
+
+/**
+ * writeFiles - Writes to the desired files the result of a template given
+ * various prerendered data and options.
+ *
+ * @param {string} name          Something to identify in the console
+ * @param {string} destPath      Path to write the files to
+ * @param {Map}    filesMap      Mapping of a string file name to templater
+ * @param {Object} prerenderData Contains the html and state
+ * @param {Object} options       Various options for the templater
+ */
+function writeFiles(name, destPath, filesMap, {html, state}, options) {
+  for (const [file, templater] of filesMap) {
+    fs.writeFileSync(path.join(destPath, file), templater({html, options, state}));
+  }
+  console.log("\x1b[32m", `✓ ${name}`, "\x1b[0m");
+}
+
+const STATIC_FILES = new Map([
+  ["activity-stream-debug.html", ({options}) => templateHTML(options)],
+  ["activity-stream-initial-state.js", ({state}) => templateJs("gActivityStreamPrerenderedState", "static", state)],
+  ["activity-stream-prerendered-debug.html", ({html, options}) => templateHTML(options, html)]
+]);
+
+const LOCALIZED_FILES = new Map([
+  ["activity-stream-prerendered.html", ({html, options}) => templateHTML(options, html)],
+  ["activity-stream-strings.js", ({options: {locale, strings}}) => templateJs("gActivityStreamStrings", locale, strings)],
+  ["activity-stream.html", ({options}) => templateHTML(options)]
+]);
+
+/**
+ * main - Parses command line arguments, generates html and js with templates,
+ *        and writes files to their specified locations.
+ */
+function main() { // eslint-disable-line max-statements
+  // This code parses command line arguments passed to this script.
+  // Note: process.argv.slice(2) is necessary because the first two items in
+  // process.argv are paths
+  const args = require("minimist")(process.argv.slice(2), {
+    alias: {
+      addonPath: "a",
+      baseUrl: "b"
+    }
+  });
+
+  const baseOptions = Object.assign({debug: false}, DEFAULT_OPTIONS, args || {});
+  const addonPath = path.resolve(__dirname, baseOptions.addonPath);
+  const allStrings = require(`${baseOptions.addonPath}/data/locales.json`);
+  const extraLocales = Object.keys(allStrings).filter(locale =>
+    locale !== DEFAULT_LOCALE && !CENTRAL_LOCALES.includes(locale));
+
+  const prerenderedPath = path.join(addonPath, "prerendered");
+  console.log(`Writing prerendered files to individual directories under ${prerenderedPath}:`);
+
+  // Save default locale's strings to compare against other locales' strings
+  let defaultStrings;
+  let langStrings;
+  const isSubset = (strings, existing) => existing &&
+    Object.keys(strings).every(key => strings[key] === existing[key]);
+
+  // Process the default locale first then all the ones from mozilla-central
+  const localizedLocales = [];
+  const skippedLocales = [];
+  for (const locale of [DEFAULT_LOCALE, ...CENTRAL_LOCALES, ...extraLocales]) {
+    // Skip the locale if it would have resulted in duplicate packaged files
+    const strings = getStrings(locale, allStrings);
+    if (isSubset(strings, defaultStrings) || isSubset(strings, langStrings)) {
+      skippedLocales.push(locale);
+      continue;
+    }
+
+    const prerenderData  = prerender(locale, strings);
+    const options = Object.assign({}, baseOptions, {
+      direction: getTextDirection(locale),
+      locale,
+      strings
+    });
+
+    // Put locale-specific files in their own directory
+    const localePath = path.join(prerenderedPath, "locales", locale);
+    mkdir("-p", localePath);
+    writeFiles(locale, localePath, LOCALIZED_FILES, prerenderData, options);
+
+    // Only write static files once for the default locale
+    if (locale === DEFAULT_LOCALE) {
+      const staticPath = path.join(prerenderedPath, "static");
+      mkdir("-p", staticPath);
+      writeFiles(`${locale} (static)`, staticPath, STATIC_FILES, prerenderData,
+        Object.assign({}, options, {debug: true}));
+
+      // Save the default strings to compare against other locales' strings
+      defaultStrings = strings;
+    }
+
+    // Save the language's strings to maybe reuse for the next similar locales
+    if (getLanguage(locale) === locale) {
+      langStrings = strings;
+    }
+
+    localizedLocales.push(locale);
+  }
+
+  if (skippedLocales.length) {
+    console.log("\x1b[33m", `Skipped the following locales because they use the same strings as ${DEFAULT_LOCALE} or its language locale: ${skippedLocales.join(", ")}`, "\x1b[0m");
+  }
+  if (extraLocales.length) {
+    console.log("\x1b[31m", `✗ These locales were not in CENTRAL_LOCALES, but probably should be: ${extraLocales.join(", ")}`, "\x1b[0m");
+  }
+
+  // Provide some help to copy/paste locales if tests are failing
+  console.log(`\nIf aboutNewTabService tests are failing for unexpected locales, make sure its list is updated:\nconst ACTIVITY_STREAM_LOCALES = "${localizedLocales.join(" ")}".split(" ");`);
+}
+
+main();
new file mode 100755
--- /dev/null
+++ b/browser/extensions/activity-stream/bin/strings-import.js
@@ -0,0 +1,75 @@
+#! /usr/bin/env node
+"use strict";
+
+/* eslint-disable no-console */
+const fetch = require("node-fetch");
+
+/* globals cd, ls, mkdir, rm, ShellString */
+require("shelljs/global");
+
+const DEFAULT_LOCALE = "en-US";
+const L10N_CENTRAL = "https://hg.mozilla.org/l10n-central";
+const PROPERTIES_PATH = "raw-file/default/browser/chrome/browser/activity-stream/newtab.properties";
+const STRINGS_FILE = "strings.properties";
+
+// Get all the locales in l10n-central
+async function getLocales() {
+  console.log(`Getting locales from ${L10N_CENTRAL}`);
+
+  // Add all non-test sub repository locales
+  const locales = [];
+  const subrepos = await (await fetch(`${L10N_CENTRAL}?style=json`)).json();
+  subrepos.entries.forEach(({name}) => {
+    if (name !== "x-testing") {
+      locales.push(name);
+    }
+  });
+
+  console.log(`Got ${locales.length} locales: ${locales}`);
+  return locales;
+}
+
+// Save the properties file to the locale's directory
+async function saveProperties(locale) {
+  // Only save a file if the repository has the file
+  const url = `${L10N_CENTRAL}/${locale}/${PROPERTIES_PATH}`;
+  const response = await fetch(url);
+  if (!response.ok) {
+    // Indicate that this locale didn't save
+    return locale;
+  }
+
+  // Save the file to the right place
+  const text = await response.text();
+  mkdir(locale);
+  cd(locale);
+  ShellString(text).to(STRINGS_FILE);
+  cd("..");
+
+  // Indicate that we were successful in saving
+  return "";
+}
+
+// Replace and update each locale's strings
+async function updateLocales() {
+  console.log("Switching to and deleting existing l10n tree under: locales");
+
+  cd("locales");
+  ls().forEach(dir => {
+    // Keep the default/source locale as it might have newer strings
+    if (dir !== DEFAULT_LOCALE) {
+      rm("-r", dir);
+    }
+  });
+
+  // Save the properties file for each locale in parallel
+  const locales = await getLocales();
+  const missing = (await Promise.all(locales.map(saveProperties))).filter(v => v);
+  console.log(`Skipped ${missing.length} locales without strings: ${missing.sort()}`);
+
+  console.log(`
+Please check the diffs, add/remove files, and then commit the result. Suggested commit message:
+chore(l10n): Update from l10n-central ${new Date()}`);
+}
+
+updateLocales().catch(console.error);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/bin/test-merges.js
@@ -0,0 +1,344 @@
+#! /usr/bin/env node
+"use strict";
+
+/* eslint-disable no-console, mozilla/no-task */
+/* this is a node script; primary interaction is via console */
+
+const Task = require("co-task");
+const process = require("process");
+const path = require("path");
+const GitHubApi = require("@octokit/rest");
+const shelljs = require("shelljs");
+const child_process = require("child_process");
+const github = new GitHubApi();
+
+// some of our API requests need to be authenticated
+let token = process.env.AS_PINE_TOKEN;
+github.authenticate({type: "token", token});
+
+// note that this token MUST have the public_repo scope set in the github API
+
+const AS_REPO_OWNER = process.env.AS_REPO_OWNER || "mozilla";
+const AS_REPO_NAME = process.env.AS_REPO_NAME || "activity-stream";
+const AS_REPO = `${AS_REPO_OWNER}/${AS_REPO_NAME}`;
+const OLDEST_PR_DATE = "2017-03-17";
+const HG = "hg"; // mercurial
+const HG_BRANCH_NAME = "pine";
+const ALREADY_PUSHED_LABEL = "pushed-to-pine";
+const TREEHERDER_PREFIX = "https://treeherder.mozilla.org/#/jobs?repo=pine&revision=";
+
+// Path to the working directory where the export/commit operations will be
+// done.  Highly advisted to be used only for this testing purpose so you don't
+// accidently clobber real work.
+//
+// There will be two child directories:
+//
+// activity-stream - the github repo to be exported from.  MUST
+//
+// * be cloned by hand before running this script
+// * be 'npm install'ed
+// * have the ${ALREADY_PUSHED_LABEL} label created by hand
+// * have the user who has issued AS_PINE_TOKEN as a collaborator for the repo
+//   in order to be able to change labels.
+//
+// mozilla-central - the hg repo for firefox. Will be created if it doesn't
+// already exist.
+const {AS_PINE_TEST_DIR} = process.env;
+
+const TESTING_LOCAL_MC = path.join(AS_PINE_TEST_DIR, "mozilla-central");
+
+const SimpleGit = require("simple-git");
+const TESTING_LOCAL_GIT = path.join(AS_PINE_TEST_DIR, AS_REPO_NAME);
+const git = new SimpleGit(TESTING_LOCAL_GIT);
+
+// Mostly useful to specify during development of the test automation so that
+// prepare-mochitests-dev and friends from the development repo get used
+// instead of from the testing repo, which won't have had any changes checked in
+// just yet.
+const AS_GIT_BIN_REPO = process.env.AS_GIT_BIN_REPO || TESTING_LOCAL_GIT;
+
+const PREPARE_MOCHITESTS_DEV =
+  path.join(AS_GIT_BIN_REPO, "bin", "prepare-mochitests-dev");
+
+/**
+ * Find all PRs merged since ${OLDEST_PR_DATE} that don't have
+ * ${ALREADY_PUSHED_LABEL}
+ *
+ * @return {Promise} Promise that resolves with the search results or rejects
+ */
+function findNewlyMergedPRs() {
+  const searchTerms = [
+    // closed PRs in our repo
+    `repo:${AS_REPO}`, "type:pr", "state:closed", "is:merged",
+
+    // don't try and mochitest old closed stuff, we don't want to kick off a
+    // zillion test jobs
+    `merged:>=${OLDEST_PR_DATE}`,
+
+    // only look at merges to master
+    "base:master",
+
+    // if it's already been pushed to pine, don't do it again
+    `-label:${ALREADY_PUSHED_LABEL}`
+  ];
+
+  console.log(`Searching ${AS_REPO} for newly merged PRs`);
+  return github.search.issues({q: searchTerms.join("+")});
+}
+
+/**
+ * Return the commitId when the given PR was merged.  This is the one
+ * we will want to export and test.
+ *
+ * @param  {String} prNumber  The number of the PR to export.
+ * @return {String}           The commitId associated with the merge of this PR.
+ */
+function getPRMergeCommitId(prNumber) {
+  return github.issues.getEvents({
+    owner: AS_REPO_OWNER,
+    repo: AS_REPO_NAME,
+    issue_number: prNumber
+  }).then(({data}) => {
+    if (data.incomplete_results) {
+      // XXX should handle this case theoretically, but since we'll be running
+      // regularly from cron, it seems unlikely that we'll even hit 30 new
+      // merges (default GitHub page size) in a single run.
+      throw new Error("data.incomplete_results is true, aborting");
+    }
+
+    let mergeEvents = data.filter(item => item.event === "merged");
+    if (mergeEvents.length > 1) {
+      throw new Error("more than one merge event, aborting");
+    } else if (!mergeEvents.length) {
+      throw new Error(`Github returned no merge events for PR ${prNumber}, aborting.  Workaround: mark this PR as pushed-to-pine, so it gets skipped`);
+    }
+    let [mergeEvent] = mergeEvents;
+
+    if (!mergeEvent.commit_id) {
+      throw new Error("merge event has no commit id attached, aborted");
+    }
+
+    return mergeEvent.commit_id;
+  }).catch(err => { throw err; });
+}
+
+/**
+ * Checks out the given commit into ${TESTING_LOCAL_GIT}
+ *
+ * @param  {String} commitId
+ * @return {Promise<String[]|?>} Resolves with commit [id, message] on checkout, or
+ *                      rejects with error
+ */
+function checkoutGitCommit(commitId) {
+  return new Promise((resolve, reject) => {
+    console.log(`Fetching changes from github remote ${AS_REPO}...`);
+    // fetch any changes from the remote
+    git.fetch({}, (err, data) => {
+      if (err) {
+        reject(err);
+        return;
+      }
+      console.log(`Starting github checkout of ${commitId}...`);
+      git.checkout(commitId, (err2, data2) => {
+        if (err2) {
+          reject(err2);
+          return;
+        }
+
+        // Pass along the original commit message
+        git.show(["-s", "--format=%B"], (err3, data3) => {
+          if (err3) {
+            reject(err3);
+            return;
+          }
+          resolve([commitId, data3.trim()]);
+        });
+      });
+    });
+  });
+}
+
+function exportToLocalMC(commitId) {
+  return new Promise((resolve, reject) => {
+    console.log("Preparing mochitest dev environment...");
+    // Weirdly, /bin/yes causes npm-run-all bundle-static to explode, so we
+    // use echo.
+    shelljs.exec(`
+      echo yes | \
+        env AS_GIT_BIN_REPO=${AS_GIT_BIN_REPO} SYMLINK_TESTS=false \
+        ENABLE_MC_AS=1 ${PREPARE_MOCHITESTS_DEV}`,
+      {async: true, cwd: TESTING_LOCAL_GIT, silent: false}, (code, stdout, stderr) => {
+        if (code) {
+          reject(new Error(`${PREPARE_MOCHITESTS_DEV} failed, exit code: ${code}`));
+          return;
+        }
+
+        resolve(commitId);
+      });
+  });
+}
+
+function commitToHg([commitId, commitMsg]) {
+  return new Promise((resolve, reject) => {
+    // we use child_process.execFile here because shelljs.exec goes through
+    // the shell, which means that if the original commit message has shell
+    // quote characters, things can go haywire in weird ways.
+    console.log(`Committing exported ${commitId} to ${AS_REPO_NAME}...`);
+    child_process.execFile(HG,
+      [
+        "commit",
+        "--addremove",
+        "-m",
+        `${commitMsg}\n\nExport of ${commitId} from ${AS_REPO_OWNER}/${AS_REPO_NAME}`,
+        "."
+      ],
+      {cwd: TESTING_LOCAL_MC, env: process.env, timeout: 5 * 60 * 1000},
+      (code, stdout, stderr) => {
+        if (code) {
+          reject(new Error(`${HG} commit failed, output: ${stderr}`));
+          return;
+        }
+
+        resolve(code);
+      }
+    );
+  });
+}
+
+/**
+ * [pushToHgProjectBranch description]
+ *
+ * @return {Promise<String|Number>} resolves with the text written to XXXstdout, or
+ *                                  rejects with the exit code from ${HG}.
+ */
+function pushToHgProjectBranch() {
+  return new Promise((resolve, reject) => {
+    shelljs.exec(`${HG} push -f ${HG_BRANCH_NAME}`, {async: true, cwd: TESTING_LOCAL_MC},
+      (code, stdout, stderr) => {
+        if (code) {
+          reject(new Error(`${HG} failed, exit code: ${code}`));
+          return;
+        }
+
+        // Grab the last linked revision from the push output
+        const [rev] = stdout.split(/(?:\/rev\/|changeset=)/).slice(-1)[0].split("\n");
+        resolve(`[Treeherder: ${rev}](${TREEHERDER_PREFIX}${rev})`);
+      }
+    );
+  });
+}
+
+/**
+ * Remove last commit from the repo so the next artifact build will work right
+ */
+function stripTipFromHg() {
+  return new Promise((resolve, reject) => {
+    console.log("Stripping tip commit from mozilla-central so the next artifact build will work ...");
+    shelljs.exec(`${HG} strip --force --rev -1`,
+      {async: true, cwd: TESTING_LOCAL_MC},
+      (code, stdout, stderr) => {
+        if (code) {
+          reject(new Error(`${HG} strip failed, output: ${stderr}`));
+          return;
+        }
+
+        resolve(code);
+      }
+    );
+  });
+}
+
+function annotateGithubPR(prNumber, annotation) {
+  console.log(`Annotating ${prNumber} with ${annotation}...`);
+
+  // We use createComment from issues instead of pullRequests because we're
+  // not commenting on a particular commit
+  return github.issues.createComment({
+    owner: AS_REPO_OWNER,
+    repo: AS_REPO_NAME,
+    number: prNumber,
+    body: annotation
+  }).catch(reason => console.log(reason));
+}
+
+/**
+ * Labels a given github PR ${ALREADY_PUSHED_LABEL}.
+ */
+function labelGithubPR(prNumber) {
+  console.log(`Labeling PR ${prNumber} with ${ALREADY_PUSHED_LABEL}...`);
+
+  return github.issues.addLabels({
+    owner: AS_REPO_OWNER,
+    repo: AS_REPO_NAME,
+    number: prNumber,
+    labels: [ALREADY_PUSHED_LABEL]
+  }).catch(reason => console.log(reason));
+}
+
+function pushPR(pr) {
+  return getPRMergeCommitId(pr.number)
+
+    // get the merged commit to test
+    .then(checkoutGitCommit)
+
+    // use prepare-mochitest-dev to export
+    .then(exportToLocalMC)
+
+    // commit latest export to hg
+    .then(commitToHg)
+
+    // hg push
+    .then(() => pushToHgProjectBranch().catch(() => {
+      stripTipFromHg();
+      throw new Error("pushToHgProjectBranch failed; tip stripped from hg");
+    }))
+
+    // annotate PR with URL to watch
+    .then(annotation => annotateGithubPR(pr.number, annotation))
+
+    // make sure next artifact build doesn't explode
+    .then(() => stripTipFromHg())
+
+    // label with ${ALREADY_PUSHED_LABEL}
+    .then(() => labelGithubPR(pr.number))
+
+    .catch(err => {
+      console.log(err);
+      throw err;
+    });
+}
+
+function main() {
+  findNewlyMergedPRs().then(({data}) => {
+    if (data.incomplete_results) {
+      throw new Error("data.incomplete_results is true, aborting");
+    }
+
+    if (data.items.length === 0) {
+      console.log("No newly merged PRs to test");
+      return;
+    }
+
+    function* executePush() {
+      for (let pr of data.items) {
+        yield pushPR(pr);
+      }
+    }
+
+    // Serialize the execution of the export and pushing tests since each
+    // depend on exclusive access the state of the git and hg repos used to
+    // stage the tests.
+    Task.spawn(executePush).then(() => {
+      console.log("Processed all new merges.");
+    }).catch(reason => {
+      console.log("Something went wrong processing the merges:", reason);
+      process.exitCode = -1;
+    });
+  })
+  .catch(reason => {
+    console.error(reason);
+    process.exitCode = -1;
+  });
+}
+
+main();
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/bin/update-version.js
@@ -0,0 +1,62 @@
+#! /usr/bin/env node
+/* globals cd, sed */
+"use strict";
+
+/**
+ * Generate update install.rdf.in in the given directory with a version string
+ * composed of YYYY.MM.DD.${minuteOfDay}-${github_commit_hash}.
+ *
+ * @note The github hash is taken from the github repo in the current directory
+ * the script is run in.
+ *
+ * @note The minute of the day was chosen so that the version number is
+ * (more-or-less) consistently increasing (modulo clock-skew and builds that
+ * happen within a minute of each other), and although it's UTC, it won't likely
+ * be confused with something in a readers own time zone.
+ *
+ * @example generated version string: 2017.08.28.1217-ebda466c
+ */
+const process = require("process");
+require("shelljs/global");
+const simpleGit = require("simple-git")(process.cwd());
+
+const time = new Date();
+const minuteOfDay = time.getUTCHours() * 60 + time.getUTCMinutes();
+
+/**
+ * Return the given string padded with 0s out to the given width.
+ *
+ * XXX we should ditch this function in favor of using padStart once
+ * we start requiring Node 8.
+ *
+ * @param {any} s - the string to pad, will be coerced to String first
+ * @param {Number} width - what's the desired width?
+ */
+function zeroPadStart(s, width) {
+  let padded = String(s);
+  while (padded.length < width) {
+    padded = `0${padded}`;
+  }
+
+  return padded;
+}
+
+// git rev-parse --short HEAD
+simpleGit.revparse(["--short", "HEAD"], (err, gitHash) => {
+  if (err) {
+    // eslint-disable-next-line no-console
+    console.error(`SimpleGit.revparse failed: ${err}`);
+    throw new Error(`SimpleGit.revparse failed: ${err}`);
+  }
+
+  // eslint-disable-next-line prefer-template
+  let versionString = String(time.getUTCFullYear()) +
+    "." + zeroPadStart(time.getUTCMonth() + 1, 2) +
+    "." + zeroPadStart(time.getUTCDate(), 2) +
+    "." + zeroPadStart(minuteOfDay, 4) +
+    "-" + gitHash.trim();
+
+  cd(process.argv[2]);
+  sed("-i", /(<em:version>).+(<\/em:version>)$/, `$1${versionString}$2`,
+      "install.rdf.in");
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/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/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/content-src/activity-stream.jsx
@@ -0,0 +1,30 @@
+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
+    isFirstrun={global.document.location.href === "about:welcome"}
+    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/content-src/asrouter/asrouter-content.jsx
@@ -0,0 +1,188 @@
+import {actionCreators as ac, ASRouterActions as ra} from "common/Actions.jsm";
+import {OUTGOING_MESSAGE_NAME as AS_GENERAL_OUTGOING_MESSAGE_NAME} from "content-src/lib/init-store";
+import {ImpressionsWrapper} from "./components/ImpressionsWrapper/ImpressionsWrapper";
+import {OnboardingMessage} from "./templates/OnboardingMessage/OnboardingMessage";
+import React from "react";
+import ReactDOM from "react-dom";
+import {SimpleSnippet} from "./templates/SimpleSnippet/SimpleSnippet";
+
+const INCOMING_MESSAGE_NAME = "ASRouter:parent-to-child";
+const OUTGOING_MESSAGE_NAME = "ASRouter:child-to-parent";
+
+export const ASRouterUtils = {
+  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) {
+    ASRouterUtils.sendMessage({type: "BLOCK_MESSAGE_BY_ID", data: {id}});
+  },
+  blockBundle(bundle) {
+    ASRouterUtils.sendMessage({type: "BLOCK_BUNDLE", data: {bundle}});
+  },
+  executeAction({button_action, button_action_params}) {
+    if (button_action in ra) {
+      ASRouterUtils.sendMessage({type: button_action, data: {button_action_params}});
+    }
+  },
+  unblockById(id) {
+    ASRouterUtils.sendMessage({type: "UNBLOCK_MESSAGE_BY_ID", data: {id}});
+  },
+  unblockBundle(bundle) {
+    ASRouterUtils.sendMessage({type: "UNBLOCK_BUNDLE", data: {bundle}});
+  },
+  getNextMessage() {
+    ASRouterUtils.sendMessage({type: "GET_NEXT_MESSAGE"});
+  },
+  overrideMessage(id) {
+    ASRouterUtils.sendMessage({type: "OVERRIDE_MESSAGE", data: {id}});
+  },
+  sendTelemetry(ping) {
+    const payload = ac.ASRouterUserEvent(ping);
+    global.sendAsyncMessage(AS_GENERAL_OUTGOING_MESSAGE_NAME, payload);
+  }
+};
+
+// Note: nextProps/prevProps refer to props passed to <ImpressionsWrapper />, not <ASRouterUISurface />
+function shouldSendImpressionOnUpdate(nextProps, prevProps) {
+  return (nextProps.message.id && (!prevProps.message || prevProps.message.id !== nextProps.message.id));
+}
+
+export class ASRouterUISurface extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onMessageFromParent = this.onMessageFromParent.bind(this);
+    this.sendImpression = this.sendImpression.bind(this);
+    this.sendUserActionTelemetry = this.sendUserActionTelemetry.bind(this);
+    this.state = {message: {}, bundle: {}};
+  }
+
+  sendUserActionTelemetry(extraProps = {}) {
+    const {message, bundle} = this.state;
+    if (!message && !extraProps.message_id) {
+      throw new Error(`You must provide a message_id for bundled messages`);
+    }
+    const eventType = `${message.provider || bundle.provider}_user_event`;
+    ASRouterUtils.sendTelemetry({
+      message_id: message.id || extraProps.message_id,
+      source: extraProps.id,
+      action: eventType,
+      ...extraProps
+    });
+  }
+
+  sendImpression(extraProps) {
+    this.sendUserActionTelemetry({event: "IMPRESSION", ...extraProps});
+  }
+
+  onBlockById(id) {
+    return () => ASRouterUtils.blockById(id);
+  }
+
+  clearBundle(bundle) {
+    return () => ASRouterUtils.blockBundle(bundle);
+  }
+
+  onMessageFromParent({data: action}) {
+    switch (action.type) {
+      case "SET_MESSAGE":
+        this.setState({message: action.data});
+        break;
+      case "SET_BUNDLED_MESSAGES":
+        this.setState({bundle: action.data});
+        break;
+      case "CLEAR_MESSAGE":
+        if (action.data.id === this.state.message.id) {
+          this.setState({message: {}});
+        }
+        break;
+      case "CLEAR_BUNDLE":
+        if (this.state.bundle.bundle) {
+          this.setState({bundle: {}});
+        }
+        break;
+      case "CLEAR_ALL":
+        this.setState({message: {}, bundle: {}});
+    }
+  }
+
+  componentWillMount() {
+    ASRouterUtils.addListener(this.onMessageFromParent);
+    ASRouterUtils.sendMessage({type: "CONNECT_UI_REQUEST"});
+  }
+
+  componentWillUnmount() {
+    ASRouterUtils.removeListener(this.onMessageFromParent);
+  }
+
+  renderSnippets() {
+    return (
+      <ImpressionsWrapper
+        id="NEWTAB_FOOTER_BAR"
+        message={this.state.message}
+        sendImpression={this.sendImpression}
+        shouldSendImpressionOnUpdate={shouldSendImpressionOnUpdate}
+        // This helps with testing
+        document={this.props.document}>
+          <SimpleSnippet
+            {...this.state.message}
+            UISurface="NEWTAB_FOOTER_BAR"
+            getNextMessage={ASRouterUtils.getNextMessage}
+            onBlock={this.onBlockById(this.state.message.id)}
+            sendUserActionTelemetry={this.sendUserActionTelemetry} />
+      </ImpressionsWrapper>);
+  }
+
+  renderOnboarding() {
+    return (
+      <OnboardingMessage
+        {...this.state.bundle}
+        UISurface="NEWTAB_OVERLAY"
+        onAction={ASRouterUtils.executeAction}
+        onDoneButton={this.clearBundle(this.state.bundle.bundle)}
+        getNextMessage={ASRouterUtils.getNextMessage}
+        sendUserActionTelemetry={this.sendUserActionTelemetry} />);
+  }
+
+  render() {
+    const {message, bundle} = this.state;
+    if (!message.id && !bundle.template) { return null; }
+    if (bundle.template === "onboarding") { return this.renderOnboarding(); }
+    return this.renderSnippets();
+  }
+}
+
+ASRouterUISurface.defaultProps = {document: global.document};
+
+export class ASRouterContent {
+  constructor() {
+    this.initialized = false;
+    this.containerElement = null;
+  }
+
+  _mount() {
+    this.containerElement = global.document.getElementById("snippets-container");
+    ReactDOM.render(<ASRouterUISurface />, this.containerElement);
+  }
+
+  _unmount() {
+    ReactDOM.unmountComponentAtNode(this.containerElement);
+  }
+
+  init() {
+    this._mount();
+    this.initialized = true;
+  }
+
+  uninit() {
+    if (this.initialized) {
+      this._unmount();
+      this.initialized = false;
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/asrouter/components/Button/Button.jsx
@@ -0,0 +1,26 @@
+import React from "react";
+import {safeURI} from "../../template-utils";
+
+const ALLOWED_STYLE_TAGS = ["color", "backgroundColor"];
+
+export const Button = props => {
+  const style = {};
+
+  // Add allowed style tags from props, e.g. props.color becomes style={color: props.color}
+  for (const tag of ALLOWED_STYLE_TAGS) {
+    if (typeof props[tag] !== "undefined") {
+      style[tag] = props[tag];
+    }
+  }
+  // remove border if bg is set to something custom
+  if (style.backgroundColor) {
+    style.border = "0";
+  }
+
+  return (<a href={safeURI(props.url)}
+    onClick={props.onClick}
+    className={props.className || "ASRouterButton"}
+    style={style}>
+    {props.children}
+  </a>);
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/asrouter/components/Button/_Button.scss
@@ -0,0 +1,13 @@
+.ASRouterButton {
+  white-space: nowrap;
+  border-radius: 4px;
+  border: 1px solid var(--newtab-border-secondary-color);
+  background-color: var(--newtab-button-secondary-color);
+  font-family: inherit;
+  padding: 8px 15px;
+  margin-inline-start: 12px;
+  color: inherit;
+  .tall & {
+    margin-inline-start: 20px;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/asrouter/components/ImpressionsWrapper/ImpressionsWrapper.jsx
@@ -0,0 +1,60 @@
+import React from "react";
+
+export const VISIBLE = "visible";
+export const VISIBILITY_CHANGE_EVENT = "visibilitychange";
+
+/**
+ * Component wrapper used to send telemetry pings on every impression.
+ */
+export class ImpressionsWrapper extends React.PureComponent {
+  // 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.
+  sendImpressionOrAddListener() {
+    if (this.props.document.visibilityState === VISIBLE) {
+      this.props.sendImpression({id: this.props.id});
+    } else {
+      // We should only ever send the latest impression stats ping, so remove any
+      // older listeners.
+      if (this._onVisibilityChange) {
+        this.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 (this.props.document.visibilityState === VISIBLE) {
+          this.props.sendImpression({id: this.props.id});
+          this.props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
+        }
+      };
+      this.props.document.addEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
+    }
+  }
+
+  componentWillUnmount() {
+    if (this._onVisibilityChange) {
+      this.props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
+    }
+  }
+
+  componentDidMount() {
+    if (this.props.sendOnMount) {
+      this.sendImpressionOrAddListener();
+    }
+  }
+
+  componentDidUpdate(prevProps) {
+    if (this.props.shouldSendImpressionOnUpdate(this.props, prevProps)) {
+      this.sendImpressionOrAddListener();
+    }
+  }
+
+  render() {
+    return this.props.children;
+  }
+}
+
+ImpressionsWrapper.defaultProps = {
+  document: global.document,
+  sendOnMount: true
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/asrouter/components/ModalOverlay/ModalOverlay.jsx
@@ -0,0 +1,30 @@
+import React from "react";
+
+export class ModalOverlay extends React.PureComponent {
+  componentWillMount() {
+    this.setState({active: true});
+    document.body.classList.add("modal-open");
+  }
+
+  componentWillUnmount() {
+    document.body.classList.remove("modal-open");
+    this.setState({active: false});
+  }
+
+  render() {
+    const {active} = this.state;
+    const {title, button_label} = this.props;
+    return (
+      <div>
+        <div className={`modalOverlayOuter ${active ? "active" : ""}`} />
+        <div className={`modalOverlayInner ${active ? "active" : ""}`}>
+          <h2> {title} </h2>
+          {this.props.children}
+          <div className="footer">
+            <button onClick={this.props.onDoneButton} className="button primary modalButton"> {button_label} </button>
+          </div>
+        </div>
+      </div>
+    );
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/asrouter/components/ModalOverlay/_ModalOverlay.scss
@@ -0,0 +1,93 @@
+.activity-stream {
+  &.modal-open {
+    overflow: hidden;
+  }
+}
+.modalOverlayOuter {
+  background: $white;
+  opacity: 0.93;
+  height: 100%;
+  position: fixed;
+  top: 0;
+  width: 100%;
+  display: none;
+  z-index: 100000;
+
+  &.active {
+    display: block;
+  }
+}
+
+.modalOverlayInner {
+  width: 960px;
+  height: 510px;
+  position: fixed;
+  top: calc(50% - 255px); // halfway down minus half the height of the modal
+  left: calc(50% - 480px); // halfway across minus half the width of the modal
+  background: $white;
+  box-shadow: 0 1px 15px 0 $black-30;
+  border-radius: 4px;
+  display: none;
+  z-index: 100001;
+
+
+  // modal takes over entire screen
+  @media(max-width: 960px) {
+    width: 100%;
+    height: 100%;
+    top: 0;
+    left: 0;
+    box-shadow: none;
+    border-radius: 0;
+  }
+
+  // if modal is short enough, add a vertical scroll bar
+  @media(max-width: 850px) and (max-height: 730px) {
+    overflow-y: scroll;
+  }
+
+  &.active {
+    display: block;
+  }
+
+  h2 {
+    color: $grey-60;
+    text-align: center;
+    font-weight: 200;
+    margin-top: 30px;
+    font-size: 28px;
+    line-height: 37px;
+    letter-spacing: -0.13px;
+
+    @media(max-width: 960px) {
+      margin-top: 100px;
+    }
+
+    @media(max-width: 850px) {
+      margin-top: 30px;
+    }
+  }
+
+  .footer {
+    border-top: 1px solid $grey-30;
+    height: 70px;
+    width: 100%;
+    position: absolute;
+    bottom: 0;
+    text-align: center;
+    background-color: $white;
+
+    // if modal is short enough, footer becomes sticky
+    @media(max-width: 850px) and (max-height: 730px) {
+      position: sticky;
+    }
+
+    .modalButton {
+      margin-top: 20px;
+      width: 150px;
+      height: 30px;
+      padding: 4px 0 6px 0;
+      font-size: 15px;
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/asrouter/components/SnippetBase/SnippetBase.jsx
@@ -0,0 +1,26 @@
+import React from "react";
+
+export class SnippetBase extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onBlockClicked = this.onBlockClicked.bind(this);
+  }
+
+  onBlockClicked() {
+    this.props.sendUserActionTelemetry({event: "BLOCK", id: this.props.UISurface});
+    this.props.onBlock();
+  }
+
+  render() {
+    const {props} = this;
+
+    const containerClassName = `SnippetBaseContainer${props.className ? ` ${props.className}` : ""}`;
+
+    return (<div className={containerClassName}>
+      <div className="innerWrapper">
+        {props.children}
+      </div>
+      <button className="blockButton" onClick={this.onBlockClicked} />
+    </div>);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/asrouter/components/SnippetBase/_SnippetBase.scss
@@ -0,0 +1,58 @@
+.SnippetBaseContainer {
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  background-color: var(--newtab-snippets-background-color);
+  color: var(--newtab-text-primary-color);
+  font-size: 12px;
+  line-height: 16px;
+  border-top: 1px solid var(--newtab-snippets-hairline-color);
+  box-shadow: $shadow-secondary;
+  display: flex;
+  align-items: center;
+
+  .innerWrapper {
+    margin: 0 auto;
+    display: flex;
+    align-items: center;
+    padding: 12px $section-horizontal-padding;
+
+    // This is to account for the block button on smaller screens
+    padding-inline-end: 36px;
+    @media (min-width: $break-point-large) {
+      padding-inline-end: $section-horizontal-padding;
+    }
+
+    max-width: $wrapper-max-width-large;
+    @media (min-width: $break-point-widest) {
+      max-width: $wrapper-max-width-widest;
+    }
+  }
+
+  .blockButton {
+    display: none;
+    background: none;
+    border: 0;
+    position: absolute;
+    top: 50%;
+    offset-inline-end: 12px;
+    height: 16px;
+    width: 16px;
+    background-image: url('resource://activity-stream/data/content/assets/glyph-dismiss-16.svg');
+    -moz-context-properties: fill;
+    fill: var(--newtab-icon-primary-color);
+    opacity: 0.5;
+    margin-top: -8px;
+    padding: 0;
+    cursor: pointer;
+
+    @media (min-width: 766px) {
+      offset-inline-end: 24px;
+    }
+  }
+
+  &:hover .blockButton {
+    display: block;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/asrouter/schemas/message-format.md
@@ -0,0 +1,44 @@
+## Activity Stream Router message format
+
+Field name | Type     | Required | Description | Example / Note
+---        | ---      | ---      | ---         | ---
+`id`       | `string` | Yes | A unique identifier for the message that should not conflict with any other previous message | `ONBOARDING_1`
+`template` | `string` | Yes | An id matching an existing Activity Stream Router template | [See example](https://github.com/mozilla/activity-stream/blob/33669c67c2269078a6d3d6d324fb48175d98f634/system-addon/content-src/message-center/templates/SimpleSnippet.jsx)
+`publish_start` | `date` | No | When to start showing the message | `1524474850876`
+`publish_end` | `date` | No | When to stop showing the message | `1524474850876`
+`content` | `object` | Yes | An object containing all variables/props to be rendered in the template. Subset of allowed tags detailed below. | [See example below](#html-subset)
+`campaign` | `string` | No | Campaign id that the message belongs to | `RustWebAssembly`
+`targeting` | `string` `JEXL` | Yes | A [JEXL expression](http://normandy.readthedocs.io/en/latest/user/filter_expressions.html#jexl-basics) with all targeting information needed in order to decide if the message is shown | Not yet implemented, [some examples](http://normandy.readthedocs.io/en/latest/user/filter_expressions.html#examples)
+
+### Message example
+```javascript
+{
+  id: "ONBOARDING_1",
+  template: "simple_snippet",
+  content: {
+    title: "Find it faster",
+    body: "Access all of your favorite search engines with a click. Search the whole Web or just one website from the search box."
+  }
+}
+```
+
+### HTML subset
+The following tags are allowed in the content of the snippet: `i, b, u, strong, em, br`.
+
+Links cannot be rendered using regular anchor tags because [Fluent does not allow for href attributes](https://github.com/projectfluent/fluent.js/blob/a03d3aa833660f8c620738b26c80e46b1a4edb05/fluent-dom/src/overlay.js#L13). They will be wrapped in custom tags, for example `<cta>link</cta>` and the url will be provided as part of the payload:
+```
+{
+  "id": "7899",
+  "content": {
+    "text": "Use the CMD (CTRL) + T keyboard shortcut to <cta>open a new tab quickly!</cta>",
+    "links": {
+      "cta": {
+        "url": "https://support.mozilla.org/en-US/kb/keyboard-shortcuts-perform-firefox-tasks-quickly"
+      }
+    }
+  }
+}
+```
+If a tag that is not on the allowed is used, the text content will be extracted and displayed.
+
+Grouping multiple allowed elements is not possible, only the first level will be used: `<u><b>text</b></u>` will be interpreted as `<u>text</u>`.
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/asrouter/schemas/provider-response.schema.json
@@ -0,0 +1,37 @@
+{
+  "title": "ProviderResponse",
+  "description": "A response object for remote providers of AS Router",
+  "type": "object",
+  "properties": {
+    "messages": {
+      "type": "array",
+      "description": "An array of router messages",
+      "items": {
+        "title": "RouterMessage",
+        "description": "A definition of an individual message",
+        "type": "object",
+        "properties": {
+          "id": {
+            "type": "string",
+            "description": "A unique identifier for the message that should not conflict with any other previous message"
+          },
+          "template": {
+            "type": "string",
+            "description": "An id matching an existing Activity Stream Router template",
+            "enum": ["simple_snippet"]
+          },
+          "content": {
+            "type": "object",
+            "description": "An object containing all variables/props to be rendered in the template. See individual template schemas for details."
+          },
+          "targeting": {
+            "type": "string",
+            "description": "a JEXL expression representing targeting information"
+          }
+        },
+        "required": ["id", "template", "content"]
+      }
+    }
+  },
+  "required": ["messages"]
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/asrouter/template-utils.js
@@ -0,0 +1,17 @@
+export function safeURI(url) {
+  if (!url) {
+    return "";
+  }
+  const {protocol} = new URL(url);
+  const isAllowed = [
+    "http:",
+    "https:",
+    "data:",
+    "resource:",
+    "chrome:"
+  ].includes(protocol);
+  if (!isAllowed) {
+    console.warn(`The protocol ${protocol} is not allowed for template URLs.`); // eslint-disable-line no-console
+  }
+  return isAllowed ? url : "";
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.jsx
@@ -0,0 +1,52 @@
+import {ModalOverlay} from "../../components/ModalOverlay/ModalOverlay";
+import React from "react";
+
+class OnboardingCard extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onClick = this.onClick.bind(this);
+  }
+
+  onClick() {
+    const {props} = this;
+    props.sendUserActionTelemetry({event: "CLICK_BUTTON", message_id: props.id, id: props.UISurface});
+    props.onAction(props.content);
+  }
+
+  render() {
+    const {content} = this.props;
+    return (
+      <div className="onboardingMessage">
+        <div className={`onboardingMessageImage ${content.icon}`} />
+        <div className="onboardingContent">
+          <span>
+            <h3> {content.title} </h3>
+            <p> {content.text} </p>
+          </span>
+          <span>
+            <button className="button onboardingButton" onClick={this.onClick}> {content.button_label} </button>
+          </span>
+        </div>
+      </div>
+    );
+  }
+}
+
+export class OnboardingMessage extends React.PureComponent {
+  render() {
+    const {props} = this;
+    return (
+      <ModalOverlay {...props} button_label={"Start Browsing"} title={"Welcome to Firefox"}>
+        <div className="onboardingMessageContainer">
+          {props.bundle.map(message => (
+            <OnboardingCard key={message.id}
+              sendUserActionTelemetry={props.sendUserActionTelemetry}
+              onAction={props.onAction}
+              UISurface={props.UISurface}
+              {...message} />
+          ))}
+        </div>
+      </ModalOverlay>
+    );
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/asrouter/templates/OnboardingMessage/_OnboardingMessage.scss
@@ -0,0 +1,142 @@
+.onboardingMessageContainer {
+  display: grid;
+  grid-column-gap: 21px;
+  grid-template-columns: auto auto auto;
+  padding-left: 30px;
+  padding-right: 30px;
+
+  // at 850px, the cards go from vertical layout to horizontal layout
+  @media(max-width: 850px) {
+    grid-template-columns: none;
+    grid-template-rows: auto auto auto;
+    padding-left: 110px;
+    padding-right: 110px;
+  }
+}
+
+.onboardingMessage {
+  height: 340px;
+  text-align: center;
+  padding: 13px;
+  font-weight: 200;
+
+  // at 850px, img floats left, content floats right next to it
+  @media(max-width: 850px) {
+    height: 170px;
+    text-align: left;
+    padding: 10px;
+    border-bottom: 1px solid #D8D8D8;
+    display: flex;
+    margin-bottom: 11px;
+
+    &:last-child {
+      border: none;
+    }
+
+    .onboardingContent {
+      padding-left: 10px;
+      height: 100%;
+
+      > span > h3 {
+        margin-top: 0;
+        margin-bottom: 4px;
+        font-weight: 400;
+      }
+
+      > span > p {
+        margin-top: 0;
+        line-height: 22px;
+        font-size: 15px;
+      }
+    }
+  }
+
+  .onboardingMessageImage {
+    height: 100px;
+    width: 120px;
+    background-size: 120px;
+    background-position: center center;
+    background-repeat: no-repeat;
+    display: inline-block;
+    vertical-align: middle;
+
+
+    @media(max-width: 850px) {
+      height: 75px;
+      min-width: 80px;
+      background-size: 80px;
+    }
+
+    &.addons {
+      background-image: url("resource://activity-stream/data/content/assets/illustration-addons@2x.png");
+    }
+
+    &.privatebrowsing {
+      background-image: url("resource://activity-stream/data/content/assets/illustration-privatebrowsing@2x.png");
+    }
+
+    &.screenshots {
+      background-image: url("resource://activity-stream/data/content/assets/illustration-screenshots@2x.png");
+    }
+
+    &.gift {
+      background-image: url("resource://activity-stream/data/content/assets/illustration-gift@2x.png");
+    }
+  }
+
+  .onboardingContent {
+    height: 175px;
+
+    > span > h3 {
+      color: $grey-90;
+      margin-bottom: 8px;
+      font-weight: 400;
+    }
+
+    > span > p {
+      color: $grey-60;
+      margin-top: 0;
+      height: 130px;
+      margin-bottom: 12px;
+      font-size: 15px;
+      line-height: 22px;
+    }
+  }
+
+  .onboardingButton {
+    background-color: $grey-90-10;
+    border: none;
+    width: 150px;
+    height: 30px;
+    margin-bottom: 23px;
+    padding: 4px 0 6px 0;
+    font-size: 15px;
+
+    // at 850px, the button shimmies down and to the right
+    @media(max-width: 850px) {
+      float: right;
+      margin-top: -60px;
+      margin-right: -10px;
+    }
+  }
+
+
+  &::before {
+    content: '';
+    height: 220px;
+    width: 1px;
+    position: absolute;
+    background-color: #D8D8D8;
+    margin-top: 40px;
+    margin-left: 215px;
+
+    // at 850px, the line goes from vertical to horizontal
+    @media(max-width: 850px) {
+      content: none;
+    }
+  }
+
+  &:last-child::before {
+    content: none;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx
@@ -0,0 +1,53 @@
+import {Button} from "../../components/Button/Button";
+import React from "react";
+import {safeURI} from "../../template-utils";
+import {SnippetBase} from "../../components/SnippetBase/SnippetBase";
+
+const DEFAULT_ICON_PATH = "chrome://branding/content/icon64.png";
+
+export class SimpleSnippet extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onButtonClick = this.onButtonClick.bind(this);
+  }
+
+  onButtonClick() {
+    this.props.sendUserActionTelemetry({event: "CLICK_BUTTON", id: this.props.UISurface});
+  }
+
+  renderTitle() {
+    const {title} = this.props.content;
+    return title ? <h3 className="title">{title}</h3> : null;
+  }
+
+  renderTitleIcon() {
+    const titleIcon = safeURI(this.props.content.title_icon);
+    return titleIcon ? <span className="titleIcon" style={{backgroundImage: `url("${titleIcon}")`}} /> : null;
+  }
+
+  renderButton(className) {
+    const {props} = this;
+    return (<Button
+      className={className}
+      onClick={this.onButtonClick}
+      url={props.content.button_url}
+      color={props.content.button_color}
+      backgroundColor={props.content.button_background_color}>
+      {props.content.button_label}
+    </Button>);
+  }
+
+  render() {
+    const {props} = this;
+    const hasLink = props.content.button_url && props.content.button_type === "anchor";
+    const hasButton = props.content.button_url && !props.content.button_type;
+    const className = `SimpleSnippet${props.content.tall ? " tall" : ""}`;
+    return (<SnippetBase {...props} className={className}>
+      <img src={safeURI(props.content.icon) || DEFAULT_ICON_PATH} className="icon" />
+      <div>
+        {this.renderTitleIcon()} {this.renderTitle()} <p className="body">{props.content.text}</p> {hasLink ? this.renderButton("ASRouterAnchor") : null}
+      </div>
+      {hasButton ? <div>{this.renderButton()}</div> : null}
+    </SnippetBase>);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json
@@ -0,0 +1,66 @@
+{
+  "title": "SimpleSnippet",
+  "description": "A simple template with an icon, text, and optional button.",
+  "version": "0.2.0",
+  "type": "object",
+  "properties": {
+    "title": {
+      "type": "string",
+      "description": "Snippet title displayed before snippet text"
+    },
+    "text": {
+      "type": "string",
+      "description": "Main body text of snippet"
+    },
+    "icon": {
+      "type": "string",
+      "description": "Snippet icon. 64x64px. SVG or PNG preferred."
+    },
+    "title_icon": {
+      "type": "string",
+      "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale."
+    },
+    "button_url": {
+      "type": "string",
+      "description": "A url, button_label links to this"
+    },
+    "button_label": {
+      "type": "string",
+      "description": "Text for a button next to main snippet text that links to button_url. Requires button_url."
+    },
+    "button_color": {
+      "type": "string",
+      "description": "The text color of the button. Valid CSS color."
+    },
+    "button_background_color": {
+      "type": "string",
+      "description": "The background color of the button. Valid CSS color."
+    },
+    "button_type": {
+      "type": "string",
+      "enum": ["anchor", "button"],
+      "description": "(**temporary**, until we get html support in text field Bug 1457233) Style for button, either a regular button or a text link."
+    },
+    "tall": {
+      "type": "boolean",
+      "description": "To be used by fundraising only, increases height to roughly 120px. Defaults to false."
+    },
+    "links": {
+      "additionalProperties": {
+        "url": {
+          "type": "string",
+          "description": "The url where the link points to."
+        }
+      }
+    }
+  },
+  "additionalProperties": false,
+  "required": ["text"],
+  "dependencies": {
+    "button_url": ["button_label"],
+    "button_label": ["button_url"],
+    "button_type": ["button_url"],
+    "button_color": ["button_url"],
+    "button_background_color": ["button_url"]
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/asrouter/templates/SimpleSnippet/_SimpleSnippet.scss
@@ -0,0 +1,42 @@
+.SimpleSnippet {
+  &.tall {
+    padding: 27px 0;
+  }
+
+  .title {
+    display: inline;
+    font-size: inherit;
+    margin: 0;
+  }
+
+  .titleIcon {
+    background-repeat: no-repeat;
+    background-size: 14px;
+    height: 16px;
+    width: 16px;
+    margin-top: 2px;
+    margin-inline-end: 2px;
+    display: inline-block;
+    vertical-align: top;
+  }
+
+  .body {
+    display: inline;
+    margin: 0;
+  }
+
+  .icon {
+    height: 42px;
+    width: 42px;
+    margin-inline-end: 12px;
+    flex-shrink: 0;
+  }
+  &.tall .icon {
+    margin-inline-end: 20px;
+  }
+
+  .ASRouterAnchor {
+    color: inherit;
+    text-decoration: underline;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx
@@ -0,0 +1,101 @@
+import {ASRouterUtils} from "../../asrouter/asrouter-content";
+import React from "react";
+
+export class ASRouterAdmin extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onMessage = this.onMessage.bind(this);
+    this.findOtherBundledMessagesOfSameTemplate = this.findOtherBundledMessagesOfSameTemplate.bind(this);
+    this.state = {};
+  }
+
+  onMessage({data: action}) {
+    if (action.type === "ADMIN_SET_STATE") {
+      this.setState(action.data);
+    }
+  }
+
+  componentWillMount() {
+    ASRouterUtils.sendMessage({type: "ADMIN_CONNECT_STATE"});
+    ASRouterUtils.addListener(this.onMessage);
+  }
+
+  componentWillUnmount() {
+    ASRouterUtils.removeListener(this.onMessage);
+  }
+
+  findOtherBundledMessagesOfSameTemplate(template) {
+    return this.state.messages.filter(msg => msg.template === template && msg.bundled);
+  }
+
+  handleBlock(msg) {
+    if (msg.bundled) {
+      // If we are blocking a message that belongs to a bundle, block all other messages that are bundled of that same template
+      let bundle = this.findOtherBundledMessagesOfSameTemplate(msg.template);
+      return () => ASRouterUtils.blockBundle(bundle);
+    }
+    return () => ASRouterUtils.blockById(msg.id);
+  }
+
+  handleUnblock(msg) {
+    if (msg.bundled) {
+      // If we are unblocking a message that belongs to a bundle, unblock all other messages that are bundled of that same template
+      let bundle = this.findOtherBundledMessagesOfSameTemplate(msg.template);
+      return () => ASRouterUtils.unblockBundle(bundle);
+    }
+    return () => ASRouterUtils.unblockById(msg.id);
+  }
+
+  handleOverride(id) {
+    return () => ASRouterUtils.overrideMessage(id);
+  }
+
+  renderMessageItem(msg) {
+    const isCurrent = msg.id === this.state.lastMessageId;
+    const isBlocked = this.state.blockList.includes(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) : this.handleBlock(msg)}>{isBlocked ? "Unblock" : "Block"}</button>
+       {isBlocked ? null : <button className="button" onClick={this.handleOverride(msg.id)}>Show</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>);
+  }
+
+  renderProviders() {
+    return (<table><tbody>
+      {this.state.providers.map((provider, i) => (<tr className="message-item" key={i}>
+        <td>{provider.id}</td>
+        <td>{provider.type === "remote" ? <a target="_blank" href={provider.url}>{provider.url}</a> : "(local)"}</td>
+      </tr>))}
+    </tbody></table>);
+  }
+
+  render() {
+    return (<div className="asrouter-admin outer-wrapper">
+      <h1>AS Router Admin</h1>
+      <button className="button primary" onClick={ASRouterUtils.getNextMessage}>Refresh Current Message</button>
+      <h2>Message Providers</h2>
+      {this.state.providers ? this.renderProviders() : null}
+      <h2>Messages</h2>
+      {this.renderMessages()}
+    </div>);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/ASRouterAdmin/ASRouterAdmin.scss
@@ -0,0 +1,78 @@
+
+.asrouter-admin {
+  $border-color: var(--newtab-border-secondary-color);
+  $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 0 92px;
+
+  h1 {
+    font-weight: 200;
+    font-size: 32px;
+  }
+
+  table {
+    border-collapse: collapse;
+    width: 100%;
+  }
+
+  .message-item {
+    &:first-child td {
+      border-top: 1px solid $border-color;
+    }
+
+    td {
+      vertical-align: top;
+      border-bottom: 1px solid $border-color;
+      padding: 8px;
+
+      &:first-child {
+        border-left: 1px solid $border-color;
+      }
+
+      &:last-child {
+        border-right: 1px solid $border-color;
+      }
+    }
+
+    &.current {
+      .message-id span {
+        background: $yellow-50;
+        padding: 2px 5px;
+
+        .dark-theme & {
+          color: $black;
+        }
+      }
+    }
+
+    &.blocked {
+      .message-id,
+      .message-summary {
+        opacity: 0.5;
+      }
+
+      .message-id {
+        opacity: 0.5;
+      }
+    }
+
+    .message-id {
+      font-family: $monospace;
+      font-size: 12px;
+    }
+  }
+
+  pre {
+    background: var(--newtab-textbox-background-color);
+    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/content-src/components/Base/Base.jsx
@@ -0,0 +1,148 @@
+import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
+import {addLocaleData, injectIntl, IntlProvider} from "react-intl";
+import {ASRouterAdmin} from "content-src/components/ASRouterAdmin/ASRouterAdmin";
+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 {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";
+import {StartupOverlay} from "content-src/components/StartupOverlay/StartupOverlay";
+
+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,
+      this.props.isFirstrun ? "welcome" : ""
+    ].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.asrouterExperimentEnabled && window.location.hash === "#asrouter") {
+      return (<ASRouterAdmin />);
+    }
+
+    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"
+    ].filter(v => v).join(" ");
+
+    return (
+      <div>
+        <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>
+        {this.props.isFirstrun && <StartupOverlay />}
+      </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/content-src/components/Base/_Base.scss
@@ -0,0 +1,96 @@
+.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;
+  }
+
+  @media (min-width: $break-point-widest) {
+    width: $wrapper-max-width-widest;
+  }
+
+  section {
+    margin-bottom: $section-spacing;
+    position: relative;
+  }
+}
+
+.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/content-src/components/Card/Card.jsx
@@ -0,0 +1,312 @@
+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,
+      cardImage: null
+    };
+    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 {cardImage} = this.state;
+    if (!cardImage) {
+      return;
+    }
+
+    const imageUrl = cardImage.url;
+    if (!this.state.imageLoaded) {
+      // Initialize a promise to share a load across multiple card updates
+      if (!gImageLoading.has(imageUrl)) {
+        const loaderPromise = new Promise((resolve, reject) => {
+          const loader = new Image();
+          loader.addEventListener("load", resolve);
+          loader.addEventListener("error", reject);
+          loader.src = imageUrl;
+        });
+
+        // Save and remove the promise only while it's pending
+        gImageLoading.set(imageUrl, loaderPromise);
+        loaderPromise.catch(ex => ex).then(() => gImageLoading.delete(imageUrl)).catch();
+      }
+
+      // Wait for the image whether just started loading or reused promise
+      await gImageLoading.get(imageUrl);
+
+      // Only update state if we're still waiting to load the original image
+      if (_Card.isImageInState(this.state, this.props.link.image) && !this.state.imageLoaded) {
+        this.setState({imageLoaded: true});
+      }
+    }
+  }
+
+  /**
+   * Checks if `.image` property on link object is a local image with blob data.
+   * This function only works for props since state has `.url` and not `.data`.
+   *
+   * @param {obj|string} image
+   * @returns {bool} true if image is a local image object, otherwise false
+   *                 (otherwise, image will be a URL as a string)
+   */
+  static isLocalImageObject(image) {
+    return image && image.data && image.path;
+  }
+
+  /**
+   * Helper to obtain the next state based on nextProps and prevState.
+   *
+   * NOTE: Rename this method to getDerivedStateFromProps when we update React
+   *       to >= 16.3. We will need to update tests as well. We cannot rename this
+   *       method to getDerivedStateFromProps now because there is a mismatch in
+   *       the React version that we are using for both testing and production.
+   *       (i.e. react-test-render => "16.3.2", react => "16.2.0").
+   *
+   * See https://github.com/airbnb/enzyme/blob/master/packages/enzyme-adapter-react-16/package.json#L43.
+   */
+  static getNextStateFromProps(nextProps, prevState) {
+    const {image} = nextProps.link;
+    const imageInState = _Card.isImageInState(prevState, image);
+    let nextState = null;
+
+    // Image is updating.
+    if (!imageInState && nextProps.link) {
+      nextState = {imageLoaded: false};
+    }
+
+    if (imageInState) {
+      return nextState;
+    }
+
+    nextState = nextState || {};
+
+    // Since image was updated, attempt to revoke old image blob URL, if it exists.
+    _Card.maybeRevokeImageBlob(prevState);
+
+    if (!image) {
+      nextState.cardImage = null;
+    } else if (_Card.isLocalImageObject(image)) {
+      nextState.cardImage = {url: global.URL.createObjectURL(image.data), path: image.path};
+    } else {
+      nextState.cardImage = {url: image};
+    }
+
+    return nextState;
+  }
+
+  /**
+   * Helper to conditionally revoke the previous card image if it is a blob.
+   */
+  static maybeRevokeImageBlob(prevState) {
+    if (prevState.cardImage && prevState.cardImage.path) {
+      global.URL.revokeObjectURL(prevState.cardImage.url);
+    }
+  }
+
+  /**
+   * Helper to check if an image is already in state.
+   */
+  static isImageInState(state, image) {
+    const {cardImage} = state;
+
+    // Both image and cardImage are present.
+    if (image && cardImage) {
+      return _Card.isLocalImageObject(image) ?
+             cardImage.path === image.path :
+             cardImage.url === image;
+    }
+
+    // This will only handle the remaining three possible outcomes.
+    // (i.e. everything except when both image and cardImage are present)
+    return !image && !cardImage;
+  }
+
+  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();
+  }
+
+  // NOTE: Remove this function when we update React to >= 16.3 since React will
+  //       call getDerivedStateFromProps automatically. We will also need to
+  //       rename getNextStateFromProps to getDerivedStateFromProps.
+  componentWillMount() {
+    const nextState = _Card.getNextStateFromProps(this.props, this.state);
+    if (nextState) {
+      this.setState(nextState);
+    }
+  }
+
+  // NOTE: Remove this function when we update React to >= 16.3 since React will
+  //       call getDerivedStateFromProps automatically. We will also need to
+  //       rename getNextStateFromProps to getDerivedStateFromProps.
+  componentWillReceiveProps(nextProps) {
+    const nextState = _Card.getNextStateFromProps(nextProps, this.state);
+    if (nextState) {
+      this.setState(nextState);
+    }
+  }
+
+  componentWillUnmount() {
+    _Card.maybeRevokeImageBlob(this.state);
+  }
+
+  render() {
+    const {index, className, 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 = this.state.cardImage || link.hasImage;
+    const imageStyle = {backgroundImage: this.state.cardImage ? `url(${this.state.cardImage.url})` : "none"};
+    const outerClassName = [
+      "card-outer",
+      className,
+      isContextMenuOpen && "active",
+      props.placeholder && "placeholder"
+    ].filter(v => v).join(" ");
+
+    return (<li className={outerClassName}>
+      <a href={link.type === "pocket" ? link.open_url : link.url} onClick={!props.placeholder ? this.onLinkClick : undefined}>
+        <div className="card">
+          <div className="card-preview-image-outer">
+            {hasImage &&
+              <div className={`card-preview-image${this.state.imageLoaded ? " loaded" : ""}`} style={imageStyle} />
+            }
+          </div>
+          <div className="card-details">
+            {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.slice(0, 100)}{link.type === "download" && `  \u2014 ${link.description}`}
+              </div>
+            }
+            <div className={[
+              "card-text",
+              icon ? "" : "no-context",
+              link.description ? "" : "no-description",
+              link.hostname ? "" : "no-host-name"
+            ].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 = props => <Card placeholder={true} className={props.className} />;
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/Card/_Card.scss
@@ -0,0 +1,315 @@
+.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-preview-image-outer,
+    .card-context {
+      display: none;
+    }
+  }
+
+  .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 {
+    background-color: $grey-30;
+    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;
+  }
+
+  .card-text {
+    max-height: 4 * $card-text-line-height + $card-title-margin;
+    overflow: hidden;
+
+    &.no-host-name,
+    &.no-context {
+      max-height: 5 * $card-text-line-height + $card-title-margin;
+    }
+
+    &.no-host-name.no-context {
+      max-height: 6 * $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;
+    white-space: nowrap;
+  }
+
+  .card-host-name.alternate { display: none; }
+
+  .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-secondary-color);
+    display: flex;
+    font-size: 11px;
+    offset-inline-start: 0;
+    padding: 9px 16px 9px 14px;
+    position: absolute;
+  }
+
+  .card-context-icon {
+    fill: var(--newtab-text-secondary-color);
+    height: 22px;
+    margin-inline-end: 6px;
+  }
+
+  .card-context-label {
+    flex-grow: 1;
+    line-height: 22px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+}
+
+.normal-cards {
+  .card-outer {
+    // Wide layout styles
+    @media (min-width: $break-point-widest) {
+      $line-height: 23px;
+      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-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;
+      }
+    }
+  }
+}
+
+.compact-cards {
+  $card-detail-vertical-spacing: 12px;
+  $card-title-font-size: 12px;
+
+  .card-outer {
+    height: $card-height-compact;
+
+    .card-preview-image-outer {
+      height: $card-preview-image-height-compact;
+    }
+
+    .card-details {
+      padding: $card-detail-vertical-spacing 16px;
+    }
+
+    .card-host-name {
+      line-height: 10px;
+    }
+
+    .card-text {
+      .card-title,
+      &:not(.no-description) .card-title {
+        font-size: $card-title-font-size;
+        line-height: $card-title-font-size + 1;
+        max-height: $card-title-font-size + 1;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+    }
+
+    .card-description {
+      display: none;
+    }
+
+    .card-context {
+      $icon-size: 16px;
+      $container-size: 32px;
+      background-color: var(--newtab-card-background-color);
+      border-radius: $container-size / 2;
+      clip-path: inset(-1px -1px $container-size - ($card-height-compact - $card-preview-image-height-compact - 2 * $card-detail-vertical-spacing));
+      height: $container-size;
+      width: $container-size;
+      padding: ($container-size - $icon-size) / 2;
+      top: $card-preview-image-height-compact - $icon-size;
+      offset-inline-end: 12px;
+      offset-inline-start: auto;
+
+      &::after {
+        border: 1px solid var(--newtab-card-hairline-color);
+        border-bottom: 0;
+        border-radius: ($container-size / 2) + 1 ($container-size / 2) + 1 0 0;
+        content: '';
+        position: absolute;
+        height: ($container-size + 2) / 2;
+        width: $container-size + 2;
+        top: -1px;
+        left: -1px;
+      }
+
+      .card-context-icon {
+        margin-inline-end: 0;
+        height: $icon-size;
+        width: $icon-size;
+
+        &.icon-bookmark-added {
+          fill: $bookmark-icon-fill;
+        }
+
+        &.icon-download {
+          fill: $download-icon-fill;
+        }
+
+        &.icon-history-item {
+          fill: $history-icon-fill;
+        }
+
+        &.icon-pocket {
+          fill: $pocket-icon-fill;
+        }
+      }
+
+      .card-context-label {
+        display: none;
+      }
+    }
+  }
+
+  @media not all and (min-width: $break-point-widest) {
+    .hide-for-narrow {
+      display: none;
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/Card/types.js
@@ -0,0 +1,26 @@
+export const cardContextTypes = {
+  history: {
+    intlID: "type_label_visited",
+    icon: "history-item"
+  },
+  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/content-src/components/CollapsibleSection/CollapsibleSection.jsx
@@ -0,0 +1,218 @@
+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, isWebExtension} = 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" : ""}`}
+        // Note: data-section-id is used for web extension api tests in mozilla central
+        data-section-id={id}>
+        <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}
+                isWebExtension={isWebExtension} />
+            }
+          </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/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/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/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/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/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/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/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/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/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/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/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/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/content-src/components/Search/_Search.scss
@@ -0,0 +1,153 @@
+.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
+  .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 {
+      // Make the border slightly shorter by offsetting from the top and bottom
+      $border-offset: 18%;
+
+      background-image: none;
+      border-image: linear-gradient(transparent $border-offset, var(--newtab-border-secondary-color) $border-offset, var(--newtab-border-secondary-color) 100% - $border-offset, transparent 100% - $border-offset) 1;
+      border-inline-end: 1px solid;
+      position: relative;
+
+      &.selected {
+        background: var(--newtab-element-hover-color);
+      }
+
+      &:active {
+        background: var(--newtab-element-active-color);
+      }
+    }
+
+    .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/content-src/components/SectionMenu/SectionMenu.jsx
@@ -0,0 +1,56 @@
+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"];
+const WEBEXT_SECTION_MENU_OPTIONS = ["MoveUp", "MoveDown", "Separator", "CheckCollapsed", "Separator", "ManageWebExtension"];
+
+export class _SectionMenu extends React.PureComponent {
+  getOptions() {
+    const {props} = this;
+
+    const propOptions = props.isWebExtension ? [...WEBEXT_SECTION_MENU_OPTIONS] : [...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/content-src/components/Sections/Sections.jsx
@@ -0,0 +1,258 @@
+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_DEFAULT = 3;
+const CARDS_PER_ROW_COMPACT_WIDE = 4;
+
+function getFormattedMessage(message) {
+  return typeof message === "string" ? <span>{message}</span> : <FormattedMessage {...message} />;
+}
+
+export class Section extends React.PureComponent {
+  get numRows() {
+    const {rowsPref, maxRows, Prefs} = this.props;
+    return rowsPref ? Prefs.values[rowsPref] : maxRows;
+  }
+
+  _dispatchImpressionStats() {
+    const {props} = this;
+    let cardsPerRow = CARDS_PER_ROW_DEFAULT;
+    if (props.compactCards && global.matchMedia(`(min-width: 1072px)`).matches) {
+      // If the section has compact cards and the viewport is wide enough, we show
+      // 4 columns instead of 3.
+      // $break-point-widest = 1072px (from _variables.scss)
+      cardsPerRow = CARDS_PER_ROW_COMPACT_WIDE;
+    }
+    const maxCards = cardsPerRow * this.numRows;
+    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;
+  }
+
+  render() {
+    const {
+      id, eventSource, title, icon, rows,
+      emptyState, dispatch, compactCards,
+      contextMenuOptions, initialized, disclaimer,
+      pref, privacyNoticeURL, isFirst, isLast
+    } = this.props;
+
+    const maxCardsPerRow = compactCards ? CARDS_PER_ROW_COMPACT_WIDE : CARDS_PER_ROW_DEFAULT;
+    const {numRows} = this;
+    const maxCards = maxCardsPerRow * numRows;
+    const maxCardsOnNarrow = CARDS_PER_ROW_DEFAULT * numRows;
+
+    // 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);
+
+    // 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;
+
+    const cards = [];
+    if (!shouldShowEmptyState) {
+      for (let i = 0; i < maxCards; i++) {
+        const link = realRows[i];
+        // On narrow viewports, we only show 3 cards per row. We'll mark the rest as
+        // .hide-for-narrow to hide in CSS via @media query.
+        const className = (i >= maxCardsOnNarrow) ? "hide-for-narrow" : "";
+        cards.push(link ? (
+          <Card key={i}
+            index={i}
+            className={className}
+            dispatch={dispatch}
+            link={link}
+            contextMenuOptions={contextMenuOptions}
+            eventSource={eventSource}
+            shouldSendImpressionStats={this.props.shouldSendImpressionStats}
+            isWebExtension={this.props.isWebExtension} />
+        ) : (
+          <PlaceholderCard key={i} className={className} />
+        ));
+      }
+    }
+
+    const sectionClassName = [
+      "section",
+      compactCards ? "compact-cards" : "normal-cards"
+    ].join(" ");
+
+    // <Section> <-- React component
+    // <section> <-- HTML5 element
+    return (<ComponentPerfTimer {...this.props}>
+      <CollapsibleSection className={sectionClassName} 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}
+        isWebExtension={this.props.isWebExtension}>
+
+        {!shouldShowEmptyState && (<ul className="section-list" style={{padding: 0}}>
+          {cards}
+        </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/content-src/components/Sections/_Sections.scss
@@ -0,0 +1,77 @@
+.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;
+      }
+    }
+
+    @media (min-width: $break-point-widest) and (max-width: $break-point-widest + 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;
+      }
+    }
+
+    @media (min-width: $break-point-widest) {
+      height: $card-height-large;
+    }
+  }
+}
+
+@media (min-width: $break-point-widest) {
+  .sections-list {
+    // Compact cards stay the same size but normal cards get bigger.
+    .normal-cards {
+      .section-list {
+        grid-template-columns: repeat(auto-fit, $card-width-large);
+      }
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/StartupOverlay/StartupOverlay.jsx
@@ -0,0 +1,89 @@
+import {FormattedMessage, injectIntl} from "react-intl";
+import {actionCreators as ac} from "common/Actions.jsm";
+import {connect} from "react-redux";
+import React from "react";
+
+export class _StartupOverlay extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onInputChange = this.onInputChange.bind(this);
+    this.onSubmit = this.onSubmit.bind(this);
+    this.clickSkip = this.clickSkip.bind(this);
+    this.initScene = this.initScene.bind(this);
+    this.removeOverlay = this.removeOverlay.bind(this);
+
+    this.state = {emailInput: ""};
+    this.initScene();
+  }
+
+  initScene() {
+    // Timeout to allow the scene to render once before attaching the attribute
+    // to trigger the animation.
+    setTimeout(() => {
+      this.setState({show: true});
+    }, 10);
+  }
+
+  removeOverlay() {
+    window.removeEventListener("visibilitychange", this.removeOverlay);
+    this.setState({show: false});
+    setTimeout(() => {
+      // Allow scrolling and fully remove overlay after animation finishes.
+      document.body.classList.remove("welcome");
+    }, 400);
+  }
+
+  onInputChange(e) {
+    this.setState({emailInput: e.target.value});
+  }
+
+  onSubmit() {
+    this.props.dispatch(ac.UserEvent({event: "SUBMIT_EMAIL"}));
+    window.addEventListener("visibilitychange", this.removeOverlay);
+  }
+
+  clickSkip() {
+    this.props.dispatch(ac.UserEvent({event: "SKIPPED_SIGNIN"}));
+    this.removeOverlay();
+  }
+
+  render() {
+    let termsLink = (<a href="https://accounts.firefox.com/legal/terms" target="_blank" rel="noopener noreferrer"><FormattedMessage id="firstrun_terms_of_service" /></a>);
+    let privacyLink = (<a href="https://accounts.firefox.com/legal/privacy" target="_blank" rel="noopener noreferrer"><FormattedMessage id="firstrun_privacy_notice" /></a>);
+    return (
+      <div className={`overlay-wrapper ${this.state.show ? "show " : ""}`}>
+        <div className="background" />
+        <div className="firstrun-scene">
+          <div className="fxaccounts-container">
+            <div className="firstrun-left-divider">
+              <h1 className="firstrun-title"><FormattedMessage id="firstrun_title" /></h1>
+              <p className="firstrun-content"><FormattedMessage id="firstrun_content" /></p>
+              <a className="firstrun-link" href="https://www.mozilla.org/firefox/features/sync/" target="_blank" rel="noopener noreferrer"><FormattedMessage id="firstrun_learn_more_link" /></a>
+            </div>
+            <div className="firstrun-sign-in">
+              <p className="form-header"><FormattedMessage id="firstrun_form_header" /><span><FormattedMessage id="firstrun_form_sub_header" /></span></p>
+              <form method="get" action="https://accounts.firefox.com?entrypoint=activity-stream-firstrun&utm_source=activity-stream&utm_campaign=firstrun" target="_blank" rel="noopener noreferrer" onSubmit={this.onSubmit}>
+                <input name="service" type="hidden" value="sync" />
+                <input name="action" type="hidden" value="email" />
+                <input name="context" type="hidden" value="fx_desktop_v3" />
+                <input className="email-input" name="email" type="email" required="true" placeholder={this.props.intl.formatMessage({id: "firstrun_email_input_placeholder"})} onChange={this.onInputChange} />
+                <div className="extra-links">
+                  <FormattedMessage
+                    id="firstrun_extra_legal_links"
+                    values={{
+                      terms: termsLink,
+                      privacy: privacyLink
+                    }} />
+                </div>
+                <button className="continue-button" type="submit"><FormattedMessage id="firstrun_continue_to_login" /></button>
+              </form>
+              <button className="skip-button" disabled={!!this.state.emailInput} onClick={this.clickSkip}><FormattedMessage id="firstrun_skip_login" /></button>
+            </div>
+          </div>
+        </div>
+      </div>
+    );
+  }
+}
+
+export const StartupOverlay = connect()(injectIntl(_StartupOverlay));
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/StartupOverlay/_StartupOverlay.scss
@@ -0,0 +1,247 @@
+.activity-stream {
+  &.welcome {
+    overflow: hidden;
+  }
+
+  &:not(.welcome) {
+    .overlay-wrapper {
+      display: none;
+    }
+  }
+}
+
+.overlay-wrapper {
+  position: fixed;
+  top: 0;
+  width: 100vw;
+  height: 100vh;
+  z-index: 21000;
+  font-weight: 200;
+  transition: opacity 0.4s;
+  opacity: 0;
+
+  &.show {
+    transition: none;
+    opacity: 1;
+
+    .firstrun-sign-in {
+      transition: opacity 1.5s, transform 1.5s;
+      transition-delay: 0.2s;
+      transform: translateY(-50%) scale(1);
+      opacity: 1;
+    }
+
+    .firstrun-firefox-logo {
+      transition: opacity 2.3s;
+      opacity: 1;
+    }
+
+    .firstrun-title,
+    .firstrun-content,
+    .firstrun-link {
+      transition: transform 0.5s, opacity 0.8s;
+      transform: translateY(0);
+      opacity: 1;
+    }
+
+    .firstrun-title {
+      transition-delay: 0.2s;
+    }
+
+    .firstrun-content {
+      transition-delay: 0.4s;
+    }
+
+    .firstrun-link {
+      transition-delay: 0.6s;
+    }
+
+    .fxaccounts-container {
+      transition: none;
+      opacity: 1;
+    }
+  }
+}
+
+.background {
+  width: 100%;
+  height: 100%;
+  display: block;
+  background: url('#{$image-path}fox-tail.png') top -200px center no-repeat,
+  linear-gradient(to bottom, $blue-70 40%, #004EC2 60%, $blue-60 80%, #0080FF 90%, #00C7FF 100%) top center no-repeat,
+  $blue-70;
+  background-size: cover;
+}
+
+.firstrun-sign-in {
+  transform: translateY(-50%) scale(0.8);
+  position: relative;
+  top: 50%;
+  width: 358px;
+  opacity: 0;
+  background-color: $white;
+  float: inline-end;
+  color: $grey-90;
+  text-align: center;
+  padding: 10px;
+
+  .extra-links {
+    font-size: 12px;
+    max-width: 340px;
+    margin: 14px 50px;
+    color: #676F7E;
+    cursor: default;
+
+    a {
+      color: $grey-50;
+      cursor: pointer;
+      text-decoration: underline;
+    }
+
+    a:hover,
+    a:active,
+    a:focus {
+      color: $blue-50;
+    }
+  }
+
+  .email-input {
+    box-shadow: none;
+    margin: auto;
+    width: 244px;
+    display: block;
+    height: 40px;
+    padding-inline-start: 20px;
+    border: 1px solid $grey-50;
+    border-radius: 2px;
+    font-size: 16px;
+
+    &:hover {
+      border-color: $grey-90;
+    }
+  }
+
+  .form-header {
+    font-size: 18px;
+    margin: 15px auto;
+  }
+
+  .form-header span {
+    font-size: 14px;
+    margin-top: 4px;
+    display: block;
+  }
+
+  button {
+    border-radius: 2px;
+    display: block;
+    cursor: pointer;
+    margin: 10px auto 0;
+  }
+
+  .continue-button {
+    font-size: 18px;
+    height: 43px;
+    width: 250px;
+    padding: 8px 0;
+    border: 1px solid $blue-60;
+    color: $white;
+    background-color: $blue-50;
+    transition-duration: 150ms;
+    transition-property: background-color;
+
+    &:not([disabled]):active {
+      background: $blue-70;
+      border-color: $blue-80;
+    }
+  }
+
+  .skip-button {
+    font-size: 13px;
+    margin-top: 40px;
+    margin-bottom: 20px;
+    background-color: #FCFCFC;
+    color: $blue-50;
+    border: 1px solid $blue-50;
+    min-height: 24px;
+    padding: 5px 10px;
+    transition: background-color 150ms, color 150ms, border-color 150ms;
+
+    &[disabled] {
+      background-color: #EBEBEB;
+      border-color: #B1B1B1;
+      color: #6A6A6A;
+      cursor: default;
+      opacity: 0.5;
+    }
+
+    &:not([disabled]):hover {
+      background-color: $blue-50;
+      border-color: $blue-60;
+      color: $white;
+    }
+  }
+}
+
+.firstrun-left-divider {
+  position: relative;
+  float: inline-start;
+  clear: both;
+  width: 435px;
+}
+
+.firstrun-content {
+  line-height: 1.5;
+  margin-bottom: 48px;
+  max-width: 352px;
+  background: url('#{$image-path}sync-devices.svg') bottom center no-repeat;
+  padding-bottom: 210px;
+}
+
+.firstrun-link {
+  color: $white;
+  display: block;
+  text-decoration: underline;
+
+  &:hover,
+  &:active,
+  &:focus {
+    color: $white;
+  }
+}
+
+.firstrun-title {
+  background: url('chrome://branding/content/about-logo.png') top left no-repeat;
+  background-size: 90px 90px;
+  margin: 40px 0 10px;
+  padding-top: 110px;
+  font-weight: 200;
+}
+
+[dir='rtl'] {
+  .firstrun-title {
+    background-position: top right;
+  }
+}
+
+.fxaccounts-container {
+  position: absolute;
+  bottom: 0;
+  right: 0;
+  top: 0;
+  left: 0;
+  color: $white;
+  height: 515px;
+  margin: auto;
+  width: 819px;
+  z-index: 10;
+  transition: opacity 0.3s;
+  opacity: 0;
+}
+
+.firstrun-title,
+.firstrun-content,
+.firstrun-link {
+  opacity: 0;
+  transform: translateY(-5px);
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/TopSites/TopSite.jsx
@@ -0,0 +1,425 @@
+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(event) {
+    this.userEvent("CLICK");
+
+    // Specially handle a top site link click for "typed" frecency bonus as
+    // specified as a property on the link.
+    event.preventDefault();
+    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}})
+    }));
+  }
+
+  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/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/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/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/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/content-src/components/TopSites/_TopSites.scss
@@ -0,0 +1,485 @@
+$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;
+    }
+  }
+
+  // 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;
+    }
+  }
+
+  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: var(--newtab-topsites-icon-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;
+    }
+  }
+}
+
+.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/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/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 {