Bug 1074680 - Create a Loop room, r=Standard8.
authorNicolas Perriault <nperriault@gmail.com>
Fri, 31 Oct 2014 16:28:33 +0100
changeset 213423 ca4948a63866d07c1c032d6729ba85787105c90f
parent 213422 a155ff71d1740c2077c80322240ad0e5f3c8cae1
child 213424 0f8732b3b6e73653723a1ac11229de0f936b9924
push id27749
push userryanvm@gmail.com
push dateFri, 31 Oct 2014 20:30:39 +0000
treeherdermozilla-central@04a87b6ff211 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersStandard8
bugs1074680
milestone36.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 1074680 - Create a Loop room, r=Standard8.
browser/components/loop/content/js/panel.js
browser/components/loop/content/js/panel.jsx
browser/components/loop/content/shared/css/panel.css
browser/components/loop/content/shared/js/actions.js
browser/components/loop/content/shared/js/roomListStore.js
browser/components/loop/test/desktop-local/panel_test.js
browser/components/loop/test/shared/roomListStore_test.js
browser/components/loop/ui/fake-mozLoop.js
browser/components/loop/ui/ui-showcase.css
--- a/browser/components/loop/content/js/panel.js
+++ b/browser/components/loop/content/js/panel.js
@@ -492,17 +492,17 @@ loop.panel = (function(_, mozL10n) {
     },
 
     handleMouseLeave: function(event) {
       this.setState({urlCopied: false});
     },
 
     _isActive: function() {
       // XXX bug 1074679 will implement this properly
-      return this.props.room.currSize > 0;
+      return this.props.room.participants.length > 0;
     },
 
     render: function() {
       var room = this.props.room;
       var roomClasses = React.addons.classSet({
         "room-entry": true,
         "room-active": this._isActive()
       });
@@ -533,67 +533,84 @@ loop.panel = (function(_, mozL10n) {
    * Room list.
    */
   var RoomList = React.createClass({displayName: 'RoomList',
     mixins: [Backbone.Events],
 
     propTypes: {
       store: React.PropTypes.instanceOf(loop.store.RoomListStore).isRequired,
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
-      rooms: React.PropTypes.array
+      userDisplayName: React.PropTypes.string.isRequired  // for room creation
     },
 
     getInitialState: function() {
-      var storeState = this.props.store.getStoreState();
-      return {
-        error: this.props.error || storeState.error,
-        rooms: this.props.rooms || storeState.rooms,
-      };
+      return this.props.store.getStoreState();
     },
 
-    componentWillMount: function() {
-      this.listenTo(this.props.store, "change", this._onRoomListChanged);
+    componentDidMount: function() {
+      this.listenTo(this.props.store, "change", this._onStoreStateChanged);
 
+      // XXX this should no longer be necessary once have a better mechanism
+      // for updating the list (possibly as part of the content side of bug
+      // 1074665.
       this.props.dispatcher.dispatch(new sharedActions.GetAllRooms());
     },
 
     componentWillUnmount: function() {
       this.stopListening(this.props.store);
     },
 
-    _onRoomListChanged: function() {
+    _onStoreStateChanged: function() {
       this.setState(this.props.store.getStoreState());
     },
 
     _getListHeading: function() {
       var numRooms = this.state.rooms.length;
       if (numRooms === 0) {
         return mozL10n.get("rooms_list_no_current_conversations");
       }
       return mozL10n.get("rooms_list_current_conversations", {num: numRooms});
     },
 
+    _hasPendingOperation: function() {
+      return this.state.pendingCreation || this.state.pendingInitialRetrieval;
+    },
+
+    handleCreateButtonClick: function() {
+      this.props.dispatcher.dispatch(new sharedActions.CreateRoom({
+        nameTemplate: mozL10n.get("rooms_default_room_name_template"),
+        roomOwner: this.props.userDisplayName
+      }));
+    },
+
     openRoom: function(room) {
       // XXX implement me; see bug 1074678
     },
 
     render: function() {
       if (this.state.error) {
         // XXX Better end user reporting of errors.
-        console.error(this.state.error);
+        console.error("RoomList error", this.state.error);
       }
 
       return (
-        React.DOM.div({className: "room-list"}, 
+        React.DOM.div({className: "rooms"}, 
           React.DOM.h1(null, this._getListHeading()), 
-          
+          React.DOM.div({className: "room-list"}, 
             this.state.rooms.map(function(room, i) {
               return RoomEntry({key: i, room: room, openRoom: this.openRoom});
             }, this)
-          
+          ), 
+          React.DOM.p(null, 
+            React.DOM.button({className: "btn btn-info", 
+                    onClick: this.handleCreateButtonClick, 
+                    disabled: this._hasPendingOperation()}, 
+              mozL10n.get("rooms_new_room_button_label")
+            )
+          )
         )
       );
     }
   });
 
   /**
    * Panel view.
    */
@@ -662,17 +679,18 @@ loop.panel = (function(_, mozL10n) {
      */
     _renderRoomsTab: function() {
       if (!navigator.mozLoop.getLoopBoolPref("rooms.enabled")) {
         return null;
       }
       return (
         Tab({name: "rooms"}, 
           RoomList({dispatcher: this.props.dispatcher, 
-                    store: this.props.roomListStore})
+                    store: this.props.roomListStore, 
+                    userDisplayName: this._getUserDisplayName()})
         )
       );
     },
 
     startForm: function(name, contact) {
       this.refs[name].initForm(contact);
       this.selectTab(name);
     },
@@ -688,20 +706,23 @@ loop.panel = (function(_, mozL10n) {
     componentDidMount: function() {
       window.addEventListener("LoopStatusChanged", this._onStatusChanged);
     },
 
     componentWillUnmount: function() {
       window.removeEventListener("LoopStatusChanged", this._onStatusChanged);
     },
 
+    _getUserDisplayName: function() {
+      return this.state.userProfile && this.state.userProfile.email ||
+             __("display_name_guest");
+    },
+
     render: function() {
       var NotificationListView = sharedViews.NotificationListView;
-      var displayName = this.state.userProfile && this.state.userProfile.email ||
-                        __("display_name_guest");
       return (
         React.DOM.div(null, 
           NotificationListView({notifications: this.props.notifications, 
                                 clearOnDocumentHidden: true}), 
           TabView({ref: "tabView", selectedTab: this.props.selectedTab, 
             buttonsHidden: !this.state.userProfile && !this.props.showTabButtons}, 
             Tab({name: "call"}, 
               React.DOM.div({className: "content-area"}, 
@@ -726,17 +747,17 @@ loop.panel = (function(_, mozL10n) {
             ), 
             Tab({name: "contacts_import", hidden: true}, 
               ContactDetailsForm({ref: "contacts_import", mode: "import", 
                                   selectTab: this.selectTab})
             )
           ), 
           React.DOM.div({className: "footer"}, 
             React.DOM.div({className: "user-details"}, 
-              UserIdentity({displayName: displayName}), 
+              UserIdentity({displayName: this._getUserDisplayName()}), 
               AvailabilityDropdown(null)
             ), 
             React.DOM.div({className: "signin-details"}, 
               AuthLink(null), 
               React.DOM.div({className: "footer-signin-separator"}), 
               SettingsDropdown(null)
             )
           )
--- a/browser/components/loop/content/js/panel.jsx
+++ b/browser/components/loop/content/js/panel.jsx
@@ -492,17 +492,17 @@ loop.panel = (function(_, mozL10n) {
     },
 
     handleMouseLeave: function(event) {
       this.setState({urlCopied: false});
     },
 
     _isActive: function() {
       // XXX bug 1074679 will implement this properly
-      return this.props.room.currSize > 0;
+      return this.props.room.participants.length > 0;
     },
 
     render: function() {
       var room = this.props.room;
       var roomClasses = React.addons.classSet({
         "room-entry": true,
         "room-active": this._isActive()
       });
@@ -533,67 +533,84 @@ loop.panel = (function(_, mozL10n) {
    * Room list.
    */
   var RoomList = React.createClass({
     mixins: [Backbone.Events],
 
     propTypes: {
       store: React.PropTypes.instanceOf(loop.store.RoomListStore).isRequired,
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
-      rooms: React.PropTypes.array
+      userDisplayName: React.PropTypes.string.isRequired  // for room creation
     },
 
     getInitialState: function() {
-      var storeState = this.props.store.getStoreState();
-      return {
-        error: this.props.error || storeState.error,
-        rooms: this.props.rooms || storeState.rooms,
-      };
+      return this.props.store.getStoreState();
     },
 
-    componentWillMount: function() {
-      this.listenTo(this.props.store, "change", this._onRoomListChanged);
+    componentDidMount: function() {
+      this.listenTo(this.props.store, "change", this._onStoreStateChanged);
 
+      // XXX this should no longer be necessary once have a better mechanism
+      // for updating the list (possibly as part of the content side of bug
+      // 1074665.
       this.props.dispatcher.dispatch(new sharedActions.GetAllRooms());
     },
 
     componentWillUnmount: function() {
       this.stopListening(this.props.store);
     },
 
-    _onRoomListChanged: function() {
+    _onStoreStateChanged: function() {
       this.setState(this.props.store.getStoreState());
     },
 
     _getListHeading: function() {
       var numRooms = this.state.rooms.length;
       if (numRooms === 0) {
         return mozL10n.get("rooms_list_no_current_conversations");
       }
       return mozL10n.get("rooms_list_current_conversations", {num: numRooms});
     },
 
+    _hasPendingOperation: function() {
+      return this.state.pendingCreation || this.state.pendingInitialRetrieval;
+    },
+
+    handleCreateButtonClick: function() {
+      this.props.dispatcher.dispatch(new sharedActions.CreateRoom({
+        nameTemplate: mozL10n.get("rooms_default_room_name_template"),
+        roomOwner: this.props.userDisplayName
+      }));
+    },
+
     openRoom: function(room) {
       // XXX implement me; see bug 1074678
     },
 
     render: function() {
       if (this.state.error) {
         // XXX Better end user reporting of errors.
-        console.error(this.state.error);
+        console.error("RoomList error", this.state.error);
       }
 
       return (
-        <div className="room-list">
+        <div className="rooms">
           <h1>{this._getListHeading()}</h1>
-          {
+          <div className="room-list">{
             this.state.rooms.map(function(room, i) {
               return <RoomEntry key={i} room={room} openRoom={this.openRoom} />;
             }, this)
-          }
+          }</div>
+          <p>
+            <button className="btn btn-info"
+                    onClick={this.handleCreateButtonClick}
+                    disabled={this._hasPendingOperation()}>
+              {mozL10n.get("rooms_new_room_button_label")}
+            </button>
+          </p>
         </div>
       );
     }
   });
 
   /**
    * Panel view.
    */
@@ -662,17 +679,18 @@ loop.panel = (function(_, mozL10n) {
      */
     _renderRoomsTab: function() {
       if (!navigator.mozLoop.getLoopBoolPref("rooms.enabled")) {
         return null;
       }
       return (
         <Tab name="rooms">
           <RoomList dispatcher={this.props.dispatcher}
-                    store={this.props.roomListStore} />
+                    store={this.props.roomListStore}
+                    userDisplayName={this._getUserDisplayName()}/>
         </Tab>
       );
     },
 
     startForm: function(name, contact) {
       this.refs[name].initForm(contact);
       this.selectTab(name);
     },
@@ -688,20 +706,23 @@ loop.panel = (function(_, mozL10n) {
     componentDidMount: function() {
       window.addEventListener("LoopStatusChanged", this._onStatusChanged);
     },
 
     componentWillUnmount: function() {
       window.removeEventListener("LoopStatusChanged", this._onStatusChanged);
     },
 
+    _getUserDisplayName: function() {
+      return this.state.userProfile && this.state.userProfile.email ||
+             __("display_name_guest");
+    },
+
     render: function() {
       var NotificationListView = sharedViews.NotificationListView;
-      var displayName = this.state.userProfile && this.state.userProfile.email ||
-                        __("display_name_guest");
       return (
         <div>
           <NotificationListView notifications={this.props.notifications}
                                 clearOnDocumentHidden={true} />
           <TabView ref="tabView" selectedTab={this.props.selectedTab}
             buttonsHidden={!this.state.userProfile && !this.props.showTabButtons}>
             <Tab name="call">
               <div className="content-area">
@@ -726,17 +747,17 @@ loop.panel = (function(_, mozL10n) {
             </Tab>
             <Tab name="contacts_import" hidden={true}>
               <ContactDetailsForm ref="contacts_import" mode="import"
                                   selectTab={this.selectTab}/>
             </Tab>
           </TabView>
           <div className="footer">
             <div className="user-details">
-              <UserIdentity displayName={displayName} />
+              <UserIdentity displayName={this._getUserDisplayName()} />
               <AvailabilityDropdown />
             </div>
             <div className="signin-details">
               <AuthLink />
               <div className="footer-signin-separator" />
               <SettingsDropdown />
             </div>
           </div>
--- a/browser/components/loop/content/shared/css/panel.css
+++ b/browser/components/loop/content/shared/css/panel.css
@@ -134,27 +134,47 @@ body {
 }
 
 .content-area input:not(.pristine):invalid {
   border-color: #d74345;
   box-shadow: 0 0 4px #c43c3e;
 }
 
 /* Rooms */
-.room-list {
+.rooms {
   background: #f5f5f5;
+  min-height: 100px;
 }
 
-.room-list > h1 {
+.rooms > h1 {
   font-weight: bold;
   color: #999;
   padding: .5rem 1rem;
   border-bottom: 1px solid #ddd;
 }
 
+.rooms > p {
+  border-top: 1px solid #ddd;
+  padding: 1rem 0;
+  margin: 0;
+}
+
+.rooms > p > .btn {
+  display: block;
+  font-size: 1rem;
+  margin: 0 auto;
+  padding: .5rem 1rem;
+  border-radius: 3px;
+}
+
+.room-list {
+  max-height: 335px; /* XXX better computation needed */
+  overflow: scroll;
+}
+
 .room-list > .room-entry {
   padding: 1rem 1rem 0 .5rem;
 }
 
 .room-list > .room-entry > h2 {
   font-size: .85rem;
   color: #777;
 }
--- a/browser/components/loop/content/shared/js/actions.js
+++ b/browser/components/loop/content/shared/js/actions.js
@@ -123,28 +123,47 @@ loop.shared.actions = (function() {
     SetMute: Action.define("setMute", {
       // The part of the stream to enable, e.g. "audio" or "video"
       type: String,
       // Whether or not to enable the stream.
       enabled: Boolean
     }),
 
     /**
+     * Creates a new room.
+     * XXX: should move to some roomActions module - refs bug 1079284
+     */
+    CreateRoom: Action.define("createRoom", {
+      // The localized template to use to name the new room
+      // (eg. "Conversation {{conversationLabel}}").
+      nameTemplate: String,
+      roomOwner: String
+    }),
+
+    /**
+     * Rooms creation error.
+     * XXX: should move to some roomActions module - refs bug 1079284
+     */
+    CreateRoomError: Action.define("createRoomError", {
+      error: Error
+    }),
+
+    /**
      * Retrieves room list.
      * XXX: should move to some roomActions module - refs bug 1079284
      */
     GetAllRooms: Action.define("getAllRooms", {
     }),
 
     /**
      * An error occured while trying to fetch the room list.
      * XXX: should move to some roomActions module - refs bug 1079284
      */
     GetAllRoomsError: Action.define("getAllRoomsError", {
-      error: String
+      error: Error
     }),
 
     /**
      * Updates room list.
      * XXX: should move to some roomActions module - refs bug 1079284
      */
     UpdateRoomList: Action.define("updateRoomList", {
       roomList: Array
@@ -153,11 +172,11 @@ loop.shared.actions = (function() {
     /**
      * Primes localRoomStore with roomLocalId, which triggers the EmptyRoomView
      * to do any necessary setup.
      *
      * XXX should move to localRoomActions module
      */
     SetupEmptyRoom: Action.define("setupEmptyRoom", {
       localRoomId: String
-    }),
+    })
   };
 })();
--- a/browser/components/loop/content/shared/js/roomListStore.js
+++ b/browser/components/loop/content/shared/js/roomListStore.js
@@ -52,107 +52,283 @@ loop.store = loop.store || {};
    *                                registering to consume actions.
    * - {mozLoop}         mozLoop    The MozLoop API object.
    *
    * @extends {Backbone.Events}
    * @param {Object} options Options object.
    */
   function RoomListStore(options) {
     options = options || {};
-    this.storeState = {error: null, rooms: []};
 
     if (!options.dispatcher) {
       throw new Error("Missing option dispatcher");
     }
-    this.dispatcher = options.dispatcher;
+    this._dispatcher = options.dispatcher;
 
     if (!options.mozLoop) {
       throw new Error("Missing option mozLoop");
     }
-    this.mozLoop = options.mozLoop;
+    this._mozLoop = options.mozLoop;
 
-    this.dispatcher.register(this, [
+    this._dispatcher.register(this, [
+      "createRoom",
+      "createRoomError",
       "getAllRooms",
       "getAllRoomsError",
       "openRoom",
       "updateRoomList"
     ]);
   }
 
   RoomListStore.prototype = _.extend({
     /**
-     * Retrieves current store state.
+     * Maximum size given to createRoom; only 2 is supported (and is
+     * always passed) because that's what the user-experience is currently
+     * designed and tested to handle.
+     * @type {Number}
+     */
+    maxRoomCreationSize: 2,
+
+    /**
+     * The number of hours for which the room will exist.
+     * @type {Number}
+     */
+    defaultExpiresIn: 5,
+
+    /**
+     * Internal store state representation.
+     * @type {Object}
+     * @see  #getStoreState
+     */
+    _storeState: {
+      error: null,
+      pendingCreation: false,
+      pendingInitialRetrieval: false,
+      rooms: []
+    },
+
+    /**
+     * Retrieves current store state. The returned state object holds the
+     * following properties:
+     *
+     * - {Boolean} pendingCreation         Pending room creation flag.
+     * - {Boolean} pendingInitialRetrieval Pending initial list retrieval flag.
+     * - {Array}   rooms                   The current room list.
+     * - {Error}   error                   Latest error encountered, if any.
      *
      * @return {Object}
      */
     getStoreState: function() {
-      return this.storeState;
+      return this._storeState;
+    },
+
+    /**
+     * Updates store state and trigger a "change" event.
+     *
+     * @param {Object} newState The new store state.
+     */
+    setStoreState: function(newState) {
+      for (var key in newState) {
+        this._storeState[key] = newState[key];
+      }
+      this.trigger("change");
+    },
+
+    /**
+     * Registers mozLoop.rooms events.
+     */
+    startListeningToRoomEvents: function() {
+      // Rooms event registration
+      this._mozLoop.rooms.on("add", this._onRoomAdded.bind(this));
+      this._mozLoop.rooms.on("update", this._onRoomUpdated.bind(this));
+      this._mozLoop.rooms.on("remove", this._onRoomRemoved.bind(this));
+    },
+
+    /**
+     * Local proxy helper to dispatch an action.
+     *
+     * @param {Action} action The action to dispatch.
+     */
+    _dispatchAction: function(action) {
+      this._dispatcher.dispatch(action);
     },
 
     /**
-     * Updates store states and trigger a "change" event.
+     * Updates current room list when a new room is available.
      *
-     * @param {Object} state The new store state.
+     * @param {String} eventName     The event name (unused).
+     * @param {Object} addedRoomData The added room data.
+     */
+    _onRoomAdded: function(eventName, addedRoomData) {
+      addedRoomData.participants = [];
+      addedRoomData.ctime = new Date().getTime();
+      this._dispatchAction(new sharedActions.UpdateRoomList({
+        roomList: this._storeState.rooms.concat(new Room(addedRoomData))
+      }));
+    },
+
+    /**
+     * Executed when a room is updated.
+     *
+     * @param {String} eventName       The event name (unused).
+     * @param {Object} updatedRoomData The updated room data.
      */
-    setStoreState: function(state) {
-      this.storeState = state;
-      this.trigger("change");
+    _onRoomUpdated: function(eventName, updatedRoomData) {
+      this._dispatchAction(new sharedActions.UpdateRoomList({
+        roomList: this._storeState.rooms.map(function(room) {
+          return room.roomToken === updatedRoomData.roomToken ?
+                 updatedRoomData : room;
+        })
+      }));
     },
 
     /**
+     * Executed when a room is removed.
+     *
+     * @param {String} eventName       The event name (unused).
+     * @param {Object} removedRoomData The removed room data.
+     */
+    _onRoomRemoved: function(eventName, removedRoomData) {
+      this._dispatchAction(new sharedActions.UpdateRoomList({
+        roomList: this._storeState.rooms.filter(function(room) {
+          return room.roomToken !== removedRoomData.roomToken;
+        })
+      }));
+    },
+
+
+    /**
      * Maps and sorts the raw room list received from the mozLoop API.
      *
      * @param  {Array} rawRoomList Raw room list.
      * @return {Array}
      */
-    _processRawRoomList: function(rawRoomList) {
+    _processRoomList: function(rawRoomList) {
       if (!rawRoomList) {
         return [];
       }
       return rawRoomList
         .map(function(rawRoom) {
           return new Room(rawRoom);
         })
         .slice()
         .sort(function(a, b) {
           return b.ctime - a.ctime;
         });
     },
 
     /**
+     * Finds the next available room number in the provided room list.
+     *
+     * @param  {String} nameTemplate The room name template; should contain a
+     *                               {{conversationLabel}} placeholder.
+     * @return {Number}
+     */
+    findNextAvailableRoomNumber: function(nameTemplate) {
+      var searchTemplate = nameTemplate.replace("{{conversationLabel}}", "");
+      var searchRegExp = new RegExp("^" + searchTemplate + "(\\d+)$");
+
+      var roomNumbers = this._storeState.rooms.map(function(room) {
+        var match = searchRegExp.exec(room.roomName);
+        return match && match[1] ? parseInt(match[1], 10) : 0;
+      });
+
+      if (!roomNumbers.length) {
+        return 1;
+      }
+
+      return Math.max.apply(null, roomNumbers) + 1;
+    },
+
+    /**
+     * Generates a room names against the passed template string.
+     *
+     * @param  {String} nameTemplate The room name template.
+     * @return {String}
+     */
+    _generateNewRoomName: function(nameTemplate) {
+      var roomLabel = this.findNextAvailableRoomNumber(nameTemplate);
+      return nameTemplate.replace("{{conversationLabel}}", roomLabel);
+    },
+
+    /**
+     * Creates a new room.
+     *
+     * @param {sharedActions.CreateRoom} actionData The new room information.
+     */
+    createRoom: function(actionData) {
+      this.setStoreState({pendingCreation: true});
+
+      var roomCreationData = {
+        roomName:  this._generateNewRoomName(actionData.nameTemplate),
+        roomOwner: actionData.roomOwner,
+        maxSize:   this.maxRoomCreationSize,
+        expiresIn: this.defaultExpiresIn
+      };
+
+      this._mozLoop.rooms.create(roomCreationData, function(err) {
+        this.setStoreState({pendingCreation: false});
+        if (err) {
+         this._dispatchAction(new sharedActions.CreateRoomError({error: err}));
+        }
+      }.bind(this));
+    },
+
+    /**
+     * Executed when a room creation error occurs.
+     *
+     * @param {sharedActions.CreateRoomError} actionData The action data.
+     */
+    createRoomError: function(actionData) {
+      this.setStoreState({
+        error: actionData.error,
+        pendingCreation: false
+      });
+    },
+
+    /**
      * Gather the list of all available rooms from the MozLoop API.
      */
     getAllRooms: function() {
-      this.mozLoop.rooms.getAll(function(err, rawRoomList) {
+      this.setStoreState({pendingInitialRetrieval: true});
+      this._mozLoop.rooms.getAll(null, function(err, rawRoomList) {
         var action;
+
+        this.setStoreState({pendingInitialRetrieval: false});
+
         if (err) {
           action = new sharedActions.GetAllRoomsError({error: err});
         } else {
           action = new sharedActions.UpdateRoomList({roomList: rawRoomList});
         }
-        this.dispatcher.dispatch(action);
+
+        this._dispatchAction(action);
+
+        // We can only start listening to room events after getAll() has been
+        // called executed first.
+        this.startListeningToRoomEvents();
       }.bind(this));
     },
 
     /**
      * Updates current error state in case getAllRooms failed.
      *
-     * @param {sharedActions.UpdateRoomListError} actionData The action data.
+     * @param {sharedActions.GetAllRoomsError} actionData The action data.
      */
     getAllRoomsError: function(actionData) {
       this.setStoreState({error: actionData.error});
     },
 
     /**
      * Updates current room list.
      *
      * @param {sharedActions.UpdateRoomList} actionData The action data.
      */
     updateRoomList: function(actionData) {
       this.setStoreState({
         error: undefined,
-        rooms: this._processRawRoomList(actionData.roomList)
+        rooms: this._processRoomList(actionData.roomList)
       });
     },
   }, Backbone.Events);
 
   loop.store.RoomListStore = RoomListStore;
 })();
--- a/browser/components/loop/test/desktop-local/panel_test.js
+++ b/browser/components/loop/test/desktop-local/panel_test.js
@@ -46,19 +46,20 @@ describe("loop.panel", function() {
       telemetryAdd: sinon.spy(),
       contacts: {
         getAll: function(callback) {
           callback(null, []);
         },
         on: sandbox.stub()
       },
       rooms: {
-        getAll: function(callback) {
+        getAll: function(version, callback) {
           callback(null, []);
-        }
+        },
+        on: sandbox.stub()
       }
     };
 
     document.mozL10n.initialize(navigator.mozLoop);
     // XXX prevent a race whenever mozL10n hasn't been initialized yet
     setTimeout(done, 0);
   });
 
@@ -620,17 +621,17 @@ describe("loop.panel", function() {
                                   true);
         });
 
       it("should notify the user when the operation failed", function() {
         fakeClient.requestCallUrl = function(_, cb) {
           cb("fake error");
         };
         sandbox.stub(notifications, "errorL10n");
-        var view = TestUtils.renderIntoDocument(loop.panel.CallUrlResult({
+        TestUtils.renderIntoDocument(loop.panel.CallUrlResult({
           notifications: notifications,
           client: fakeClient
         }));
 
         sinon.assert.calledOnce(notifications.errorL10n);
         sinon.assert.calledWithExactly(notifications.errorL10n,
                                        "unable_retrieve_url");
       });
@@ -699,41 +700,86 @@ describe("loop.panel", function() {
 
         TestUtils.SimulateNative.mouseOut(roomNode);
 
         expect(buttonNode.classList.contains("checked")).eql(false);
       });
   });
 
   describe("loop.panel.RoomList", function() {
-    var roomListStore, dispatcher;
+    var roomListStore, dispatcher, fakeEmail;
 
     beforeEach(function() {
+      fakeEmail = "fakeEmail@example.com";
       dispatcher = new loop.Dispatcher();
       roomListStore = new loop.store.RoomListStore({
         dispatcher: dispatcher,
         mozLoop: navigator.mozLoop
       });
+      roomListStore.setStoreState({
+        pendingCreation: false,
+        pendingInitialRetrieval: false,
+        rooms: [],
+        error: undefined
+      });
     });
 
     function createTestComponent() {
       return TestUtils.renderIntoDocument(loop.panel.RoomList({
         store: roomListStore,
-        dispatcher: dispatcher
+        dispatcher: dispatcher,
+        userDisplayName: fakeEmail
       }));
     }
 
     it("should dispatch a GetAllRooms action on mount", function() {
       var dispatch = sandbox.stub(dispatcher, "dispatch");
 
       createTestComponent();
 
       sinon.assert.calledOnce(dispatch);
       sinon.assert.calledWithExactly(dispatch, new sharedActions.GetAllRooms());
     });
+
+    it("should dispatch a CreateRoom action when clicking on the Start a " +
+       "conversation button",
+      function() {
+        navigator.mozLoop.userProfile = {email: fakeEmail};
+        var dispatch = sandbox.stub(dispatcher, "dispatch");
+        var view = createTestComponent();
+
+        TestUtils.Simulate.click(view.getDOMNode().querySelector("button"));
+
+        sinon.assert.calledWith(dispatch, new sharedActions.CreateRoom({
+          nameTemplate: "fakeText",
+          roomOwner: fakeEmail
+        }));
+      });
+
+    it("should disable the create button when a creation operation is ongoing",
+      function() {
+        var dispatch = sandbox.stub(dispatcher, "dispatch");
+        roomListStore.setStoreState({pendingCreation: true});
+
+        var view = createTestComponent();
+
+        var buttonNode = view.getDOMNode().querySelector("button[disabled]");
+        expect(buttonNode).to.not.equal(null);
+    });
+
+    it("should disable the create button when a list retrieval operation is pending",
+      function() {
+        var dispatch = sandbox.stub(dispatcher, "dispatch");
+        roomListStore.setStoreState({pendingInitialRetrieval: true});
+
+        var view = createTestComponent();
+
+        var buttonNode = view.getDOMNode().querySelector("button[disabled]");
+        expect(buttonNode).to.not.equal(null);
+    });
   });
 
   describe('loop.panel.ToSView', function() {
 
     it("should render when the value of loop.seenToS is not set", function() {
       var view = TestUtils.renderIntoDocument(loop.panel.ToSView());
 
       TestUtils.findRenderedDOMComponentWithClass(view, "terms-service");
--- a/browser/components/loop/test/shared/roomListStore_test.js
+++ b/browser/components/loop/test/shared/roomListStore_test.js
@@ -1,30 +1,55 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 var expect = chai.expect;
 
 describe("loop.store.Room", function () {
   "use strict";
+
   describe("#constructor", function() {
     it("should validate room values", function() {
       expect(function() {
         new loop.store.Room();
       }).to.Throw(Error, /missing required/);
     });
   });
 });
 
 describe("loop.store.RoomListStore", function () {
   "use strict";
 
   var sharedActions = loop.shared.actions;
   var sandbox, dispatcher;
 
+  var fakeRoomList = [{
+    roomToken: "_nxD4V4FflQ",
+    roomUrl: "http://sample/_nxD4V4FflQ",
+    roomName: "First Room Name",
+    maxSize: 2,
+    participants: [],
+    ctime: 1405517546
+  }, {
+    roomToken: "QzBbvGmIZWU",
+    roomUrl: "http://sample/QzBbvGmIZWU",
+    roomName: "Second Room Name",
+    maxSize: 2,
+    participants: [],
+    ctime: 1405517418
+  }, {
+    roomToken: "3jKS_Els9IU",
+    roomUrl: "http://sample/3jKS_Els9IU",
+    roomName: "Third Room Name",
+    maxSize: 3,
+    clientMaxSize: 2,
+    participants: [],
+    ctime: 1405518241
+  }];
+
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
     dispatcher = new loop.Dispatcher();
   });
 
   afterEach(function() {
     sandbox.restore();
   });
@@ -38,93 +63,267 @@ describe("loop.store.RoomListStore", fun
 
     it("should throw an error if mozLoop is missing", function() {
       expect(function() {
         new loop.store.RoomListStore({dispatcher: dispatcher});
       }).to.Throw(/mozLoop/);
     });
   });
 
-  describe("#getAllRooms", function() {
-    var store, fakeMozLoop;
-    var fakeRoomList = [{
-      roomToken: "_nxD4V4FflQ",
-      roomUrl: "http://sample/_nxD4V4FflQ",
-      roomName: "First Room Name",
-      maxSize: 2,
-      participants: [],
-      ctime: 1405517546
-    }, {
-      roomToken: "QzBbvGmIZWU",
-      roomUrl: "http://sample/QzBbvGmIZWU",
-      roomName: "Second Room Name",
-      maxSize: 2,
-      participants: [],
-      ctime: 1405517418
-    }, {
-      roomToken: "3jKS_Els9IU",
-      roomUrl: "http://sample/3jKS_Els9IU",
-      roomName: "Third Room Name",
-      maxSize: 3,
-      clientMaxSize: 2,
-      participants: [],
-      ctime: 1405518241
-    }];
+  describe("constructed", function() {
+    var fakeMozLoop, store;
 
     beforeEach(function() {
       fakeMozLoop = {
         rooms: {
-          getAll: function(cb) {
-            cb(null, fakeRoomList);
-          }
+          create: function() {},
+          getAll: function() {},
+          on: sandbox.stub()
         }
       };
       store = new loop.store.RoomListStore({
         dispatcher: dispatcher,
         mozLoop: fakeMozLoop
       });
+      store.setStoreState({
+        error: undefined,
+        pendingCreation: false,
+        pendingInitialRetrieval: false,
+        rooms: []
+      });
     });
 
-    it("should trigger a list:changed event", function(done) {
-      store.on("change", function() {
-        done();
+    describe("MozLoop rooms event listeners", function() {
+      beforeEach(function() {
+        _.extend(fakeMozLoop.rooms, Backbone.Events);
+
+        fakeMozLoop.rooms.getAll = function(version, cb) {
+          cb(null, fakeRoomList);
+        };
+
+        store.getAllRooms(); // registers event listeners
+      });
+
+      describe("add", function() {
+        it("should add the room entry to the list", function() {
+          fakeMozLoop.rooms.trigger("add", "add", {
+            roomToken: "newToken",
+            roomUrl: "http://sample/newToken",
+            roomName: "New room",
+            maxSize: 2,
+            participants: [],
+            ctime: 1405517546
+          });
+
+          expect(store.getStoreState().rooms).to.have.length.of(4);
+        });
       });
 
-      dispatcher.dispatch(new sharedActions.GetAllRooms());
+      describe("update", function() {
+        it("should update a room entry", function() {
+          fakeMozLoop.rooms.trigger("update", "update", {
+            roomToken: "_nxD4V4FflQ",
+            roomUrl: "http://sample/_nxD4V4FflQ",
+            roomName: "Changed First Room Name",
+            maxSize: 2,
+            participants: [],
+            ctime: 1405517546
+          });
+
+          expect(store.getStoreState().rooms).to.have.length.of(3);
+          expect(store.getStoreState().rooms.some(function(room) {
+            return room.roomName === "Changed First Room Name";
+          })).eql(true);
+        });
+      });
+
+      describe("remove", function() {
+        it("should remove a room from the list", function() {
+          fakeMozLoop.rooms.trigger("remove", "remove", {
+            roomToken: "_nxD4V4FflQ"
+          });
+
+          expect(store.getStoreState().rooms).to.have.length.of(2);
+          expect(store.getStoreState().rooms.some(function(room) {
+            return room.roomToken === "_nxD4V4FflQ";
+          })).eql(false);
+        });
+      });
+    });
+
+    describe("#findNextAvailableRoomNumber", function() {
+      var fakeNameTemplate = "RoomWord {{conversationLabel}}";
+
+      it("should find next available room number from an empty room list",
+        function() {
+          store.setStoreState({rooms: []});
+
+          expect(store.findNextAvailableRoomNumber(fakeNameTemplate)).eql(1);
+        });
+
+      it("should find next available room number from a non empty room list",
+        function() {
+          store.setStoreState({
+            rooms: [{roomName: "RoomWord 1"}]
+          });
+
+          expect(store.findNextAvailableRoomNumber(fakeNameTemplate)).eql(2);
+        });
+
+      it("should not be sensitive to initial list order", function() {
+        store.setStoreState({
+          rooms: [{roomName: "RoomWord 99"}, {roomName: "RoomWord 98"}]
+        });
+
+        expect(store.findNextAvailableRoomNumber(fakeNameTemplate)).eql(100);
+      });
     });
 
-    it("should fetch the room list from the mozLoop API", function(done) {
-      store.once("change", function() {
+    describe("#createRoom", function() {
+      var fakeNameTemplate = "Conversation {{conversationLabel}}";
+      var fakeLocalRoomId = "777";
+      var fakeOwner = "fake@invalid";
+      var fakeRoomCreationData = {
+        nameTemplate: fakeNameTemplate,
+        roomOwner: fakeOwner
+      };
+
+      var fakeCreatedRoom = {
+        roomName: "Conversation 1",
+        roomToken: "fake",
+        roomUrl: "http://invalid",
+        maxSize: 42,
+        participants: [],
+        ctime: 1234567890
+      };
+
+      beforeEach(function() {
+        store.setStoreState({pendingCreation: false, rooms: []});
+      });
+
+      it("should request creation of a new room", function() {
+        sandbox.stub(fakeMozLoop.rooms, "create");
+
+        store.createRoom(new sharedActions.CreateRoom(fakeRoomCreationData));
+
+        sinon.assert.calledWith(fakeMozLoop.rooms.create, {
+          roomName: "Conversation 1",
+          roomOwner: fakeOwner,
+          maxSize: store.maxRoomCreationSize,
+          expiresIn: store.defaultExpiresIn
+        });
+      });
+
+      it("should store any creation encountered error", function() {
+        var err = new Error("fake");
+        sandbox.stub(fakeMozLoop.rooms, "create", function(data, cb) {
+          cb(err);
+        });
+
+        store.createRoom(new sharedActions.CreateRoom(fakeRoomCreationData));
+
+        expect(store.getStoreState().error).eql(err);
+      });
+
+      it("should switch the pendingCreation state flag to true", function() {
+        sandbox.stub(fakeMozLoop.rooms, "create");
+
+        store.createRoom(new sharedActions.CreateRoom(fakeRoomCreationData));
+
+        expect(store.getStoreState().pendingCreation).eql(true);
+      });
+
+      it("should switch the pendingCreation state flag to false once the " +
+         "operation is done", function() {
+        sandbox.stub(fakeMozLoop.rooms, "create", function(data, cb) {
+          cb();
+        });
+
+        store.createRoom(new sharedActions.CreateRoom(fakeRoomCreationData));
+
+        expect(store.getStoreState().pendingCreation).eql(false);
+      });
+    });
+
+    describe("#setStoreState", function() {
+      it("should update store state data", function() {
+        store.setStoreState({pendingCreation: true});
+
+        expect(store.getStoreState().pendingCreation).eql(true);
+      });
+
+      it("should trigger a `change` event", function(done) {
+        store.once("change", function() {
+          done();
+        });
+
+        store.setStoreState({pendingCreation: true});
+      });
+    });
+
+    describe("#getAllRooms", function() {
+      it("should fetch the room list from the MozLoop API", function() {
+        fakeMozLoop.rooms.getAll = function(version, cb) {
+          cb(null, fakeRoomList);
+        };
+
+        store.getAllRooms(new sharedActions.GetAllRooms());
+
         expect(store.getStoreState().error).to.be.a.undefined;
         expect(store.getStoreState().rooms).to.have.length.of(3);
-        done();
       });
 
-      dispatcher.dispatch(new sharedActions.GetAllRooms());
-    });
+      it("should order the room list using ctime desc", function() {
+        fakeMozLoop.rooms.getAll = function(version, cb) {
+          cb(null, fakeRoomList);
+        };
 
-    it("should order the room list using ctime desc", function(done) {
-      store.once("change", function() {
+        store.getAllRooms(new sharedActions.GetAllRooms());
+
         var storeState = store.getStoreState();
         expect(storeState.error).to.be.a.undefined;
         expect(storeState.rooms[0].ctime).eql(1405518241);
         expect(storeState.rooms[1].ctime).eql(1405517546);
         expect(storeState.rooms[2].ctime).eql(1405517418);
-        done();
+      });
+
+      it("should report an error", function() {
+        var err = new Error("fake");
+        fakeMozLoop.rooms.getAll = function(version, cb) {
+          cb(err);
+        };
+
+        dispatcher.dispatch(new sharedActions.GetAllRooms());
+
+        expect(store.getStoreState().error).eql(err);
       });
 
-      dispatcher.dispatch(new sharedActions.GetAllRooms());
-    });
+      it("should register event listeners after the list is retrieved",
+        function() {
+          sandbox.stub(store, "startListeningToRoomEvents");
+          fakeMozLoop.rooms.getAll = function(version, cb) {
+            cb(null, fakeRoomList);
+          };
 
-    it("should report an error", function() {
-      fakeMozLoop.rooms.getAll = function(cb) {
-        cb("fakeError");
-      };
+          store.getAllRooms();
+
+          sinon.assert.calledOnce(store.startListeningToRoomEvents);
+        });
 
-      store.once("change", function() {
-        var storeState = store.getStoreState();
-        expect(storeState.error).eql("fakeError");
+      it("should set the pendingInitialRetrieval flag to true", function() {
+        store.getAllRooms();
+
+        expect(store.getStoreState().pendingInitialRetrieval).eql(true);
       });
 
-      dispatcher.dispatch(new sharedActions.GetAllRooms());
+      it("should set pendingInitialRetrieval to false once the action is " +
+         "performed", function() {
+        fakeMozLoop.rooms.getAll = function(version, cb) {
+          cb(null, fakeRoomList);
+        };
+
+        store.getAllRooms();
+
+        expect(store.getStoreState().pendingInitialRetrieval).eql(false);
+      });
     });
   });
 });
--- a/browser/components/loop/ui/fake-mozLoop.js
+++ b/browser/components/loop/ui/fake-mozLoop.js
@@ -60,14 +60,15 @@ navigator.mozLoop = {
   copyString: function() {},
   contacts: {
     getAll: function(callback) {
       callback(null, []);
     },
     on: function() {}
   },
   rooms: {
-    getAll: function(callback) {
+    getAll: function(version, callback) {
       callback(null, fakeRooms);
-    }
+    },
+    on: function() {}
   },
   fxAEnabled: true
 };
--- a/browser/components/loop/ui/ui-showcase.css
+++ b/browser/components/loop/ui/ui-showcase.css
@@ -11,38 +11,38 @@
 .showcase {
   min-width: 350px;
   max-width: 730px;
 }
 
 .showcase > header {
   position: fixed;
   top: 0;
-  background-color: #fbfbfb;
+  background-color: #fff;
   z-index: 100000;
   width: 100%;
   padding-bottom: 1em;
 }
 
 .showcase > header > h1,
 .showcase > section > h1 {
   font-size: 2em;
   font-weight: bold;
   margin: .5em 0;
 }
 
 .showcase-menu > a {
   margin-right: .5em;
-  padding: .4rem;
+  padding: .2rem;
   margin-top: .2rem;
 }
 
 .showcase > section {
   position: relative;
-  padding-top: 14em;
+  padding-top: 15em;
   clear: both;
 }
 
 .showcase > section > h1 {
   margin: 1em 0;
   border-bottom: 1px solid #aaa;
 }
 
@@ -64,18 +64,18 @@
   margin: 1.5em 0;
 }
 
 .showcase > section .example > h3 {
   font-size: 1.2em;
   font-weight: bold;
   border-bottom: 1px dashed #aaa;
   margin: 1em 0;
-  margin-top: -14em;
-  padding-top: 14em;
+  margin-top: -15em;
+  padding-top: 15em;
   text-align: left;
 }
 
 .showcase > section .example > h3 a {
   text-decoration: none;
   color: #555;
 }