Bug 1074680 - Create a Loop room, r=Standard8. a=loop-only
authorNicolas Perriault <nperriault@gmail.com>
Fri, 31 Oct 2014 16:28:33 +0100
changeset 226012 d61af365c9a2852d864d49fe2630ac1676a1b821
parent 226011 72635769caad8fda5825173042dc44dbdfeed5cb
child 226013 ed92dd8406328b4ba6671756075580ac100870e5
push id7239
push userrjesup@wgate.com
push dateMon, 10 Nov 2014 18:49:04 +0000
treeherdermozilla-aurora@eb1a1082bbc8 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersStandard8, loop-only
bugs1074680
milestone35.0a2
Bug 1074680 - Create a Loop room, r=Standard8. a=loop-only
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;
 }