Bug 1074672 Part 1 - Implement a room list view for Loop, r=mikedeboer.
authorNicolas Perriault <nperriault@gmail.com>
Wed, 08 Oct 2014 16:59:56 -0700
changeset 234196 c7a7563e5afe511da9ba072e10be7801d35db716
parent 234195 30d33a4fedc4c5ee7decce7974cc44d79163f83b
child 234197 dc4bdc7c32b6f69b36e1401355913a281d16c5b4
push id611
push userraliiev@mozilla.com
push dateMon, 05 Jan 2015 23:23:16 +0000
treeherdermozilla-release@345cd3b9c445 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmikedeboer
bugs1074672
milestone35.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 1074672 Part 1 - Implement a room list view for Loop, r=mikedeboer.
browser/app/profile/firefox.js
browser/components/loop/content/js/panel.js
browser/components/loop/content/js/panel.jsx
browser/components/loop/content/panel.html
browser/components/loop/content/shared/css/panel.css
browser/components/loop/content/shared/js/actions.js
browser/components/loop/content/shared/js/conversationStore.js
browser/components/loop/content/shared/js/roomListStore.js
browser/components/loop/jar.mn
browser/components/loop/test/desktop-local/index.html
browser/components/loop/test/desktop-local/panel_test.js
browser/components/loop/test/functional/test_1_browser_call.py
browser/components/loop/test/shared/index.html
browser/components/loop/test/shared/roomListStore_test.js
browser/components/loop/ui/fake-mozLoop.js
browser/components/loop/ui/index.html
browser/components/loop/ui/ui-showcase.css
browser/components/loop/ui/ui-showcase.js
browser/components/loop/ui/ui-showcase.jsx
browser/locales/en-US/chrome/browser/loop/loop.properties
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1618,16 +1618,17 @@ pref("loop.debug.websocket", false);
 pref("loop.debug.sdk", false);
 #ifdef DEBUG
 pref("loop.CSP", "default-src 'self' about: file: chrome: http://localhost:*; img-src 'self' data: http://www.gravatar.com/ about: file: chrome:; font-src 'none'; connect-src wss://*.tokbox.com https://*.opentok.com https://*.tokbox.com wss://*.mozilla.com https://*.mozilla.org wss://*.mozaws.net http://localhost:* ws://localhost:*");
 #else
 pref("loop.CSP", "default-src 'self' about: file: chrome:; img-src 'self' data: http://www.gravatar.com/ about: file: chrome:; font-src 'none'; connect-src wss://*.tokbox.com https://*.opentok.com https://*.tokbox.com wss://*.mozilla.com https://*.mozilla.org wss://*.mozaws.net");
 #endif
 pref("loop.oauth.google.redirect_uri", "urn:ietf:wg:oauth:2.0:oob:auto");
 pref("loop.oauth.google.scope", "https://www.google.com/m8/feeds");
+pref("loop.rooms.enabled", false);
 
 // serverURL to be assigned by services team
 pref("services.push.serverURL", "wss://push.services.mozilla.com/");
 
 pref("social.sidebar.unload_timeout_ms", 10000);
 
 pref("dom.identity.enabled", false);
 
--- a/browser/components/loop/content/js/panel.js
+++ b/browser/components/loop/content/js/panel.js
@@ -9,39 +9,55 @@
 
 var loop = loop || {};
 loop.panel = (function(_, mozL10n) {
   "use strict";
 
   var sharedViews = loop.shared.views;
   var sharedModels = loop.shared.models;
   var sharedMixins = loop.shared.mixins;
+  var sharedActions = loop.shared.actions;
   var Button = sharedViews.Button;
   var ButtonGroup = sharedViews.ButtonGroup;
   var ContactsList = loop.contacts.ContactsList;
   var ContactDetailsForm = loop.contacts.ContactDetailsForm;
   var __ = mozL10n.get; // aliasing translation function as __ for concision
 
   var TabView = React.createClass({displayName: 'TabView',
-    getInitialState: function() {
+    propTypes: {
+      buttonsHidden: React.PropTypes.bool,
+      // The selectedTab prop is used by the UI showcase.
+      selectedTab: React.PropTypes.string
+    },
+
+    getDefaultProps: function() {
       return {
+        buttonsHidden: false,
         selectedTab: "call"
       };
     },
 
+    getInitialState: function() {
+      return {selectedTab: this.props.selectedTab};
+    },
+
     handleSelectTab: function(event) {
       var tabName = event.target.dataset.tabName;
       this.setState({selectedTab: tabName});
     },
 
     render: function() {
       var cx = React.addons.classSet;
       var tabButtons = [];
       var tabs = [];
       React.Children.forEach(this.props.children, function(tab, i) {
+        // Filter out null tabs (eg. rooms when the feature is disabled)
+        if (!tab) {
+          return;
+        }
         var tabName = tab.props.name;
         var isSelected = (this.state.selectedTab == tabName);
         if (!tab.props.hidden) {
           tabButtons.push(
             React.DOM.li({className: cx({selected: isSelected}), 
                 key: i, 
                 'data-tab-name': tabName, 
                 onClick: this.handleSelectTab})
@@ -438,26 +454,145 @@ loop.panel = (function(_, mozL10n) {
         React.DOM.p({className: "user-identity"}, 
           this.props.displayName
         )
       );
     }
   });
 
   /**
+   * Room list entry.
+   */
+  var RoomEntry = React.createClass({displayName: 'RoomEntry',
+    propTypes: {
+      openRoom: React.PropTypes.func.isRequired,
+      room:     React.PropTypes.instanceOf(loop.store.Room).isRequired
+    },
+
+    shouldComponentUpdate: function(nextProps, nextState) {
+      return nextProps.room.ctime > this.props.room.ctime;
+    },
+
+    handleClickRoom: function(event) {
+      event.preventDefault();
+      this.props.openRoom(this.props.room);
+    },
+
+    _isActive: function() {
+      // XXX bug 1074679 will implement this properly
+      return this.props.room.currSize > 0;
+    },
+
+    render: function() {
+      var room = this.props.room;
+      var roomClasses = React.addons.classSet({
+        "room-entry": true,
+        "room-active": this._isActive()
+      });
+
+      return (
+        React.DOM.div({className: roomClasses}, 
+          React.DOM.h2(null, 
+            React.DOM.span({className: "room-notification"}), 
+            room.roomName
+          ), 
+          React.DOM.p(null, 
+            React.DOM.a({ref: "room", href: "#", onClick: this.handleClickRoom}, 
+              room.roomUrl
+            )
+          )
+        )
+      );
+    }
+  });
+
+  /**
+   * 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
+    },
+
+    getInitialState: function() {
+      var storeState = this.props.store.getStoreState();
+      return {
+        error: this.props.error || storeState.error,
+        rooms: this.props.rooms || storeState.rooms,
+      };
+    },
+
+    componentWillMount: function() {
+      this.listenTo(this.props.store, "change", this._onRoomListChanged);
+
+      this.props.dispatcher.dispatch(new sharedActions.GetAllRooms());
+    },
+
+    componentWillUnmount: function() {
+      this.stopListening(this.props.store);
+    },
+
+    _onRoomListChanged: function() {
+      var storeState = this.props.store.getStoreState();
+      this.setState({
+        error: storeState.error,
+        rooms: storeState.rooms
+      });
+    },
+
+    _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});
+    },
+
+    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);
+      }
+
+      return (
+        React.DOM.div({className: "room-list"}, 
+          React.DOM.h1(null, this._getListHeading()), 
+          
+            this.state.rooms.map(function(room, i) {
+              return RoomEntry({key: i, room: room, openRoom: this.openRoom});
+            }, this)
+          
+        )
+      );
+    }
+  });
+
+  /**
    * Panel view.
    */
   var PanelView = React.createClass({displayName: 'PanelView',
     propTypes: {
       notifications: React.PropTypes.object.isRequired,
       client: React.PropTypes.object.isRequired,
       // Mostly used for UI components showcase and unit tests
       callUrl: React.PropTypes.string,
       userProfile: React.PropTypes.object,
       showTabButtons: React.PropTypes.bool,
+      selectedTab: React.PropTypes.string,
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
+      roomListStore:
+        React.PropTypes.instanceOf(loop.store.RoomListStore).isRequired
     },
 
     getInitialState: function() {
       return {
         userProfile: this.props.userProfile || navigator.mozLoop.userProfile,
       };
     },
 
@@ -493,16 +628,32 @@ loop.panel = (function(_, mozL10n) {
       if (profile != this.state.userProfile) {
         // On profile change (login, logout), switch back to the default tab.
         this.selectTab("call");
       }
       this.setState({userProfile: profile});
       this.updateServiceErrors();
     },
 
+    /**
+     * The rooms feature is hidden by default for now. Once it gets mainstream,
+     * this method can be safely removed.
+     */
+    _renderRoomsTab: function() {
+      if (!navigator.mozLoop.getLoopBoolPref("rooms.enabled")) {
+        return null;
+      }
+      return (
+        Tab({name: "rooms"}, 
+          RoomList({dispatcher: this.props.dispatcher, 
+                    store: this.props.roomListStore})
+        )
+      );
+    },
+
     startForm: function(name, contact) {
       this.refs[name].initForm(contact);
       this.selectTab(name);
     },
 
     selectTab: function(name) {
       this.refs.tabView.setState({ selectedTab: name });
     },
@@ -522,25 +673,27 @@ loop.panel = (function(_, mozL10n) {
     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", buttonsHidden: !this.state.userProfile && !this.props.showTabButtons}, 
+          TabView({ref: "tabView", selectedTab: this.props.selectedTab, 
+            buttonsHidden: !this.state.userProfile && !this.props.showTabButtons}, 
             Tab({name: "call"}, 
               React.DOM.div({className: "content-area"}, 
                 CallUrlResult({client: this.props.client, 
                                notifications: this.props.notifications, 
                                callUrl: this.props.callUrl}), 
                 ToSView(null)
               )
             ), 
+            this._renderRoomsTab(), 
             Tab({name: "contacts"}, 
               ContactsList({selectTab: this.selectTab, 
                             startForm: this.startForm})
             ), 
             Tab({name: "contacts_add", hidden: true}, 
               ContactDetailsForm({ref: "contacts_add", mode: "add", 
                                   selectTab: this.selectTab})
             ), 
@@ -570,21 +723,29 @@ loop.panel = (function(_, mozL10n) {
    * Panel initialisation.
    */
   function init() {
     // Do the initial L10n setup, we do this before anything
     // else to ensure the L10n environment is setup correctly.
     mozL10n.initialize(navigator.mozLoop);
 
     var client = new loop.Client();
-    var notifications = new sharedModels.NotificationCollection()
+    var notifications = new sharedModels.NotificationCollection();
+    var dispatcher = new loop.Dispatcher();
+    var roomListStore = new loop.store.RoomListStore({
+      mozLoop: navigator.mozLoop,
+      dispatcher: dispatcher
+    });
 
     React.renderComponent(PanelView({
       client: client, 
-      notifications: notifications}), document.querySelector("#main"));
+      notifications: notifications, 
+      roomListStore: roomListStore, 
+      dispatcher: dispatcher}
+    ), document.querySelector("#main"));
 
     document.body.classList.add(loop.shared.utils.getTargetPlatform());
     document.body.setAttribute("dir", mozL10n.getDirection());
 
     // Notify the window that we've finished initalization and initial layout
     var evtObject = document.createEvent('Event');
     evtObject.initEvent('loopPanelInitialized', true, false);
     window.dispatchEvent(evtObject);
@@ -592,14 +753,15 @@ loop.panel = (function(_, mozL10n) {
 
   return {
     init: init,
     UserIdentity: UserIdentity,
     AuthLink: AuthLink,
     AvailabilityDropdown: AvailabilityDropdown,
     CallUrlResult: CallUrlResult,
     PanelView: PanelView,
+    RoomList: RoomList,
     SettingsDropdown: SettingsDropdown,
     ToSView: ToSView
   };
 })(_, document.mozL10n);
 
 document.addEventListener('DOMContentLoaded', loop.panel.init);
--- a/browser/components/loop/content/js/panel.jsx
+++ b/browser/components/loop/content/js/panel.jsx
@@ -9,39 +9,55 @@
 
 var loop = loop || {};
 loop.panel = (function(_, mozL10n) {
   "use strict";
 
   var sharedViews = loop.shared.views;
   var sharedModels = loop.shared.models;
   var sharedMixins = loop.shared.mixins;
+  var sharedActions = loop.shared.actions;
   var Button = sharedViews.Button;
   var ButtonGroup = sharedViews.ButtonGroup;
   var ContactsList = loop.contacts.ContactsList;
   var ContactDetailsForm = loop.contacts.ContactDetailsForm;
   var __ = mozL10n.get; // aliasing translation function as __ for concision
 
   var TabView = React.createClass({
-    getInitialState: function() {
+    propTypes: {
+      buttonsHidden: React.PropTypes.bool,
+      // The selectedTab prop is used by the UI showcase.
+      selectedTab: React.PropTypes.string
+    },
+
+    getDefaultProps: function() {
       return {
+        buttonsHidden: false,
         selectedTab: "call"
       };
     },
 
+    getInitialState: function() {
+      return {selectedTab: this.props.selectedTab};
+    },
+
     handleSelectTab: function(event) {
       var tabName = event.target.dataset.tabName;
       this.setState({selectedTab: tabName});
     },
 
     render: function() {
       var cx = React.addons.classSet;
       var tabButtons = [];
       var tabs = [];
       React.Children.forEach(this.props.children, function(tab, i) {
+        // Filter out null tabs (eg. rooms when the feature is disabled)
+        if (!tab) {
+          return;
+        }
         var tabName = tab.props.name;
         var isSelected = (this.state.selectedTab == tabName);
         if (!tab.props.hidden) {
           tabButtons.push(
             <li className={cx({selected: isSelected})}
                 key={i}
                 data-tab-name={tabName}
                 onClick={this.handleSelectTab} />
@@ -438,26 +454,145 @@ loop.panel = (function(_, mozL10n) {
         <p className="user-identity">
           {this.props.displayName}
         </p>
       );
     }
   });
 
   /**
+   * Room list entry.
+   */
+  var RoomEntry = React.createClass({
+    propTypes: {
+      openRoom: React.PropTypes.func.isRequired,
+      room:     React.PropTypes.instanceOf(loop.store.Room).isRequired
+    },
+
+    shouldComponentUpdate: function(nextProps, nextState) {
+      return nextProps.room.ctime > this.props.room.ctime;
+    },
+
+    handleClickRoom: function(event) {
+      event.preventDefault();
+      this.props.openRoom(this.props.room);
+    },
+
+    _isActive: function() {
+      // XXX bug 1074679 will implement this properly
+      return this.props.room.currSize > 0;
+    },
+
+    render: function() {
+      var room = this.props.room;
+      var roomClasses = React.addons.classSet({
+        "room-entry": true,
+        "room-active": this._isActive()
+      });
+
+      return (
+        <div className={roomClasses}>
+          <h2>
+            <span className="room-notification" />
+            {room.roomName}
+          </h2>
+          <p>
+            <a ref="room" href="#" onClick={this.handleClickRoom}>
+              {room.roomUrl}
+            </a>
+          </p>
+        </div>
+      );
+    }
+  });
+
+  /**
+   * 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
+    },
+
+    getInitialState: function() {
+      var storeState = this.props.store.getStoreState();
+      return {
+        error: this.props.error || storeState.error,
+        rooms: this.props.rooms || storeState.rooms,
+      };
+    },
+
+    componentWillMount: function() {
+      this.listenTo(this.props.store, "change", this._onRoomListChanged);
+
+      this.props.dispatcher.dispatch(new sharedActions.GetAllRooms());
+    },
+
+    componentWillUnmount: function() {
+      this.stopListening(this.props.store);
+    },
+
+    _onRoomListChanged: function() {
+      var storeState = this.props.store.getStoreState();
+      this.setState({
+        error: storeState.error,
+        rooms: storeState.rooms
+      });
+    },
+
+    _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});
+    },
+
+    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);
+      }
+
+      return (
+        <div className="room-list">
+          <h1>{this._getListHeading()}</h1>
+          {
+            this.state.rooms.map(function(room, i) {
+              return <RoomEntry key={i} room={room} openRoom={this.openRoom} />;
+            }, this)
+          }
+        </div>
+      );
+    }
+  });
+
+  /**
    * Panel view.
    */
   var PanelView = React.createClass({
     propTypes: {
       notifications: React.PropTypes.object.isRequired,
       client: React.PropTypes.object.isRequired,
       // Mostly used for UI components showcase and unit tests
       callUrl: React.PropTypes.string,
       userProfile: React.PropTypes.object,
       showTabButtons: React.PropTypes.bool,
+      selectedTab: React.PropTypes.string,
+      dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
+      roomListStore:
+        React.PropTypes.instanceOf(loop.store.RoomListStore).isRequired
     },
 
     getInitialState: function() {
       return {
         userProfile: this.props.userProfile || navigator.mozLoop.userProfile,
       };
     },
 
@@ -493,16 +628,32 @@ loop.panel = (function(_, mozL10n) {
       if (profile != this.state.userProfile) {
         // On profile change (login, logout), switch back to the default tab.
         this.selectTab("call");
       }
       this.setState({userProfile: profile});
       this.updateServiceErrors();
     },
 
+    /**
+     * The rooms feature is hidden by default for now. Once it gets mainstream,
+     * this method can be safely removed.
+     */
+    _renderRoomsTab: function() {
+      if (!navigator.mozLoop.getLoopBoolPref("rooms.enabled")) {
+        return null;
+      }
+      return (
+        <Tab name="rooms">
+          <RoomList dispatcher={this.props.dispatcher}
+                    store={this.props.roomListStore} />
+        </Tab>
+      );
+    },
+
     startForm: function(name, contact) {
       this.refs[name].initForm(contact);
       this.selectTab(name);
     },
 
     selectTab: function(name) {
       this.refs.tabView.setState({ selectedTab: name });
     },
@@ -522,25 +673,27 @@ loop.panel = (function(_, mozL10n) {
     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" buttonsHidden={!this.state.userProfile && !this.props.showTabButtons}>
+          <TabView ref="tabView" selectedTab={this.props.selectedTab}
+            buttonsHidden={!this.state.userProfile && !this.props.showTabButtons}>
             <Tab name="call">
               <div className="content-area">
                 <CallUrlResult client={this.props.client}
                                notifications={this.props.notifications}
                                callUrl={this.props.callUrl} />
                 <ToSView />
               </div>
             </Tab>
+            {this._renderRoomsTab()}
             <Tab name="contacts">
               <ContactsList selectTab={this.selectTab}
                             startForm={this.startForm} />
             </Tab>
             <Tab name="contacts_add" hidden={true}>
               <ContactDetailsForm ref="contacts_add" mode="add"
                                   selectTab={this.selectTab} />
             </Tab>
@@ -570,21 +723,29 @@ loop.panel = (function(_, mozL10n) {
    * Panel initialisation.
    */
   function init() {
     // Do the initial L10n setup, we do this before anything
     // else to ensure the L10n environment is setup correctly.
     mozL10n.initialize(navigator.mozLoop);
 
     var client = new loop.Client();
-    var notifications = new sharedModels.NotificationCollection()
+    var notifications = new sharedModels.NotificationCollection();
+    var dispatcher = new loop.Dispatcher();
+    var roomListStore = new loop.store.RoomListStore({
+      mozLoop: navigator.mozLoop,
+      dispatcher: dispatcher
+    });
 
     React.renderComponent(<PanelView
       client={client}
-      notifications={notifications} />, document.querySelector("#main"));
+      notifications={notifications}
+      roomListStore={roomListStore}
+      dispatcher={dispatcher}
+    />, document.querySelector("#main"));
 
     document.body.classList.add(loop.shared.utils.getTargetPlatform());
     document.body.setAttribute("dir", mozL10n.getDirection());
 
     // Notify the window that we've finished initalization and initial layout
     var evtObject = document.createEvent('Event');
     evtObject.initEvent('loopPanelInitialized', true, false);
     window.dispatchEvent(evtObject);
@@ -592,14 +753,15 @@ loop.panel = (function(_, mozL10n) {
 
   return {
     init: init,
     UserIdentity: UserIdentity,
     AuthLink: AuthLink,
     AvailabilityDropdown: AvailabilityDropdown,
     CallUrlResult: CallUrlResult,
     PanelView: PanelView,
+    RoomList: RoomList,
     SettingsDropdown: SettingsDropdown,
     ToSView: ToSView
   };
 })(_, document.mozL10n);
 
 document.addEventListener('DOMContentLoaded', loop.panel.init);
--- a/browser/components/loop/content/panel.html
+++ b/browser/components/loop/content/panel.html
@@ -20,13 +20,17 @@
     <script type="text/javascript" src="loop/shared/libs/jquery-2.1.0.js"></script>
     <script type="text/javascript" src="loop/shared/libs/lodash-2.4.1.js"></script>
     <script type="text/javascript" src="loop/shared/libs/backbone-1.1.2.js"></script>
 
     <script type="text/javascript" src="loop/shared/js/utils.js"></script>
     <script type="text/javascript" src="loop/shared/js/models.js"></script>
     <script type="text/javascript" src="loop/shared/js/mixins.js"></script>
     <script type="text/javascript" src="loop/shared/js/views.js"></script>
+    <script type="text/javascript" src="loop/shared/js/validate.js"></script>
+    <script type="text/javascript" src="loop/shared/js/actions.js"></script>
+    <script type="text/javascript" src="loop/shared/js/dispatcher.js"></script>
+    <script type="text/javascript" src="loop/shared/js/roomListStore.js"></script>
     <script type="text/javascript" src="loop/js/client.js"></script>
     <script type="text/javascript;version=1.8" src="loop/js/contacts.js"></script>
     <script type="text/javascript" src="loop/js/panel.js"></script>
  </body>
 </html>
--- a/browser/components/loop/content/shared/css/panel.css
+++ b/browser/components/loop/content/shared/css/panel.css
@@ -118,16 +118,80 @@ body {
   box-shadow: none;
 }
 
 .content-area input:not(.pristine):invalid {
   border-color: #d74345;
   box-shadow: 0 0 4px #c43c3e;
 }
 
+/* Rooms */
+.room-list {
+  background: #f5f5f5;
+}
+
+.room-list > h1 {
+  font-weight: bold;
+  color: #999;
+  padding: .5rem 1rem;
+  border-bottom: 1px solid #ddd;
+}
+
+.room-list > .room-entry {
+  padding: 1rem 1rem 0 .5rem;
+}
+
+.room-list > .room-entry > h2 {
+  font-size: .85rem;
+  color: #777;
+}
+
+.room-list > .room-entry.room-active > h2 {
+  font-weight: bold;
+  color: #000;
+}
+
+.room-list > .room-entry > h2 > .room-notification {
+  display: inline-block;
+  background: transparent;
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  margin-right: .3rem;
+}
+
+.room-list > .room-entry.room-active > h2 > .room-notification {
+  background-color: #00a0ec;
+}
+
+.room-list > .room-entry:hover {
+  background: #f1f1f1;
+}
+
+.room-list > .room-entry:not(:last-child) {
+  border-bottom: 1px solid #ddd;
+}
+
+.room-list > .room-entry > p {
+  margin: 0;
+  padding: .2em 0 1rem .8rem;
+}
+
+.room-list > .room-entry > p > a {
+  color: #777;
+  opacity: .5;
+  transition: opacity .1s ease-in-out 0s;
+  text-decoration: none;
+}
+
+.room-list > .room-entry > p > a:hover {
+  opacity: 1;
+  text-decoration: underline;
+}
+
 /* Buttons */
 
 .button-group {
   display: flex;
   flex-direction: row;
   width: 100%;
 }
 
--- a/browser/components/loop/content/shared/js/actions.js
+++ b/browser/components/loop/content/shared/js/actions.js
@@ -113,11 +113,18 @@ loop.shared.actions = (function() {
     /**
      * Used to mute or unmute a stream
      */
     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
+    }),
+
+    /**
+     * Retrieves room list.
+     * XXX: should move to some roomActions module - refs bug 1079284
+     */
+    GetAllRooms: Action.define("getAllRooms", {
     })
   };
 })();
--- a/browser/components/loop/content/shared/js/conversationStore.js
+++ b/browser/components/loop/content/shared/js/conversationStore.js
@@ -1,42 +1,43 @@
 /* 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/. */
 
 /* global loop:true */
 
 var loop = loop || {};
-loop.store = (function() {
+loop.store = loop.store || {};
 
+loop.store.ConversationStore = (function() {
   var sharedActions = loop.shared.actions;
   var CALL_TYPES = loop.shared.utils.CALL_TYPES;
 
   /**
    * Websocket states taken from:
    * https://docs.services.mozilla.com/loop/apis.html#call-progress-state-change-progress
    */
-  var WS_STATES = {
+  var WS_STATES = loop.store.WS_STATES = {
     // The call is starting, and the remote party is not yet being alerted.
     INIT: "init",
     // The called party is being alerted.
     ALERTING: "alerting",
     // The call is no longer being set up and has been aborted for some reason.
     TERMINATED: "terminated",
     // The called party has indicated that he has answered the call,
     // but the media is not yet confirmed.
     CONNECTING: "connecting",
     // One of the two parties has indicated successful media set up,
     // but the other has not yet.
     HALF_CONNECTED: "half-connected",
     // Both endpoints have reported successfully establishing media.
     CONNECTED: "connected"
   };
 
-  var CALL_STATES = {
+  var CALL_STATES = loop.store.CALL_STATES = {
     // The initial state of the view.
     INIT: "cs-init",
     // The store is gathering the call data from the server.
     GATHER: "cs-gather",
     // The initial data has been gathered, the websocket is connecting, or has
     // connected, and waiting for the other side to connect to the server.
     CONNECTING: "cs-connecting",
     // The websocket has received information that we're now alerting
@@ -47,17 +48,16 @@ loop.store = (function() {
     // The call ended successfully.
     FINISHED: "cs-finished",
     // The user has finished with the window.
     CLOSE: "cs-close",
     // The call was terminated due to an issue during connection.
     TERMINATED: "cs-terminated"
   };
 
-
   var ConversationStore = Backbone.Model.extend({
     defaults: {
       // The current state of the call
       callState: CALL_STATES.INIT,
       // The reason if a call was terminated
       callStateReason: undefined,
       // The error information, if there was a failure
       error: undefined,
@@ -397,14 +397,10 @@ loop.store = (function() {
           break;
         }
       }
 
       this.dispatcher.dispatch(action);
     }
   });
 
-  return {
-    CALL_STATES: CALL_STATES,
-    ConversationStore: ConversationStore,
-    WS_STATES: WS_STATES
-  };
+  return ConversationStore;
 })();
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/js/roomListStore.js
@@ -0,0 +1,171 @@
+/* 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/. */
+
+/* global loop:true */
+
+var loop = loop || {};
+loop.store = loop.store || {};
+
+(function() {
+  "use strict";
+
+  /**
+   * Room validation schema. See validate.js.
+   * @type {Object}
+   */
+  var roomSchema = {
+    roomToken: String,
+    roomUrl:   String,
+    roomName:  String,
+    maxSize:   Number,
+    currSize:  Number,
+    ctime:     Number
+  };
+
+  /**
+   * Temporary sample raw room list data.
+   * XXX Should be removed when we plug the real mozLoop API for rooms.
+   *     See bug 1074664.
+   * @type {Array}
+   */
+  var temporaryRawRoomList = [{
+    roomToken: "_nxD4V4FflQ",
+    roomUrl: "http://sample/_nxD4V4FflQ",
+    roomName: "First Room Name",
+    maxSize: 2,
+    currSize: 0,
+    ctime: 1405517546
+  }, {
+    roomToken: "QzBbvGmIZWU",
+    roomUrl: "http://sample/QzBbvGmIZWU",
+    roomName: "Second Room Name",
+    maxSize: 2,
+    currSize: 0,
+    ctime: 1405517418
+  }, {
+    roomToken: "3jKS_Els9IU",
+    roomUrl: "http://sample/3jKS_Els9IU",
+    roomName: "Third Room Name",
+    maxSize: 3,
+    clientMaxSize: 2,
+    currSize: 1,
+    ctime: 1405518241
+  }];
+
+  /**
+   * Room type. Basically acts as a typed object constructor.
+   *
+   * @param {Object} values Room property values.
+   */
+  function Room(values) {
+    var validatedData = new loop.validate.Validator(roomSchema || {})
+                                         .validate(values || {});
+    for (var prop in validatedData) {
+      this[prop] = validatedData[prop];
+    }
+  }
+
+  loop.store.Room = Room;
+
+  /**
+   * Room store.
+   *
+   * Options:
+   * - {loop.Dispatcher} dispatcher The dispatcher for dispatching actions and
+   *                                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;
+
+    if (!options.mozLoop) {
+      throw new Error("Missing option mozLoop");
+    }
+    this.mozLoop = options.mozLoop;
+
+    this.dispatcher.register(this, [
+      "getAllRooms",
+      "openRoom"
+    ]);
+  }
+
+  RoomListStore.prototype = _.extend({
+    /**
+     * Retrieves current store state.
+     *
+     * @return {Object}
+     */
+    getStoreState: function() {
+      return this.storeState;
+    },
+
+    /**
+     * Updates store states and trigger a "change" event.
+     *
+     * @param {Object} state The new store state.
+     */
+    setStoreState: function(state) {
+      this.storeState = state;
+      this.trigger("change");
+    },
+
+    /**
+     * Proxy to navigator.mozLoop.rooms.getAll.
+     * XXX Could probably be removed when bug 1074664 lands.
+     *
+     * @param  {Function} cb Callback(error, roomList)
+     */
+    _fetchRoomList: function(cb) {
+      // Faking this.mozLoop.rooms until it's available; bug 1074664.
+      if (!this.mozLoop.hasOwnProperty("rooms")) {
+        cb(null, temporaryRawRoomList);
+        return;
+      }
+      this.mozLoop.rooms.getAll(cb);
+    },
+
+    /**
+     * Maps and sorts the raw room list received from the mozLoop API.
+     *
+     * @param  {Array} rawRoomList Raw room list.
+     * @return {Array}
+     */
+    _processRawRoomList: function(rawRoomList) {
+      if (!rawRoomList) {
+        return [];
+      }
+      return rawRoomList
+        .map(function(rawRoom) {
+          return new Room(rawRoom);
+        })
+        .slice()
+        .sort(function(a, b) {
+          return b.ctime - a.ctime;
+        });
+    },
+
+    /**
+     * Gather the list of all available rooms from the MozLoop API.
+     */
+    getAllRooms: function() {
+      this._fetchRoomList(function(err, rawRoomList) {
+        this.setStoreState({
+          error: err,
+          rooms: this._processRawRoomList(rawRoomList)
+        });
+      }.bind(this));
+    }
+  }, Backbone.Events);
+
+  loop.store.RoomListStore = RoomListStore;
+})();
--- a/browser/components/loop/jar.mn
+++ b/browser/components/loop/jar.mn
@@ -50,16 +50,17 @@ browser.jar:
   content/browser/loop/shared/img/audio-call-avatar.svg         (content/shared/img/audio-call-avatar.svg)
   content/browser/loop/shared/img/icons-10x10.svg               (content/shared/img/icons-10x10.svg)
   content/browser/loop/shared/img/icons-14x14.svg               (content/shared/img/icons-14x14.svg)
   content/browser/loop/shared/img/icons-16x16.svg               (content/shared/img/icons-16x16.svg)
 
   # Shared scripts
   content/browser/loop/shared/js/actions.js           (content/shared/js/actions.js)
   content/browser/loop/shared/js/conversationStore.js (content/shared/js/conversationStore.js)
+  content/browser/loop/shared/js/roomListStore.js     (content/shared/js/roomListStore.js)
   content/browser/loop/shared/js/dispatcher.js        (content/shared/js/dispatcher.js)
   content/browser/loop/shared/js/feedbackApiClient.js (content/shared/js/feedbackApiClient.js)
   content/browser/loop/shared/js/models.js            (content/shared/js/models.js)
   content/browser/loop/shared/js/mixins.js            (content/shared/js/mixins.js)
   content/browser/loop/shared/js/otSdkDriver.js       (content/shared/js/otSdkDriver.js)
   content/browser/loop/shared/js/views.js             (content/shared/js/views.js)
   content/browser/loop/shared/js/utils.js             (content/shared/js/utils.js)
   content/browser/loop/shared/js/validate.js          (content/shared/js/validate.js)
--- a/browser/components/loop/test/desktop-local/index.html
+++ b/browser/components/loop/test/desktop-local/index.html
@@ -38,16 +38,17 @@
   <script src="../../content/shared/js/models.js"></script>
   <script src="../../content/shared/js/mixins.js"></script>
   <script src="../../content/shared/js/views.js"></script>
   <script src="../../content/shared/js/websocket.js"></script>
   <script src="../../content/shared/js/actions.js"></script>
   <script src="../../content/shared/js/validate.js"></script>
   <script src="../../content/shared/js/dispatcher.js"></script>
   <script src="../../content/shared/js/otSdkDriver.js"></script>
+  <script src="../../content/shared/js/roomListStore.js"></script>
   <script src="../../content/js/client.js"></script>
   <script src="../../content/js/conversationViews.js"></script>
   <script src="../../content/js/conversation.js"></script>
   <script type="text/javascript;version=1.8" src="../../content/js/contacts.js"></script>
   <script src="../../content/js/panel.js"></script>
 
   <!-- Test scripts -->
   <script src="client_test.js"></script>
--- a/browser/components/loop/test/desktop-local/panel_test.js
+++ b/browser/components/loop/test/desktop-local/panel_test.js
@@ -2,23 +2,24 @@
  * 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/. */
 
 /*jshint newcap:false*/
 /*global loop, sinon */
 
 var expect = chai.expect;
 var TestUtils = React.addons.TestUtils;
+var sharedActions = loop.shared.actions;
 
 describe("loop.panel", function() {
   "use strict";
 
   var sandbox, notifications, fakeXHR, requests = [];
 
-  beforeEach(function() {
+  beforeEach(function(done) {
     sandbox = sinon.sandbox.create();
     fakeXHR = sandbox.useFakeXMLHttpRequest();
     requests = [];
     // https://github.com/cjohansen/Sinon.JS/issues/393
     fakeXHR.xhr.onCreate = function (xhr) {
       requests.push(xhr);
     };
     notifications = new loop.shared.models.NotificationCollection();
@@ -27,31 +28,37 @@ describe("loop.panel", function() {
       doNotDisturb: true,
       fxAEnabled: true,
       getStrings: function() {
         return JSON.stringify({textContent: "fakeText"});
       },
       get locale() {
         return "en-US";
       },
+      getLoopBoolPref: sandbox.stub(),
       setLoopCharPref: sandbox.stub(),
       getLoopCharPref: sandbox.stub().returns("unseen"),
+      getPluralForm: function() {
+        return "fakeText";
+      },
       copyString: sandbox.stub(),
       noteCallUrlExpiry: sinon.spy(),
       composeEmail: sinon.spy(),
       telemetryAdd: sinon.spy(),
       contacts: {
         getAll: function(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);
   });
 
   afterEach(function() {
     delete navigator.mozLoop;
     sandbox.restore();
   });
 
   describe("#init", function() {
@@ -121,64 +128,129 @@ describe("loop.panel", function() {
         TestUtils.Simulate.click(availableMenuOption);
 
         expect(view.state.showMenu).eql(true);
       });
     });
   });
 
   describe("loop.panel.PanelView", function() {
-    var fakeClient, callUrlData, view, callTab, contactsTab;
+    var fakeClient, dispatcher, roomListStore, callUrlData;
 
     beforeEach(function() {
       callUrlData = {
         callUrl: "http://call.invalid/",
         expiresAt: 1000
       };
 
       fakeClient = {
         requestCallUrl: function(_, cb) {
           cb(null, callUrlData);
         }
       };
 
-      view = TestUtils.renderIntoDocument(loop.panel.PanelView({
+      dispatcher = new loop.Dispatcher();
+      roomListStore = new loop.store.RoomListStore({
+        dispatcher: dispatcher,
+        mozLoop: navigator.mozLoop
+      });
+    });
+
+    function createTestPanelView() {
+      return TestUtils.renderIntoDocument(loop.panel.PanelView({
         notifications: notifications,
         client: fakeClient,
         showTabButtons: true,
+        dispatcher: dispatcher,
+        roomListStore: roomListStore
       }));
-
-      [callTab, contactsTab] =
-        TestUtils.scryRenderedDOMComponentsWithClass(view, "tab");
-    });
+    }
 
     describe('TabView', function() {
-      it("should select contacts tab when clicking tab button", function() {
-        TestUtils.Simulate.click(
-          view.getDOMNode().querySelector('li[data-tab-name="contacts"]'));
+      var view, callTab, roomsTab, contactsTab;
+
+      describe("loop.rooms.enabled on", function() {
+        beforeEach(function() {
+          navigator.mozLoop.getLoopBoolPref = function(pref) {
+            if (pref === "rooms.enabled") {
+              return true;
+            }
+          };
+
+          view = createTestPanelView();
+
+          [callTab, roomsTab, contactsTab] =
+            TestUtils.scryRenderedDOMComponentsWithClass(view, "tab");
+        });
+
+        it("should select contacts tab when clicking tab button", function() {
+          TestUtils.Simulate.click(
+            view.getDOMNode().querySelector("li[data-tab-name=\"contacts\"]"));
 
-        expect(contactsTab.getDOMNode().classList.contains("selected"))
-          .to.be.true;
+          expect(contactsTab.getDOMNode().classList.contains("selected"))
+            .to.be.true;
+        });
+
+        it("should select rooms tab when clicking tab button", function() {
+          TestUtils.Simulate.click(
+            view.getDOMNode().querySelector("li[data-tab-name=\"rooms\"]"));
+
+          expect(roomsTab.getDOMNode().classList.contains("selected"))
+            .to.be.true;
+        });
+
+        it("should select call tab when clicking tab button", function() {
+          TestUtils.Simulate.click(
+            view.getDOMNode().querySelector("li[data-tab-name=\"call\"]"));
+
+          expect(callTab.getDOMNode().classList.contains("selected"))
+            .to.be.true;
+        });
       });
 
-      it("should select call tab when clicking tab button", function() {
-        TestUtils.Simulate.click(
-          view.getDOMNode().querySelector('li[data-tab-name="call"]'));
+      describe("loop.rooms.enabled off", function() {
+        beforeEach(function() {
+          navigator.mozLoop.getLoopBoolPref = function(pref) {
+            if (pref === "rooms.enabled") {
+              return false;
+            }
+          };
+
+          view = createTestPanelView();
+
+          [callTab, contactsTab] =
+            TestUtils.scryRenderedDOMComponentsWithClass(view, "tab");
+        });
 
-        expect(callTab.getDOMNode().classList.contains("selected"))
-          .to.be.true;
+        it("should select contacts tab when clicking tab button", function() {
+          TestUtils.Simulate.click(
+            view.getDOMNode().querySelector("li[data-tab-name=\"contacts\"]"));
+
+          expect(contactsTab.getDOMNode().classList.contains("selected"))
+            .to.be.true;
+        });
+
+        it("should select call tab when clicking tab button", function() {
+          TestUtils.Simulate.click(
+            view.getDOMNode().querySelector("li[data-tab-name=\"call\"]"));
+
+          expect(callTab.getDOMNode().classList.contains("selected"))
+            .to.be.true;
+        });
       });
     });
 
     describe("AuthLink", function() {
       it("should trigger the FxA sign in/up process when clicking the link",
         function() {
           navigator.mozLoop.loggedInToFxA = false;
           navigator.mozLoop.logInToFxA = sandbox.stub();
 
+          var view = createTestPanelView();
+
           TestUtils.Simulate.click(
             view.getDOMNode().querySelector(".signin-link a"));
 
           sinon.assert.calledOnce(navigator.mozLoop.logInToFxA);
         });
 
       it("should be hidden if FxA is not enabled",
         function() {
@@ -188,18 +260,16 @@ describe("loop.panel", function() {
       });
 
       afterEach(function() {
         navigator.mozLoop.fxAEnabled = true;
       });
     });
 
     describe("SettingsDropdown", function() {
-      var view;
-
       beforeEach(function() {
         navigator.mozLoop.logInToFxA = sandbox.stub();
         navigator.mozLoop.logOutFromFxA = sandbox.stub();
         navigator.mozLoop.openFxASettings = sandbox.stub();
       });
 
       afterEach(function() {
         navigator.mozLoop.fxAEnabled = true;
@@ -283,16 +353,18 @@ describe("loop.panel", function() {
           view.getDOMNode().querySelector(".icon-signout"));
 
         sinon.assert.calledOnce(navigator.mozLoop.logOutFromFxA);
       });
     });
 
     describe("#render", function() {
       it("should render a ToSView", function() {
+        var view = createTestPanelView();
+
         TestUtils.findRenderedComponentWithType(view, loop.panel.ToSView);
       });
     });
   });
 
   describe("loop.panel.CallUrlResult", function() {
     var fakeClient, callUrlData, view;
 
@@ -545,16 +617,44 @@ describe("loop.panel", function() {
 
         sinon.assert.calledOnce(notifications.errorL10n);
         sinon.assert.calledWithExactly(notifications.errorL10n,
                                        "unable_retrieve_url");
       });
     });
   });
 
+  describe("loop.panel.RoomList", function() {
+    var roomListStore, dispatcher;
+
+    beforeEach(function() {
+      dispatcher = new loop.Dispatcher();
+      roomListStore = new loop.store.RoomListStore({
+        dispatcher: dispatcher,
+        mozLoop: navigator.mozLoop
+      });
+    });
+
+    function createTestComponent() {
+      return TestUtils.renderIntoDocument(loop.panel.RoomList({
+        store: roomListStore,
+        dispatcher: dispatcher
+      }));
+    }
+
+    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());
+    });
+  });
+
   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/functional/test_1_browser_call.py
+++ b/browser/components/loop/test/functional/test_1_browser_call.py
@@ -126,22 +126,22 @@ class Test1BrowserCall(MarionetteTestCas
         # expect a video container on desktop side
         video = self.wait_for_element_displayed(By.CLASS_NAME, "media")
         self.assertEqual(video.tag_name, "div", "expect a video container")
 
     def hangup_call_and_verify_feedback(self):
         self.marionette.set_context("chrome")
         button = self.marionette.find_element(By.CLASS_NAME, "btn-hangup")
 
-        # XXX For whatever reason, the click doesn't take effect unless we
-        # wait for a bit (even if we wait for the element to actually be
-        # displayed first, which we're not currently bothering with).  It's
-        # not entirely clear whether the click is being delivered in this case,
-        # or whether there's a Marionette bug here.
-        sleep(2)
+        # XXX bug 1080095 For whatever reason, the click doesn't take effect
+        # unless we wait for a bit (even if we wait for the element to
+        # actually be displayed first, which we're not currently bothering
+        # with).  It's not entirely clear whether the click is being
+        # delivered in this case, or whether there's a Marionette bug here.
+        sleep(5)
         button.click()
 
         # check that the feedback form is displayed
         feedback_form = self.wait_for_element_displayed(By.CLASS_NAME, "faces")
         self.assertEqual(feedback_form.tag_name, "div", "expect feedback form")
 
     def test_1_browser_call(self):
         self.switch_to_panel()
--- a/browser/components/loop/test/shared/index.html
+++ b/browser/components/loop/test/shared/index.html
@@ -39,27 +39,29 @@
   <script src="../../content/shared/js/views.js"></script>
   <script src="../../content/shared/js/websocket.js"></script>
   <script src="../../content/shared/js/feedbackApiClient.js"></script>
   <script src="../../content/shared/js/validate.js"></script>
   <script src="../../content/shared/js/actions.js"></script>
   <script src="../../content/shared/js/dispatcher.js"></script>
   <script src="../../content/shared/js/otSdkDriver.js"></script>
   <script src="../../content/shared/js/conversationStore.js"></script>
+  <script src="../../content/shared/js/roomListStore.js"></script>
 
   <!-- Test scripts -->
   <script src="models_test.js"></script>
   <script src="mixins_test.js"></script>
   <script src="utils_test.js"></script>
   <script src="views_test.js"></script>
   <script src="websocket_test.js"></script>
   <script src="feedbackApiClient_test.js"></script>
   <script src="validate_test.js"></script>
   <script src="dispatcher_test.js"></script>
   <script src="conversationStore_test.js"></script>
   <script src="otSdkDriver_test.js"></script>
+  <script src="roomListStore_test.js"></script>
   <script>
     mocha.run(function () {
       $("#mocha").append("<p id='complete'>Complete.</p>");
     });
   </script>
 </body>
 </html>
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/shared/roomListStore_test.js
@@ -0,0 +1,130 @@
+/* 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;
+
+  beforeEach(function() {
+    sandbox = sinon.sandbox.create();
+    dispatcher = new loop.Dispatcher();
+  });
+
+  afterEach(function() {
+    sandbox.restore();
+  });
+
+  describe("#constructor", function() {
+    it("should throw an error if the dispatcher is missing", function() {
+      expect(function() {
+        new loop.store.RoomListStore({mozLoop: {}});
+      }).to.Throw(/dispatcher/);
+    });
+
+    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,
+      currSize: 0,
+      ctime: 1405517546
+    }, {
+      roomToken: "QzBbvGmIZWU",
+      roomUrl: "http://sample/QzBbvGmIZWU",
+      roomName: "Second Room Name",
+      maxSize: 2,
+      currSize: 0,
+      ctime: 1405517418
+    }, {
+      roomToken: "3jKS_Els9IU",
+      roomUrl: "http://sample/3jKS_Els9IU",
+      roomName: "Third Room Name",
+      maxSize: 3,
+      clientMaxSize: 2,
+      currSize: 1,
+      ctime: 1405518241
+    }];
+
+    beforeEach(function() {
+      fakeMozLoop = {
+        rooms: {
+          getAll: function(cb) {
+            cb(null, fakeRoomList);
+          }
+        }
+      };
+      store = new loop.store.RoomListStore({
+        dispatcher: dispatcher,
+        mozLoop: fakeMozLoop
+      });
+    });
+
+    it("should trigger a list:changed event", function(done) {
+      store.on("change", function() {
+        done();
+      });
+
+      dispatcher.dispatch(new sharedActions.GetAllRooms());
+    });
+
+    it("should fetch the room list from the mozLoop API", function(done) {
+      store.once("change", function() {
+        expect(store.getStoreState().error).to.be.a.null;
+        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(done) {
+      store.once("change", function() {
+        var storeState = store.getStoreState();
+        expect(storeState.error).to.be.a.null;
+        expect(storeState.rooms[0].ctime).eql(1405518241);
+        expect(storeState.rooms[1].ctime).eql(1405517546);
+        expect(storeState.rooms[2].ctime).eql(1405517418);
+        done();
+      });
+
+      dispatcher.dispatch(new sharedActions.GetAllRooms());
+    });
+
+    it("should report an error", function() {
+      fakeMozLoop.rooms.getAll = function(cb) {
+        cb("fakeError");
+      };
+
+      store.once("change", function() {
+        var storeState = store.getStoreState();
+        expect(storeState.error).eql("fakeError");
+      });
+
+      dispatcher.dispatch(new sharedActions.GetAllRooms());
+    });
+  });
+});
--- a/browser/components/loop/ui/fake-mozLoop.js
+++ b/browser/components/loop/ui/fake-mozLoop.js
@@ -4,17 +4,22 @@
 
 /**
  * Faking the mozLoop object which doesn't exist in regular web pages.
  * @type {Object}
  */
 navigator.mozLoop = {
   ensureRegistered: function() {},
   getLoopCharPref: function() {},
-  getLoopBoolPref: function() {},
+  getLoopBoolPref: function(pref) {
+    // Ensure UI for rooms is displayed in the showcase.
+    if (pref === "rooms.enabled") {
+      return true;
+    }
+  },
   releaseCallData: function() {},
   contacts: {
     getAll: function(callback) {
       callback(null, []);
     },
     on: function() {}
   }
 };
--- a/browser/components/loop/ui/index.html
+++ b/browser/components/loop/ui/index.html
@@ -33,17 +33,20 @@
     <script src="../content/shared/libs/backbone-1.1.2.js"></script>
     <script src="../content/shared/js/feedbackApiClient.js"></script>
     <script src="../content/shared/js/actions.js"></script>
     <script src="../content/shared/js/utils.js"></script>
     <script src="../content/shared/js/models.js"></script>
     <script src="../content/shared/js/mixins.js"></script>
     <script src="../content/shared/js/views.js"></script>
     <script src="../content/shared/js/websocket.js"></script>
+    <script src="../content/shared/js/validate.js"></script>
+    <script src="../content/shared/js/dispatcher.js"></script>
     <script src="../content/shared/js/conversationStore.js"></script>
+    <script src="../content/shared/js/roomListStore.js"></script>
     <script src="../content/js/conversationViews.js"></script>
     <script src="../content/js/client.js"></script>
     <script src="../standalone/content/js/webapp.js"></script>
     <script type="text/javascript;version=1.8" src="../content/js/contacts.js"></script>
     <script>
       if (!loop.contacts) {
         // For browsers that don't support ES6 without special flags (all but Fx
         // at the moment), we shim the contacts namespace with its most barebone
--- a/browser/components/loop/ui/ui-showcase.css
+++ b/browser/components/loop/ui/ui-showcase.css
@@ -64,19 +64,26 @@
   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;
   text-align: left;
 }
 
+.showcase > section .example > h3 a {
+  text-decoration: none;
+  color: #555;
+}
+
 .showcase p.note {
   margin: 0;
   padding: 0;
   color: #666;
   font-style: italic;
 }
 
 .override-position * {
--- a/browser/components/loop/ui/ui-showcase.js
+++ b/browser/components/loop/ui/ui-showcase.js
@@ -51,16 +51,22 @@
   // Feedback API client configured to send data to the stage input server,
   // which is available at https://input.allizom.org
   var stageFeedbackApiClient = new loop.FeedbackAPIClient(
     "https://input.allizom.org/api/v1/feedback", {
       product: "Loop"
     }
   );
 
+  var dispatcher = new loop.Dispatcher();
+  var roomListStore = new loop.store.RoomListStore({
+    dispatcher: dispatcher,
+    mozLoop: {}
+  });
+
   // Local mocks
 
   var mockContact = {
     name: ["Mr Smith"],
     email: [{
       value: "smith@invalid.com"
     }]
   };
@@ -88,21 +94,28 @@
   errNotifications.add({
     level: "error",
     message: "Could Not Authenticate",
     details: "Did you change your password?",
     detailsButtonLabel: "Retry",
   });
 
   var Example = React.createClass({displayName: 'Example',
+    makeId: function(prefix) {
+      return (prefix || "") + this.props.summary.toLowerCase().replace(/\s/g, "-");
+    },
+
     render: function() {
       var cx = React.addons.classSet;
       return (
         React.DOM.div({className: "example"}, 
-          React.DOM.h3(null, this.props.summary), 
+          React.DOM.h3({id: this.makeId()}, 
+            this.props.summary, 
+            React.DOM.a({href: this.makeId("#")}, " ¶")
+          ), 
           React.DOM.div({className: cx({comp: true, dashed: this.props.dashed}), 
                style: this.props.style || {}}, 
             this.props.children
           )
         )
       );
     }
   });
@@ -145,36 +158,55 @@
       return (
         ShowCase(null, 
           Section({name: "PanelView"}, 
             React.DOM.p({className: "note"}, 
               React.DOM.strong(null, "Note:"), " 332px wide."
             ), 
             Example({summary: "Call URL retrieved", dashed: "true", style: {width: "332px"}}, 
               PanelView({client: mockClient, notifications: notifications, 
-                         callUrl: "http://invalid.example.url/"})
+                         callUrl: "http://invalid.example.url/", 
+                         dispatcher: dispatcher, 
+                         roomListStore: roomListStore})
             ), 
             Example({summary: "Call URL retrieved - authenticated", dashed: "true", style: {width: "332px"}}, 
               PanelView({client: mockClient, notifications: notifications, 
                          callUrl: "http://invalid.example.url/", 
-                         userProfile: {email: "test@example.com"}})
+                         userProfile: {email: "test@example.com"}, 
+                         dispatcher: dispatcher, 
+                         roomListStore: roomListStore})
             ), 
             Example({summary: "Pending call url retrieval", dashed: "true", style: {width: "332px"}}, 
-              PanelView({client: mockClient, notifications: notifications})
+              PanelView({client: mockClient, notifications: notifications, 
+                         dispatcher: dispatcher, 
+                         roomListStore: roomListStore})
             ), 
             Example({summary: "Pending call url retrieval - authenticated", dashed: "true", style: {width: "332px"}}, 
               PanelView({client: mockClient, notifications: notifications, 
-                         userProfile: {email: "test@example.com"}})
+                         userProfile: {email: "test@example.com"}, 
+                         dispatcher: dispatcher, 
+                         roomListStore: roomListStore})
             ), 
             Example({summary: "Error Notification", dashed: "true", style: {width: "332px"}}, 
-              PanelView({client: mockClient, notifications: errNotifications})
+              PanelView({client: mockClient, notifications: errNotifications, 
+                         dispatcher: dispatcher, 
+                         roomListStore: roomListStore})
             ), 
             Example({summary: "Error Notification - authenticated", dashed: "true", style: {width: "332px"}}, 
               PanelView({client: mockClient, notifications: errNotifications, 
-                         userProfile: {email: "test@example.com"}})
+                         userProfile: {email: "test@example.com"}, 
+                         dispatcher: dispatcher, 
+                         roomListStore: roomListStore})
+            ), 
+            Example({summary: "Room list tab", dashed: "true", style: {width: "332px"}}, 
+              PanelView({client: mockClient, notifications: notifications, 
+                         userProfile: {email: "test@example.com"}, 
+                         dispatcher: dispatcher, 
+                         roomListStore: roomListStore, 
+                         selectedTab: "rooms"})
             )
           ), 
 
           Section({name: "IncomingCallView"}, 
             Example({summary: "Default / incoming video call", dashed: "true", style: {width: "260px", height: "254px"}}, 
               React.DOM.div({className: "fx-embedded"}, 
                 IncomingCallView({model: mockConversationModel, 
                                   video: true})
@@ -242,41 +274,45 @@
                                      publishStream: noop})
               )
             )
           ), 
 
           Section({name: "PendingConversationView"}, 
             Example({summary: "Pending conversation view (connecting)", dashed: "true"}, 
               React.DOM.div({className: "standalone"}, 
-                PendingConversationView({websocket: mockWebSocket})
+                PendingConversationView({websocket: mockWebSocket, 
+                                         dispatcher: dispatcher})
               )
             ), 
             Example({summary: "Pending conversation view (ringing)", dashed: "true"}, 
               React.DOM.div({className: "standalone"}, 
-                PendingConversationView({websocket: mockWebSocket, callState: "ringing"})
+                PendingConversationView({websocket: mockWebSocket, 
+                                         dispatcher: dispatcher, 
+                                         callState: "ringing"})
               )
             )
           ), 
 
           Section({name: "PendingConversationView (Desktop)"}, 
             Example({summary: "Connecting", dashed: "true", 
                      style: {width: "260px", height: "265px"}}, 
               React.DOM.div({className: "fx-embedded"}, 
                 DesktopPendingConversationView({callState: "gather", 
-                                                contact: mockContact})
+                                                contact: mockContact, 
+                                                dispatcher: dispatcher})
               )
             )
           ), 
 
           Section({name: "CallFailedView"}, 
             Example({summary: "Call Failed", dashed: "true", 
                      style: {width: "260px", height: "265px"}}, 
               React.DOM.div({className: "fx-embedded"}, 
-                CallFailedView(null)
+                CallFailedView({dispatcher: dispatcher})
               )
             )
           ), 
 
           Section({name: "StartConversationView"}, 
             Example({summary: "Start conversation view", dashed: "true"}, 
               React.DOM.div({className: "standalone"}, 
                 StartConversationView({conversation: mockConversationModel, 
--- a/browser/components/loop/ui/ui-showcase.jsx
+++ b/browser/components/loop/ui/ui-showcase.jsx
@@ -51,16 +51,22 @@
   // Feedback API client configured to send data to the stage input server,
   // which is available at https://input.allizom.org
   var stageFeedbackApiClient = new loop.FeedbackAPIClient(
     "https://input.allizom.org/api/v1/feedback", {
       product: "Loop"
     }
   );
 
+  var dispatcher = new loop.Dispatcher();
+  var roomListStore = new loop.store.RoomListStore({
+    dispatcher: dispatcher,
+    mozLoop: {}
+  });
+
   // Local mocks
 
   var mockContact = {
     name: ["Mr Smith"],
     email: [{
       value: "smith@invalid.com"
     }]
   };
@@ -88,21 +94,28 @@
   errNotifications.add({
     level: "error",
     message: "Could Not Authenticate",
     details: "Did you change your password?",
     detailsButtonLabel: "Retry",
   });
 
   var Example = React.createClass({
+    makeId: function(prefix) {
+      return (prefix || "") + this.props.summary.toLowerCase().replace(/\s/g, "-");
+    },
+
     render: function() {
       var cx = React.addons.classSet;
       return (
         <div className="example">
-          <h3>{this.props.summary}</h3>
+          <h3 id={this.makeId()}>
+            {this.props.summary}
+            <a href={this.makeId("#")}>&nbsp;¶</a>
+          </h3>
           <div className={cx({comp: true, dashed: this.props.dashed})}
                style={this.props.style || {}}>
             {this.props.children}
           </div>
         </div>
       );
     }
   });
@@ -145,36 +158,55 @@
       return (
         <ShowCase>
           <Section name="PanelView">
             <p className="note">
               <strong>Note:</strong> 332px wide.
             </p>
             <Example summary="Call URL retrieved" dashed="true" style={{width: "332px"}}>
               <PanelView client={mockClient} notifications={notifications}
-                         callUrl="http://invalid.example.url/" />
+                         callUrl="http://invalid.example.url/"
+                         dispatcher={dispatcher}
+                         roomListStore={roomListStore} />
             </Example>
             <Example summary="Call URL retrieved - authenticated" dashed="true" style={{width: "332px"}}>
               <PanelView client={mockClient} notifications={notifications}
                          callUrl="http://invalid.example.url/"
-                         userProfile={{email: "test@example.com"}} />
+                         userProfile={{email: "test@example.com"}}
+                         dispatcher={dispatcher}
+                         roomListStore={roomListStore} />
             </Example>
             <Example summary="Pending call url retrieval" dashed="true" style={{width: "332px"}}>
-              <PanelView client={mockClient} notifications={notifications} />
+              <PanelView client={mockClient} notifications={notifications}
+                         dispatcher={dispatcher}
+                         roomListStore={roomListStore} />
             </Example>
             <Example summary="Pending call url retrieval - authenticated" dashed="true" style={{width: "332px"}}>
               <PanelView client={mockClient} notifications={notifications}
-                         userProfile={{email: "test@example.com"}} />
+                         userProfile={{email: "test@example.com"}}
+                         dispatcher={dispatcher}
+                         roomListStore={roomListStore} />
             </Example>
             <Example summary="Error Notification" dashed="true" style={{width: "332px"}}>
-              <PanelView client={mockClient} notifications={errNotifications}/>
+              <PanelView client={mockClient} notifications={errNotifications}
+                         dispatcher={dispatcher}
+                         roomListStore={roomListStore} />
             </Example>
             <Example summary="Error Notification - authenticated" dashed="true" style={{width: "332px"}}>
               <PanelView client={mockClient} notifications={errNotifications}
-                         userProfile={{email: "test@example.com"}} />
+                         userProfile={{email: "test@example.com"}}
+                         dispatcher={dispatcher}
+                         roomListStore={roomListStore} />
+            </Example>
+            <Example summary="Room list tab" dashed="true" style={{width: "332px"}}>
+              <PanelView client={mockClient} notifications={notifications}
+                         userProfile={{email: "test@example.com"}}
+                         dispatcher={dispatcher}
+                         roomListStore={roomListStore}
+                         selectedTab="rooms" />
             </Example>
           </Section>
 
           <Section name="IncomingCallView">
             <Example summary="Default / incoming video call" dashed="true" style={{width: "260px", height: "254px"}}>
               <div className="fx-embedded">
                 <IncomingCallView model={mockConversationModel}
                                   video={true} />
@@ -242,41 +274,45 @@
                                      publishStream={noop} />
               </Example>
             </div>
           </Section>
 
           <Section name="PendingConversationView">
             <Example summary="Pending conversation view (connecting)" dashed="true">
               <div className="standalone">
-                <PendingConversationView websocket={mockWebSocket}/>
+                <PendingConversationView websocket={mockWebSocket}
+                                         dispatcher={dispatcher} />
               </div>
             </Example>
             <Example summary="Pending conversation view (ringing)" dashed="true">
               <div className="standalone">
-                <PendingConversationView websocket={mockWebSocket} callState="ringing"/>
+                <PendingConversationView websocket={mockWebSocket}
+                                         dispatcher={dispatcher}
+                                         callState="ringing"/>
               </div>
             </Example>
           </Section>
 
           <Section name="PendingConversationView (Desktop)">
             <Example summary="Connecting" dashed="true"
                      style={{width: "260px", height: "265px"}}>
               <div className="fx-embedded">
                 <DesktopPendingConversationView callState={"gather"}
-                                                contact={mockContact} />
+                                                contact={mockContact}
+                                                dispatcher={dispatcher} />
               </div>
             </Example>
           </Section>
 
           <Section name="CallFailedView">
             <Example summary="Call Failed" dashed="true"
                      style={{width: "260px", height: "265px"}}>
               <div className="fx-embedded">
-                <CallFailedView />
+                <CallFailedView dispatcher={dispatcher} />
               </div>
             </Example>
           </Section>
 
           <Section name="StartConversationView">
             <Example summary="Start conversation view" dashed="true">
               <div className="standalone">
                 <StartConversationView conversation={mockConversationModel}
--- a/browser/locales/en-US/chrome/browser/loop/loop.properties
+++ b/browser/locales/en-US/chrome/browser/loop/loop.properties
@@ -267,8 +267,13 @@ feedback_back_button=Back
 feedback_window_will_close_in2=This window will close in {{countdown}} second;This window will close in {{countdown}} seconds
 ## LOCALIZATION_NOTE (feedback_rejoin_button): Displayed on the feedback form after
 ## a signed-in to signed-in user call.
 ## https://people.mozilla.org/~dhenein/labs/loop-mvp-spec/#feedback
 feedback_rejoin_button=Rejoin
 ## LOCALIZATION NOTE (feedback_report_user_button): Used to report a user in the case of
 ## an abusive user.
 feedback_report_user_button=Report User
+
+## LOCALIZATION NOTE (rooms_list_current_conversations): We prefer to have no
+## number in the string, but if you need it for your language please use {{num}}.
+rooms_list_current_conversations=Current conversation;Current conversations
+rooms_list_no_current_conversations=No current conversations