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 209661 c7a7563e5afe511da9ba072e10be7801d35db716
parent 209660 30d33a4fedc4c5ee7decce7974cc44d79163f83b
child 209662 dc4bdc7c32b6f69b36e1401355913a281d16c5b4
push id1
push userroot
push dateMon, 20 Oct 2014 17:29:22 +0000
reviewersmikedeboer
bugs1074672
milestone35.0a1
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