Bug 1425538 - Use NotificationBox as regular React element; r=nchevobbe draft
authorJan Odvarko <odvarko@gmail.com>
Wed, 16 May 2018 17:38:06 +0200
changeset 795780 671eda5293be945bd564ad093ea513ec61b826c1
parent 795546 380cf87c1ee3966dd94499942b73085754dc4824
push id110074
push userjodvarko@mozilla.com
push dateWed, 16 May 2018 15:39:01 +0000
reviewersnchevobbe
bugs1425538
milestone62.0a1
Bug 1425538 - Use NotificationBox as regular React element; r=nchevobbe MozReview-Commit-ID: AHvRWfRd1XY
devtools/client/shared/components/NotificationBox.js
devtools/client/webconsole/actions/index.js
devtools/client/webconsole/actions/moz.build
devtools/client/webconsole/actions/notifications.js
devtools/client/webconsole/components/App.js
devtools/client/webconsole/components/JSTerm.js
devtools/client/webconsole/components/moz.build
devtools/client/webconsole/constants.js
devtools/client/webconsole/new-console-output-wrapper.js
devtools/client/webconsole/reducers/index.js
devtools/client/webconsole/reducers/moz.build
devtools/client/webconsole/reducers/notifications.js
devtools/client/webconsole/selectors/moz.build
devtools/client/webconsole/selectors/notifications.js
devtools/client/webconsole/test/mochitest/browser_jsterm_selfxss.js
devtools/client/webconsole/utils.js
--- a/devtools/client/shared/components/NotificationBox.js
+++ b/devtools/client/shared/components/NotificationBox.js
@@ -28,22 +28,32 @@ const PriorityLevels = {
 };
 
 /**
  * This component represents Notification Box - HTML alternative for
  * <xul:notificationbox> binding.
  *
  * See also MDN for more info about <xul:notificationbox>:
  * https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XUL/notificationbox
+ *
+ * This component can maintain its own state (list of notifications)
+ * as well as consume list of notifications provided as a prop
+ * (coming e.g. from Redux store).
  */
 class NotificationBox extends Component {
   static get propTypes() {
     return {
+      // Optional box ID (used for mounted node ID attribute)
+      id: PropTypes.string,
+
       // List of notifications appended into the box.
-      notifications: PropTypes.arrayOf(PropTypes.shape({
+      // Use `PropTypes.arrayOf` validation (see below) as soon as
+      // ImmutableJS is removed. See bug 1461678 for more details.
+      notifications: PropTypes.object,
+      /* notifications: PropTypes.arrayOf(PropTypes.shape({
         // label to appear on the notification.
         label: PropTypes.string.isRequired,
 
         // Value used to identify the notification
         value: PropTypes.string.isRequired,
 
         // URL of image to appear on the notification. If "" then an icon
         // appropriate for the priority level is used.
@@ -69,17 +79,17 @@ class NotificationBox extends Component 
 
           // The accesskey attribute set on the <button> element.
           accesskey: PropTypes.string,
         })),
 
         // A function to call to notify you of interesting things that happen
         // with the notification box.
         eventCallback: PropTypes.func,
-      })),
+      })),*/
 
       // Message that should be shown when hovering over the close button
       closeButtonTooltip: PropTypes.string
     };
   }
 
   static get defaultProps() {
     return {
@@ -104,53 +114,26 @@ class NotificationBox extends Component 
   }
 
   /**
    * Create a new notification and display it. If another notification is
    * already present with a higher priority, the new notification will be
    * added behind it. See `propTypes` for arguments description.
    */
   appendNotification(label, value, image, priority, buttons = [], eventCallback) {
-    // Priority level must be within expected interval
-    // (see priority levels at the top of this file).
-    if (priority < PriorityLevels.PRIORITY_INFO_LOW ||
-      priority > PriorityLevels.PRIORITY_CRITICAL_BLOCK) {
-      throw new Error("Invalid notification priority " + priority);
-    }
-
-    // Custom image URL is not supported yet.
-    if (image) {
-      throw new Error("Custom image URL is not supported yet");
-    }
-
-    let type = "warning";
-    if (priority >= PriorityLevels.PRIORITY_CRITICAL_LOW) {
-      type = "critical";
-    } else if (priority <= PriorityLevels.PRIORITY_INFO_HIGH) {
-      type = "info";
-    }
-
-    let notifications = this.state.notifications.set(value, {
-      label: label,
-      value: value,
-      image: image,
-      priority: priority,
-      type: type,
-      buttons: Array.isArray(buttons) ? buttons : [],
-      eventCallback: eventCallback,
+    const newState = appendNotification(this.state, {
+      label,
+      value,
+      image,
+      priority,
+      buttons,
+      eventCallback,
     });
 
-    // High priorities must be on top.
-    notifications = notifications.sortBy((val, key) => {
-      return -val.priority;
-    });
-
-    this.setState({
-      notifications: notifications
-    });
+    this.setState(newState);
   }
 
   /**
    * Remove specific notification from the list.
    */
   removeNotification(notification) {
     if (notification) {
       this.close(this.state.notifications.get(notification.value));
@@ -187,16 +170,20 @@ class NotificationBox extends Component 
     if (!notification) {
       return;
     }
 
     if (notification.eventCallback) {
       notification.eventCallback("removed");
     }
 
+    if (!this.state.notifications.get(notification.value)) {
+      return;
+    }
+
     this.setState({
       notifications: this.state.notifications.remove(notification.value)
     });
   }
 
   /**
    * Render a button. A notification can have a set of custom buttons.
    * These are used to execute custom callback.
@@ -255,21 +242,97 @@ class NotificationBox extends Component 
     );
   }
 
   /**
    * Render the top (highest priority) notification. Only one
    * notification is rendered at a time.
    */
   render() {
-    let notification = this.state.notifications.first();
-    let content = notification ?
+    const notifications = this.props.notifications || this.state.notifications;
+    const notification = notifications ? notifications.first() : null;
+    const content = notification ?
       this.renderNotification(notification) :
       null;
 
-    return div({className: "notificationbox"},
+    return div({
+      className: "notificationbox",
+      id: this.props.id},
       content
     );
   }
 }
 
+// Helpers
+
+/**
+ * Create a new notification. If another notification is already present with
+ * a higher priority, the new notification will be added behind it.
+ * See `propTypes` for arguments description.
+ */
+function appendNotification(state, props) {
+  const {
+    label,
+    value,
+    image,
+    priority,
+    buttons,
+    eventCallback
+  } = props;
+
+  // Priority level must be within expected interval
+  // (see priority levels at the top of this file).
+  if (priority < PriorityLevels.PRIORITY_INFO_LOW ||
+    priority > PriorityLevels.PRIORITY_CRITICAL_BLOCK) {
+    throw new Error("Invalid notification priority " + priority);
+  }
+
+  // Custom image URL is not supported yet.
+  if (image) {
+    throw new Error("Custom image URL is not supported yet");
+  }
+
+  let type = "warning";
+  if (priority >= PriorityLevels.PRIORITY_CRITICAL_LOW) {
+    type = "critical";
+  } else if (priority <= PriorityLevels.PRIORITY_INFO_HIGH) {
+    type = "info";
+  }
+
+  if (!state.notifications) {
+    state.notifications = new Immutable.OrderedMap();
+  }
+
+  let notifications = state.notifications.set(value, {
+    label: label,
+    value: value,
+    image: image,
+    priority: priority,
+    type: type,
+    buttons: Array.isArray(buttons) ? buttons : [],
+    eventCallback: eventCallback,
+  });
+
+  // High priorities must be on top.
+  notifications = notifications.sortBy((val, key) => {
+    return -val.priority;
+  });
+
+  return {
+    notifications: notifications
+  };
+}
+
+function getNotificationWithValue(notifications, value) {
+  return notifications ? notifications.get(value) : null;
+}
+
+function removeNotificationWithValue(notifications, value) {
+  return {
+    notifications: notifications.remove(value)
+  };
+}
+
 module.exports.NotificationBox = NotificationBox;
 module.exports.PriorityLevels = PriorityLevels;
+module.exports.appendNotification = appendNotification;
+module.exports.getNotificationWithValue = getNotificationWithValue;
+module.exports.removeNotificationWithValue = removeNotificationWithValue;
--- a/devtools/client/webconsole/actions/index.js
+++ b/devtools/client/webconsole/actions/index.js
@@ -5,13 +5,14 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 const actionModules = [
   require("./filters"),
   require("./messages"),
   require("./ui"),
+  require("./notifications"),
 ];
 
 const actions = Object.assign({}, ...actionModules);
 
 module.exports = actions;
--- a/devtools/client/webconsole/actions/moz.build
+++ b/devtools/client/webconsole/actions/moz.build
@@ -2,10 +2,11 @@
 # 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/.
 
 DevToolsModules(
     'filters.js',
     'index.js',
     'messages.js',
+    'notifications.js',
     'ui.js',
 )
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/actions/notifications.js
@@ -0,0 +1,43 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+  APPEND_NOTIFICATION,
+  REMOVE_NOTIFICATION,
+} = require("devtools/client/webconsole/constants");
+
+/**
+ * Append a notification into JSTerm notification list.
+ */
+function appendNotification(label, value, image, priority, buttons = [], eventCallback) {
+  return {
+    type: APPEND_NOTIFICATION,
+    label,
+    value,
+    image,
+    priority,
+    buttons,
+    eventCallback,
+  };
+}
+
+/**
+ * Remove notification with specified value from JSTerm
+ * notification list.
+ */
+function removeNotification(value) {
+  return {
+    type: REMOVE_NOTIFICATION,
+    value,
+  };
+}
+
+module.exports = {
+  appendNotification,
+  removeNotification,
+};
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/components/App.js
@@ -0,0 +1,172 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+const { Component, createFactory } = require("devtools/client/shared/vendor/react");
+const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
+const dom = require("devtools/client/shared/vendor/react-dom-factories");
+const { connect } = require("devtools/client/shared/redux/visibility-handler-connect");
+
+const actions = require("devtools/client/webconsole/actions/index");
+const ConsoleOutput = createFactory(require("devtools/client/webconsole/components/ConsoleOutput"));
+const FilterBar = createFactory(require("devtools/client/webconsole/components/FilterBar"));
+const SideBar = createFactory(require("devtools/client/webconsole/components/SideBar"));
+const JSTerm = createFactory(require("devtools/client/webconsole/components/JSTerm"));
+const NotificationBox = createFactory(require("devtools/client/shared/components/NotificationBox").NotificationBox);
+
+const l10n = require("devtools/client/webconsole/webconsole-l10n");
+const { Utils: WebConsoleUtils } = require("devtools/client/webconsole/utils");
+
+const SELF_XSS_OK = l10n.getStr("selfxss.okstring");
+const SELF_XSS_MSG = l10n.getFormatStr("selfxss.msg", [SELF_XSS_OK]);
+
+const {
+  getNotificationWithValue,
+  PriorityLevels,
+} = require("devtools/client/shared/components/NotificationBox");
+
+const { getAllNotifications } = require("devtools/client/webconsole/selectors/notifications");
+
+const { div } = dom;
+
+/**
+ * Console root Application component.
+ */
+class App extends Component {
+  static get propTypes() {
+    return {
+      attachRefToHud: PropTypes.func.isRequired,
+      dispatch: PropTypes.func.isRequired,
+      hud: PropTypes.object.isRequired,
+      notifications: PropTypes.object,
+      onFirstMeaningfulPaint: PropTypes.func.isRequired,
+      serviceContainer: PropTypes.object.isRequired,
+    };
+  }
+
+  constructor(props) {
+    super(props);
+
+    this.onPaste = this.onPaste.bind(this);
+  }
+
+  onPaste(event) {
+    const {
+      dispatch,
+      hud,
+      notifications,
+    } = this.props;
+
+    const {
+      usageCount,
+      CONSOLE_ENTRY_THRESHOLD
+    } = WebConsoleUtils;
+
+    // Bail out if self-xss notification is suppressed.
+    if (hud.isBrowserConsole || usageCount >= CONSOLE_ENTRY_THRESHOLD) {
+      return;
+    }
+
+    // Stop event propagation, so the clipboard content is *not* inserted.
+    event.preventDefault();
+    event.stopPropagation();
+
+    // Bail out if self-xss notification is already there.
+    if (getNotificationWithValue(notifications, "selfxss-notification")) {
+      return;
+    }
+
+    const inputField = this.node.querySelector(".jsterm-input-node");
+
+    // Cleanup function if notification is closed by the user.
+    const removeCallback = (eventType) => {
+      if (eventType == "removed") {
+        inputField.removeEventListener("keyup", pasteKeyUpHandler);
+        dispatch(actions.removeNotification("selfxss-notification"));
+      }
+    };
+
+    // Create self-xss notification
+    dispatch(actions.appendNotification(
+      SELF_XSS_MSG,
+      "selfxss-notification",
+      null,
+      PriorityLevels.PRIORITY_WARNING_HIGH,
+      null,
+      removeCallback
+    ));
+
+    // Remove notification automatically when the user
+    // types "allow pasting".
+    function pasteKeyUpHandler() {
+      let value = inputField.value || inputField.textContent;
+      if (value.includes(SELF_XSS_OK)) {
+        dispatch(actions.removeNotification("selfxss-notification"));
+        inputField.removeEventListener("keyup", pasteKeyUpHandler);
+        WebConsoleUtils.usageCount = WebConsoleUtils.CONSOLE_ENTRY_THRESHOLD;
+      }
+    }
+
+    inputField.addEventListener("keyup", pasteKeyUpHandler);
+  }
+
+  // Rendering
+
+  render() {
+    const {
+      attachRefToHud,
+      hud,
+      notifications,
+      onFirstMeaningfulPaint,
+      serviceContainer,
+    } = this.props;
+
+    // Render the entire Console panel. The panel consists
+    // from the following parts:
+    // * FilterBar - Buttons & free text for content filtering
+    // * Content - List of logs & messages
+    // * SideBar - Object inspector
+    // * NotificationBox - Notifications for JSTerm (self-xss warning at the moment)
+    // * JSTerm - Input command line.
+    return (
+      div({
+        className: "webconsole-output-wrapper",
+        ref: node => {
+          this.node = node;
+        }},
+        FilterBar({
+          hidePersistLogsCheckbox: hud.isBrowserConsole,
+          serviceContainer: {
+            attachRefToHud
+          }
+        }),
+        ConsoleOutput({
+          serviceContainer,
+          onFirstMeaningfulPaint,
+        }),
+        SideBar({
+          serviceContainer,
+        }),
+        NotificationBox({
+          id: "webconsole-notificationbox",
+          notifications,
+        }),
+        JSTerm({
+          hud,
+          onPaste: this.onPaste,
+        }),
+      )
+    );
+  }
+}
+
+const mapStateToProps = state => ({
+  notifications: getAllNotifications(state),
+});
+
+const mapDispatchToProps = dispatch => ({
+  dispatch,
+});
+
+module.exports = connect(mapStateToProps, mapDispatchToProps)(App);
--- a/devtools/client/webconsole/components/JSTerm.js
+++ b/devtools/client/webconsole/components/JSTerm.js
@@ -13,18 +13,16 @@ const PropTypes = require("devtools/clie
 
 loader.lazyServiceGetter(this, "clipboardHelper",
                          "@mozilla.org/widget/clipboardhelper;1",
                          "nsIClipboardHelper");
 loader.lazyRequireGetter(this, "EventEmitter", "devtools/shared/event-emitter");
 loader.lazyRequireGetter(this, "AutocompletePopup", "devtools/client/shared/autocomplete-popup");
 loader.lazyRequireGetter(this, "asyncStorage", "devtools/shared/async-storage");
 loader.lazyRequireGetter(this, "gDevTools", "devtools/client/framework/devtools", true);
-loader.lazyRequireGetter(this, "NotificationBox", "devtools/client/shared/components/NotificationBox", true);
-loader.lazyRequireGetter(this, "PriorityLevels", "devtools/client/shared/components/NotificationBox", true);
 
 const l10n = require("devtools/client/webconsole/webconsole-l10n");
 
 // Constants used for defining the direction of JSTerm input history navigation.
 const HISTORY_BACK = -1;
 const HISTORY_FORWARD = 1;
 
 const HELP_URL = "https://developer.mozilla.org/docs/Tools/Web_Console/Helpers";
@@ -48,25 +46,28 @@ const dom = require("devtools/client/sha
  * @constructor
  * @param object webConsoleFrame
  *        The WebConsoleFrame object that owns this JSTerm instance.
  */
 class JSTerm extends Component {
   static get propTypes() {
     return {
       hud: PropTypes.object.isRequired,
+      // Handler for clipboard 'paste' event (also used for 'drop' event).
+      onPaste: PropTypes.func,
     };
   }
 
   constructor(props) {
     super(props);
 
     const {
       hud,
     } = props;
+
     this.hud = hud;
     this.hudId = this.hud.hudId;
     this.inputHistoryCount = Services.prefs.getIntPref(PREF_INPUT_HISTORY_COUNT);
     this._loadHistory();
 
     /**
      * Stores the data for the last completion.
      * @type object
@@ -171,26 +172,16 @@ class JSTerm extends Component {
     // Update the character width and height needed for the popup offset
     // calculations.
     this._updateCharSize();
 
     this.inputNode.addEventListener("keypress", this._keyPress);
     this.inputNode.addEventListener("input", this._inputEventHandler);
     this.inputNode.addEventListener("keyup", this._inputEventHandler);
     this.inputNode.addEventListener("focus", this._focusEventHandler);
-
-    if (!this.hud.isBrowserConsole) {
-      let okstring = l10n.getStr("selfxss.okstring");
-      let msg = l10n.getFormatStr("selfxss.msg", [okstring]);
-      this._onPaste = WebConsoleUtils.pasteHandlerGen(this.inputNode,
-          this.getNotificationBox(), msg, okstring);
-      this.inputNode.addEventListener("paste", this._onPaste);
-      this.inputNode.addEventListener("drop", this._onPaste);
-    }
-
     this.hud.window.addEventListener("blur", this._blurEventHandler);
     this.lastInputValue && this.setInputValue(this.lastInputValue);
 
     this.focus();
   }
 
   shouldComponentUpdate() {
     // XXX: For now, everything is handled in an imperative way and we only want React
@@ -1274,79 +1265,58 @@ class JSTerm extends Component {
     style.width = "auto";
     style.color = "transparent";
     WebConsoleUtils.copyTextStyles(this.inputNode, tempLabel);
     tempLabel.textContent = "x";
     doc.documentElement.appendChild(tempLabel);
     this._inputCharWidth = tempLabel.offsetWidth;
     tempLabel.remove();
     // Calculate the width of the chevron placed at the beginning of the input
-    // box. Remove 4 more pixels to accomodate the padding of the popup.
+    // box. Remove 4 more pixels to accommodate the padding of the popup.
     this._chevronWidth = +doc.defaultView.getComputedStyle(this.inputNode)
                              .paddingLeft.replace(/[^0-9.]/g, "") - 4;
   }
 
-  /**
-   * Build the notification box as soon as needed.
-   */
-  getNotificationBox() {
-    if (this._notificationBox) {
-      return this._notificationBox;
-    }
-
-    let box = this.hud.document.getElementById("webconsole-notificationbox");
-    let toolbox = gDevTools.getToolbox(this.hud.owner.target);
-
-    // Render NotificationBox and assign priority levels to it.
-    this._notificationBox = Object.assign(
-      toolbox.ReactDOM.render(toolbox.React.createElement(NotificationBox), box),
-      PriorityLevels);
-    return this._notificationBox;
-  }
-
   destroy() {
     this.clearCompletion();
 
     this.webConsoleClient.clearNetworkRequests();
     if (this.hud.outputNode) {
       // We do this because it's much faster than letting React handle the ConsoleOutput
       // unmounting.
       this.hud.outputNode.innerHTML = "";
     }
 
     if (this.autocompletePopup) {
       this.autocompletePopup.destroy();
       this.autocompletePopup = null;
     }
 
     if (this.inputNode) {
-      if (this._onPaste) {
-        this.inputNode.removeEventListener("paste", this._onPaste);
-        this.inputNode.removeEventListener("drop", this._onPaste);
-        this._onPaste = null;
-      }
-
       this.inputNode.removeEventListener("keypress", this._keyPress);
       this.inputNode.removeEventListener("input", this._inputEventHandler);
       this.inputNode.removeEventListener("keyup", this._inputEventHandler);
       this.inputNode.removeEventListener("focus", this._focusEventHandler);
       this.hud.window.removeEventListener("blur", this._blurEventHandler);
     }
 
     this.hud = null;
   }
 
   render() {
     if (this.props.hud.isBrowserConsole &&
         !Services.prefs.getBoolPref("devtools.chrome.enabled")) {
       return null;
     }
 
-    return [
-      dom.div({id: "webconsole-notificationbox", key: "notification"}),
+    let {
+      onPaste
+    } = this.props;
+
+    return (
       dom.div({
         className: "jsterm-input-container",
         key: "jsterm-container",
         style: {direction: "ltr"},
         "aria-live": "off",
       },
         dom.textarea({
           className: "jsterm-complete-node devtools-monospace",
@@ -1360,15 +1330,17 @@ class JSTerm extends Component {
           className: "jsterm-input-node devtools-monospace",
           key: "input",
           tabIndex: "0",
           rows: "1",
           "aria-autocomplete": "list",
           ref: node => {
             this.inputNode = node;
           },
+          onPaste: onPaste,
+          onDrop: onPaste,
         })
-      ),
-    ];
+      )
+    );
   }
 }
 
 module.exports = JSTerm;
--- a/devtools/client/webconsole/components/moz.build
+++ b/devtools/client/webconsole/components/moz.build
@@ -3,16 +3,17 @@
 # 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/.
 
 DIRS += [
     'message-types'
 ]
 
 DevToolsModules(
+    'App.js',
     'CollapseButton.js',
     'ConsoleOutput.js',
     'ConsoleTable.js',
     'FilterBar.js',
     'FilterButton.js',
     'FilterCheckbox.js',
     'GripMessageBody.js',
     'JSTerm.js',
--- a/devtools/client/webconsole/constants.js
+++ b/devtools/client/webconsole/constants.js
@@ -22,16 +22,18 @@ const actionTypes = {
   NETWORK_UPDATE_REQUEST: "NETWORK_UPDATE_REQUEST",
   PERSIST_TOGGLE: "PERSIST_TOGGLE",
   PRIVATE_MESSAGES_CLEAR: "PRIVATE_MESSAGES_CLEAR",
   REMOVED_ACTORS_CLEAR: "REMOVED_ACTORS_CLEAR",
   SELECT_NETWORK_MESSAGE_TAB: "SELECT_NETWORK_MESSAGE_TAB",
   SIDEBAR_CLOSE: "SIDEBAR_CLOSE",
   SHOW_OBJECT_IN_SIDEBAR: "SHOW_OBJECT_IN_SIDEBAR",
   TIMESTAMPS_TOGGLE: "TIMESTAMPS_TOGGLE",
+  APPEND_NOTIFICATION: "APPEND_NOTIFICATION",
+  REMOVE_NOTIFICATION: "REMOVE_NOTIFICATION",
 };
 
 const prefs = {
   PREFS: {
     // Filter preferences only have the suffix since they can be used either for the
     // webconsole or the browser console.
     FILTER: {
       ERROR: "filter.error",
--- a/devtools/client/webconsole/new-console-output-wrapper.js
+++ b/devtools/client/webconsole/new-console-output-wrapper.js
@@ -1,30 +1,26 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 "use strict";
 
 const { createElement, createFactory } = require("devtools/client/shared/vendor/react");
-const dom = require("devtools/client/shared/vendor/react-dom-factories");
 const ReactDOM = require("devtools/client/shared/vendor/react-dom");
 const { Provider } = require("devtools/client/shared/vendor/react-redux");
 
 const actions = require("devtools/client/webconsole/actions/index");
 const { createContextMenu } = require("devtools/client/webconsole/utils/context-menu");
 const { configureStore } = require("devtools/client/webconsole/store");
 const { isPacketPrivate } = require("devtools/client/webconsole/utils/messages");
 const { getAllMessagesById, getMessage } = require("devtools/client/webconsole/selectors/messages");
 const Telemetry = require("devtools/client/shared/telemetry");
 
 const EventEmitter = require("devtools/shared/event-emitter");
-const ConsoleOutput = createFactory(require("devtools/client/webconsole/components/ConsoleOutput"));
-const FilterBar = createFactory(require("devtools/client/webconsole/components/FilterBar"));
-const SideBar = createFactory(require("devtools/client/webconsole/components/SideBar"));
-const JSTerm = createFactory(require("devtools/client/webconsole/components/JSTerm"));
+const App = createFactory(require("devtools/client/webconsole/components/App"));
 
 let store = null;
 
 function NewConsoleOutputWrapper(parentNode, hud, toolbox, owner, document) {
   EventEmitter.decorate(this);
 
   this.parentNode = parentNode;
   this.hud = hud;
@@ -38,16 +34,17 @@ function NewConsoleOutputWrapper(parentN
   this.queuedMessageUpdates = [];
   this.queuedRequestUpdates = [];
   this.throttledDispatchPromise = null;
 
   this._telemetry = new Telemetry();
 
   store = configureStore(this.hud);
 }
+
 NewConsoleOutputWrapper.prototype = {
   init: function() {
     return new Promise((resolve) => {
       const attachRefToHud = (id, node) => {
         this.hud[id] = node;
       };
       // Focus the input line whenever the output area is clicked.
       this.parentNode.addEventListener("click", (event) => {
@@ -206,38 +203,25 @@ NewConsoleOutputWrapper.prototype = {
             let onNodeFrontSet = this.toolbox.selection
               .setNodeFront(front, { reason: "console" });
 
             return Promise.all([onNodeFrontSet, onInspectorUpdated]);
           }
         });
       }
 
-      let provider = createElement(
-        Provider,
-        { store },
-        dom.div(
-          {className: "webconsole-output-wrapper"},
-          FilterBar({
-            hidePersistLogsCheckbox: this.hud.isBrowserConsole,
-            serviceContainer: {
-              attachRefToHud
-            }
-          }),
-          ConsoleOutput({
-            serviceContainer,
-            onFirstMeaningfulPaint: resolve
-          }),
-          SideBar({
-            serviceContainer,
-          }),
-          JSTerm({
-            hud: this.hud,
-          }),
-        ));
+      const app = App({
+        attachRefToHud,
+        serviceContainer,
+        hud,
+        onFirstMeaningfulPaint: resolve,
+      });
+
+      // Render the root Application component.
+      let provider = createElement(Provider, { store }, app);
       this.body = ReactDOM.render(provider, this.parentNode);
     });
   },
 
   dispatchMessageAdd: function(packet, waitForResponse) {
     // Wait for the message to render to resolve with the DOM node.
     // This is just for backwards compatibility with old tests, and should
     // be removed once it's not needed anymore.
--- a/devtools/client/webconsole/reducers/index.js
+++ b/devtools/client/webconsole/reducers/index.js
@@ -4,15 +4,17 @@
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 "use strict";
 
 const { filters } = require("./filters");
 const { messages } = require("./messages");
 const { prefs } = require("./prefs");
 const { ui } = require("./ui");
+const { notifications } = require("./notifications");
 
 exports.reducers = {
   filters,
   messages,
   prefs,
   ui,
+  notifications,
 };
--- a/devtools/client/webconsole/reducers/moz.build
+++ b/devtools/client/webconsole/reducers/moz.build
@@ -2,11 +2,12 @@
 # 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/.
 
 DevToolsModules(
     'filters.js',
     'index.js',
     'messages.js',
+    'notifications.js',
     'prefs.js',
     'ui.js',
 )
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/reducers/notifications.js
@@ -0,0 +1,58 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+const {
+  APPEND_NOTIFICATION,
+  REMOVE_NOTIFICATION,
+} = require("devtools/client/webconsole/constants");
+
+const {
+  appendNotification,
+  removeNotificationWithValue
+} = require("devtools/client/shared/components/NotificationBox");
+
+/**
+ * Create default initial state for this reducer. The state is composed
+ * from list of notifications.
+ */
+function getInitialState() {
+  return {
+    notifications: undefined,
+  };
+}
+
+/**
+ * Reducer function implementation. This reducers is responsible
+ * for maintaining list of notifications. It's consumed by
+ * `NotificationBox` component.
+ */
+function notifications(state = getInitialState(), action) {
+  switch (action.type) {
+    case APPEND_NOTIFICATION:
+      return append(state, action);
+    case REMOVE_NOTIFICATION:
+      return remove(state, action);
+  }
+
+  return state;
+}
+
+// Helpers
+
+function append(state, action) {
+  return appendNotification(state, action);
+}
+
+function remove(state, action) {
+  return removeNotificationWithValue(state.notifications, action.value);
+}
+
+// Exports
+
+module.exports = {
+  notifications,
+};
--- a/devtools/client/webconsole/selectors/moz.build
+++ b/devtools/client/webconsole/selectors/moz.build
@@ -1,11 +1,12 @@
 # vim: set filetype=python:
 # 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/.
 
 DevToolsModules(
     'filters.js',
     'messages.js',
+    'notifications.js',
     'prefs.js',
     'ui.js',
 )
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/selectors/notifications.js
@@ -0,0 +1,14 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+function getAllNotifications(state) {
+  return state.notifications.notifications;
+}
+
+module.exports = {
+  getAllNotifications,
+};
--- a/devtools/client/webconsole/test/mochitest/browser_jsterm_selfxss.js
+++ b/devtools/client/webconsole/test/mochitest/browser_jsterm_selfxss.js
@@ -12,16 +12,18 @@ XPCOMUtils.defineLazyServiceGetter(
   "clipboardHelper",
   "@mozilla.org/widget/clipboardhelper;1",
   "nsIClipboardHelper"
 );
 const WebConsoleUtils = require("devtools/client/webconsole/utils").Utils;
 const stringToCopy = "foobazbarBug642615";
 
 add_task(async function() {
+  await pushPref("devtools.selfxss.count", 0);
+
   let {jsterm} = await openNewTabAndConsole(TEST_URI);
   jsterm.clearOutput();
   ok(!jsterm.completeNode.value, "no completeNode.value");
 
   jsterm.setInputValue("doc");
 
   info("wait for completion value after typing 'docu'");
   let onAutocompleteUpdated = jsterm.once("autocomplete-updated");
--- a/devtools/client/webconsole/utils.js
+++ b/devtools/client/webconsole/utils.js
@@ -23,16 +23,18 @@ const CONSOLE_ENTRY_THRESHOLD = 5;
 exports.CONSOLE_WORKER_IDS = [
   "SharedWorker",
   "ServiceWorker",
   "Worker"
 ];
 
 var WebConsoleUtils = {
 
+  CONSOLE_ENTRY_THRESHOLD,
+
   /**
    * Wrap a string in an nsISupportsString object.
    *
    * @param string string
    * @return nsISupportsString
    */
   supportsString: function(string) {
     let str = Cc["@mozilla.org/supports-string;1"]
@@ -120,17 +122,17 @@ var WebConsoleUtils = {
     } catch (ex) {
       return false;
     }
   },
 
   /**
    * Helper function to deduce the name of the provided function.
    *
-   * @param funtion function
+   * @param function function
    *        The function whose name will be returned.
    * @return string
    *         Function name.
    */
   getFunctionName: function(func) {
     let name = null;
     if (func.name) {
       name = func.name;