Bug 1350411 - Add Message Channel for Activity Stream system add-on draft bug1350411
authork88hudson <khudson@mozilla.com>
Fri, 07 Apr 2017 14:13:14 -0400
changeset 562163 51ddc5ea2047
parent 559749 b1364675bdf5
child 624189 6cd966162b7c
push id53972
push userkhudson@mozilla.com
push dateThu, 13 Apr 2017 15:30:10 +0000
bugs1350411
milestone55.0a1
Bug 1350411 - Add Message Channel for Activity Stream system add-on MozReview-Commit-ID: DCcGDjKdIHh
browser/extensions/activity-stream/common/Actions.jsm
browser/extensions/activity-stream/data/content/activity-stream.html
browser/extensions/activity-stream/lib/ActivityStream.jsm
browser/extensions/activity-stream/lib/ActivityStreamMessageChannel.jsm
browser/extensions/activity-stream/lib/NewTabInit.jsm
browser/extensions/activity-stream/lib/Store.jsm
--- a/browser/extensions/activity-stream/common/Actions.jsm
+++ b/browser/extensions/activity-stream/common/Actions.jsm
@@ -1,19 +1,130 @@
 /* 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";
 
+this.MAIN_MESSAGE_TYPE = "ActivityStream:Main";
+this.CONTENT_MESSAGE_TYPE = "ActivityStream:Content";
+
 this.actionTypes = [
   "INIT",
   "UNINIT",
+  "NEW_TAB_INITIAL_STATE",
+  "NEW_TAB_LOAD",
+  "NEW_TAB_UNLOAD"
 // The line below creates an object like this:
 // {
 //   INIT: "INIT",
 //   UNINIT: "UNINIT"
 // }
 // It prevents accidentally adding a different key/value name.
 ].reduce((obj, type) => { obj[type] = type; return obj; }, {});
 
+// Helper function for creating routed actions between content and main
+// Not intended to be used by consumers
+function _RouteMessage(action, options) {
+  const meta = action.meta ? Object.assign({}, action.meta) : {};
+  if (!options || !options.from || !options.to) {
+    throw new Error("Routed Messages must have options as the second parameter, and must at least include a .from and .to property.");
+  }
+  // For each of these fields, if they are passed as an option,
+  // add them to the action. If they are not defined, remove them.
+  ["from", "to", "toTarget", "fromTarget", "skipOrigin"].forEach(o => {
+    if (typeof options[o] !== "undefined") {
+      meta[o] = options[o];
+    } else if (meta[o]) {
+      delete meta[o];
+    }
+  });
+  return Object.assign({}, action, {meta});
+}
+
+/**
+ * SendToMain - Creates a message that will be sent to the Main process.
+ *
+ * @param  {object} action Any redux action (required)
+ * @param  {object} options
+ * @param  {string} options.fromTarget The id of the content port from which the action originated. (optional)
+ * @return {object} An action with added .meta properties
+ */
+function SendToMain(action, options = {}) {
+  return _RouteMessage(action, {
+    from: CONTENT_MESSAGE_TYPE,
+    to: MAIN_MESSAGE_TYPE,
+    fromTarget: options.fromTarget
+  });
+}
+
+/**
+ * BroadcastToContent - Creates a message that will be sent to ALL content processes.
+ *
+ * @param  {object} action Any redux action (required)
+ * @return {object} An action with added .meta properties
+ */
+function BroadcastToContent(action) {
+  return _RouteMessage(action, {
+    from: MAIN_MESSAGE_TYPE,
+    to: CONTENT_MESSAGE_TYPE
+  });
+}
+
+/**
+ * SendToContent - Creates a message that will be sent to a particular Content process.
+ *
+ * @param  {object} action Any redux action (required)
+ * @param  {string} target The id of a content port
+ * @return {object} An action with added .meta properties
+ */
+function SendToContent(action, target) {
+  if (!target) {
+    throw new Error("You must provide a target ID as the second parameter of SendToContent. If you want to send to all content processes, use BroadcastToContent");
+  }
+  return _RouteMessage(action, {
+    from: MAIN_MESSAGE_TYPE,
+    to: CONTENT_MESSAGE_TYPE,
+    toTarget: target
+  });
+}
+
+this.actionCreators = {
+  SendToMain,
+  SendToContent,
+  BroadcastToContent
+};
+
+// These are helpers to test for certain kinds of actions
+this.actionUtils = {
+  isSendToMain(action) {
+    if (!action.meta) {
+      return false;
+    }
+    return action.meta.to === MAIN_MESSAGE_TYPE && action.meta.from === CONTENT_MESSAGE_TYPE;
+  },
+  isBroadcastToContent(action) {
+    if (!action.meta) {
+      return false;
+    }
+    if (action.meta.to === CONTENT_MESSAGE_TYPE && !action.meta.toTarget) {
+      return true;
+    }
+    return false;
+  },
+  isSendToContent(action) {
+    if (!action.meta) {
+      return false;
+    }
+    if (action.meta.to === CONTENT_MESSAGE_TYPE && action.meta.toTarget) {
+      return true;
+    }
+    return false;
+  },
+  _RouteMessage
+};
+
 this.EXPORTED_SYMBOLS = [
-  "actionTypes"
+  "actionTypes",
+  "actionCreators",
+  "actionUtils",
+  "MAIN_MESSAGE_TYPE",
+  "CONTENT_MESSAGE_TYPE"
 ];
--- a/browser/extensions/activity-stream/data/content/activity-stream.html
+++ b/browser/extensions/activity-stream/data/content/activity-stream.html
@@ -2,11 +2,30 @@
 <html lang="en-us" dir="ltr">
   <head>
     <meta charset="utf-8">
     <title>New Tab</title>
   </head>
   <body>
     <div id="root">
       <h1>New Tab</h1>
+      <ul id="top-sites"></ul>
     </div>
+    <script>
+      const topSitesEl = document.getElementById("top-sites");
+      window.addMessageListener("ActivityStream:MainToContent", msg => {
+        if (msg.data.type === "NEW_TAB_INITIAL_STATE") {
+          const fragment = document.createDocumentFragment()
+          for (const row of msg.data.data.TopSites.rows) {
+            const li = document.createElement("li");
+            const a = document.createElement("a");
+            a.href = row.url;
+            a.textContent = row.title;
+            li.appendChild(a);
+            fragment.appendChild(li);
+          }
+          topSitesEl.appendChild(fragment);
+        }
+      });
+
+    </script>
   </body>
 </html>
--- a/browser/extensions/activity-stream/lib/ActivityStream.jsm
+++ b/browser/extensions/activity-stream/lib/ActivityStream.jsm
@@ -1,16 +1,19 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 "use strict";
 
 const {utils: Cu} = Components;
 const {Store} = Cu.import("resource://activity-stream/lib/Store.jsm", {});
 
+// Feeds
+const {NewTabInit} = Cu.import("resource://activity-stream/lib/NewTabInit.jsm", {});
+
 this.ActivityStream = class ActivityStream {
 
   /**
    * constructor - Initializes an instance of ActivityStream
    *
    * @param  {object} options Options for the ActivityStream instance
    * @param  {string} options.id Add-on ID. e.g. "activity-stream@mozilla.org".
    * @param  {string} options.version Version of the add-on. e.g. "0.1.0"
@@ -18,17 +21,19 @@ this.ActivityStream = class ActivityStre
    */
   constructor(options) {
     this.initialized = false;
     this.options = options;
     this.store = new Store();
   }
   init() {
     this.initialized = true;
-    this.store.init();
+    this.store.init([
+      new NewTabInit()
+    ]);
   }
   uninit() {
     this.store.uninit();
     this.initialized = false;
   }
 };
 
 this.EXPORTED_SYMBOLS = ["ActivityStream"];
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/lib/ActivityStreamMessageChannel.jsm
@@ -0,0 +1,197 @@
+/* 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/. */
+/* globals AboutNewTab, RemotePages, XPCOMUtils */
+
+"use strict";
+
+const {utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+const {
+  actionUtils: au,
+  actionCreators: ac,
+  actionTypes: at
+} = Cu.import("resource://activity-stream/common/Actions.jsm", {});
+
+XPCOMUtils.defineLazyModuleGetter(this, "AboutNewTab",
+  "resource:///modules/AboutNewTab.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "RemotePages",
+  "resource://gre/modules/RemotePageManager.jsm");
+
+const ABOUT_NEW_TAB_URL = "about:newtab";
+
+const DEFAULT_OPTIONS = {
+  dispatch(action) {
+    throw new Error(`\nMessageChannel: Received action ${action.type}, but no dispatcher was defined.\n`);
+  },
+  pageURL: ABOUT_NEW_TAB_URL,
+  outgoingMessageName: "ActivityStream:MainToContent",
+  incomingMessageName: "ActivityStream:ContentToMain"
+};
+
+this.ActivityStreamMessageChannel = class ActivityStreamMessageChannel {
+  /**
+   * ActivityStreamMessageChannel - This module connects a Redux store to a RemotePageManager in Firefox.
+   *                  Call .createChannel to start the connection, and .destroyChannel to destroy it.
+   *                  You should use the BroadcastToContent, SendToContent, and SendToMain action creators
+   *                  in common/Actions.jsm to help you create actions that will be automatically routed
+   *                  to the correct location.
+   *
+   * @param  {object} options
+   * @param  {function} options.dispatch The dispatch method from a Redux store
+   * @param  {string} options.pageURL The URL to which a RemotePageManager should be attached.
+   *                                  Note that if it is about:newtab, the existing RemotePageManager
+   *                                  for about:newtab will also be disabled
+   * @param  {string} options.outgoingMessageName The name of the message sent to child processes
+   * @param  {string} options.incomingMessageName The name of the message received from child processes
+   * @return {ActivityStreamMessageChannel}
+   */
+  constructor(options = {}) {
+    Object.assign(this, DEFAULT_OPTIONS, options);
+    this.channel = null;
+
+    this.middleware = this.middleware.bind(this);
+    this.onMessage = this.onMessage.bind(this);
+    this.onNewTabLoad = this.onNewTabLoad.bind(this);
+    this.onNewTabUnload = this.onNewTabUnload.bind(this);
+  }
+
+  /**
+   * middleware - Redux middleware that looks for SendToContent and BroadcastToContent type
+   *              actions, and sends them out.
+   *
+   * @param  {object} store A redux store
+   * @return {function} Redux middleware
+   */
+  middleware(store) {
+    return next => action => {
+      if (!this.channel) {
+        next(action);
+        return;
+      }
+      if (au.isSendToContent(action)) {
+        this.send(action);
+      } else if (au.isBroadcastToContent(action)) {
+        this.broadcast(action);
+      }
+      next(action);
+    };
+  }
+
+  /**
+   * onActionFromContent - Handler for actions from a content processes
+   *
+   * @param  {object} action  A Redux action
+   * @param  {string} targetId The portID of the port that sent the message
+   */
+  onActionFromContent(action, targetId) {
+    this.dispatch(ac.SendToMain(action, {fromTarget: targetId}));
+  }
+
+  /**
+   * broadcast - Sends an action to all ports
+   *
+   * @param  {object} action A Redux action
+   */
+  broadcast(action) {
+    this.channel.sendAsyncMessage(this.outgoingMessageName, action);
+  }
+
+  /**
+   * send - Sends an action to a specific port
+   *
+   * @param  {obj} action A redux action; it should contain a portID in the meta.toTarget property
+   */
+  send(action) {
+    const targetId = action.meta && action.meta.toTarget;
+    const target = this.getTargetById(targetId);
+    if (!target) {
+      // The target is no longer around - maybe the user closed the page
+      return;
+    }
+    target.sendAsyncMessage(this.outgoingMessageName, action);
+  }
+
+  /**
+   * getIdByTarget - Retrieve the id of a message target, if it exists in this.targets
+   *
+   * @param  {obj} targetObj A message target
+   * @return {string|null} The unique id of the target, if it exists.
+   */
+  getTargetById(id) {
+    for (let port of this.channel.messagePorts) {
+      if (port.portID === id) {
+        return port;
+      }
+    }
+    return null;
+  }
+
+  /**
+   * createChannel - Create RemotePages channel to establishing message passing
+   *                 between the main process and child pages
+   */
+  createChannel() {
+    //  RemotePageManager must be disabled for about:newtab, since only one can exist at once
+    if (this.pageURL === ABOUT_NEW_TAB_URL) {
+      AboutNewTab.override();
+    }
+    this.channel = new RemotePages(this.pageURL);
+    this.channel.addMessageListener("RemotePage:Load", this.onNewTabLoad);
+    this.channel.addMessageListener("RemotePage:Unload", this.onNewTabUnload);
+    this.channel.addMessageListener(this.incomingMessageName, this.onMessage);
+  }
+
+  /**
+   * destroyChannel - Destroys the RemotePages channel
+   */
+  destroyChannel() {
+    this.channel.destroy();
+    this.channel = null;
+    if (this.pageURL === ABOUT_NEW_TAB_URL) {
+      AboutNewTab.reset();
+    }
+  }
+
+  /**
+   * onNewTabLoad - Handler for special RemotePage:Load message fired by RemotePages
+   *
+   * @param  {obj} msg The messsage from a page that was just loaded
+   */
+  onNewTabLoad(msg) {
+    this.onActionFromContent({type: at.NEW_TAB_LOAD}, msg.target.portID);
+  }
+
+  /**
+   * onNewTabUnloadLoad - Handler for special RemotePage:Unload message fired by RemotePages
+   *
+   * @param  {obj} msg The messsage from a page that was just unloaded
+   */
+  onNewTabUnload(msg) {
+    this.onActionFromContent({type: at.NEW_TAB_UNLOAD}, msg.target.portID);
+  }
+
+  /**
+   * onMessage - Handles custom messages from content. It expects all messages to
+   *             be formatted as Redux actions, and dispatches them to this.store
+   *
+   * @param  {obj} msg A custom message from content
+   * @param  {obj} msg.action A Redux action (e.g. {type: "HELLO_WORLD"})
+   * @param  {obj} msg.target A message target
+   */
+  onMessage(msg) {
+    const action = msg.data;
+    const {portID} = msg.target;
+    if (!action || !action.type) {
+      Cu.reportError(new Error(`Received an improperly formatted message from ${portID}`));
+      return;
+    }
+    this.onActionFromContent(action, msg.target.portID);
+  }
+}
+
+this.DEFAULT_OPTIONS = DEFAULT_OPTIONS;
+this.EXPORTED_SYMBOLS = ["ActivityStreamMessageChannel", "DEFAULT_OPTIONS"];
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/lib/NewTabInit.jsm
@@ -0,0 +1,25 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+const {utils: Cu} = Components;
+const {actionTypes: at, actionCreators: ac} = Cu.import("resource://activity-stream/common/Actions.jsm", {});
+
+/**
+ * NewTabInit - A placeholder for now. This will send a copy of the state to all
+ *              newly opened tabs.
+ */
+this.NewTabInit = class NewTabInit {
+  onAction(action) {
+    let newAction;
+    switch (action.type) {
+      case at.NEW_TAB_LOAD:
+        newAction = {type: at.NEW_TAB_INITIAL_STATE, data: this.store.getState()};
+        this.store.dispatch(ac.SendToContent(newAction, action.meta.fromTarget));
+        break;
+    }
+  }
+};
+
+this.EXPORTED_SYMBOLS = ["NewTabInit"];
--- a/browser/extensions/activity-stream/lib/Store.jsm
+++ b/browser/extensions/activity-stream/lib/Store.jsm
@@ -3,76 +3,83 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 "use strict";
 
 const {utils: Cu} = Components;
 
 const {redux} = Cu.import("resource://activity-stream/vendor/Redux.jsm", {});
 const {actionTypes: at} = Cu.import("resource://activity-stream/common/Actions.jsm", {});
 const {reducers} = Cu.import("resource://activity-stream/common/Reducers.jsm", {});
+const {ActivityStreamMessageChannel} = Cu.import("resource://activity-stream/lib/ActivityStreamMessageChannel.jsm", {});
 
 /**
  * Store - This has a similar structure to a redux store, but includes some extra
- *         functionality. It accepts an array of "Feeds" on inititalization, which
+ *         functionality to allow for routing of actions between the Main processes
+ *         and child processes via a ActivityStreamMessageChannel.
+ *         It also accepts an array of "Feeds" on inititalization, which
  *         can listen for any action that is dispatched through the store.
  */
 this.Store = class Store {
 
   /**
-   * constructor - The redux store is created here,
+   * constructor - The redux store and message manager are created here,
    *               but no listeners are added until "init" is called.
    */
   constructor() {
     this._middleware = this._middleware.bind(this);
     // Bind each redux method so we can call it directly from the Store. E.g.,
     // store.dispatch() will call store._store.dispatch();
     ["dispatch", "getState", "subscribe"].forEach(method => {
       this[method] = function(...args) {
         return this._store[method](...args);
       }.bind(this);
     });
     this.feeds = new Set();
+    this._messageChannel = new ActivityStreamMessageChannel({dispatch: this.dispatch});
     this._store = redux.createStore(
       redux.combineReducers(reducers),
-      redux.applyMiddleware(this._middleware)
+      redux.applyMiddleware(this._middleware, this._messageChannel.middleware)
     );
   }
 
   /**
    * _middleware - This is redux middleware consumed by redux.createStore.
    *               it calls each feed's .onAction method, if one
    *               is defined.
    */
   _middleware(store) {
     return next => action => {
       next(action);
       this.feeds.forEach(s => s.onAction && s.onAction(action));
     };
   }
 
   /**
-   * init - Initializes the MessageManager channel, and adds feeds.
+   * init - Initializes the ActivityStreamMessageChannel channel, and adds feeds.
    *        After initialization has finished, an INIT action is dispatched.
    *
    * @param  {array} feeds An array of objects with an optional .onAction method
    */
   init(feeds) {
     if (feeds) {
       feeds.forEach(subscriber => {
         subscriber.store = this;
         this.feeds.add(subscriber);
       });
     }
+    this._messageChannel.createChannel();
     this.dispatch({type: at.INIT});
   }
 
   /**
-   * uninit - Clears all feeds, dispatches an UNINIT action
+   * uninit - Clears all feeds, dispatches an UNINIT action, and
+   *          destroys the message manager channel.
    *
    * @return {type}  description
    */
   uninit() {
     this.feeds.clear();
     this.dispatch({type: at.UNINIT});
+    this._messageChannel.destroyChannel();
   }
 };
 
 this.EXPORTED_SYMBOLS = ["Store"];