diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js index b70b84891cdb..cd1488b4d960 100644 --- a/browser/app/profile/firefox.js +++ b/browser/app/profile/firefox.js @@ -1623,6 +1623,7 @@ pref("loop.CSP", "default-src 'self' about: file: chrome:; img-src 'self' data: #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/"); diff --git a/browser/components/loop/content/js/panel.js b/browser/components/loop/content/js/panel.js index 122ce62b578e..23ab5b4b5bf3 100644 --- a/browser/components/loop/content/js/panel.js +++ b/browser/components/loop/content/js/panel.js @@ -14,6 +14,7 @@ loop.panel = (function(_, mozL10n) { 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; @@ -21,12 +22,23 @@ loop.panel = (function(_, mozL10n) { 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}); @@ -37,6 +49,10 @@ loop.panel = (function(_, mozL10n) { 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) { @@ -442,6 +458,121 @@ loop.panel = (function(_, mozL10n) { } }); + /** + * 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. */ @@ -453,6 +584,10 @@ loop.panel = (function(_, mozL10n) { 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() { @@ -498,6 +633,22 @@ loop.panel = (function(_, mozL10n) { 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); @@ -527,7 +678,8 @@ loop.panel = (function(_, mozL10n) { 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, @@ -536,6 +688,7 @@ loop.panel = (function(_, mozL10n) { ToSView(null) ) ), + this._renderRoomsTab(), Tab({name: "contacts"}, ContactsList({selectTab: this.selectTab, startForm: this.startForm}) @@ -575,11 +728,19 @@ loop.panel = (function(_, mozL10n) { 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()); @@ -597,6 +758,7 @@ loop.panel = (function(_, mozL10n) { AvailabilityDropdown: AvailabilityDropdown, CallUrlResult: CallUrlResult, PanelView: PanelView, + RoomList: RoomList, SettingsDropdown: SettingsDropdown, ToSView: ToSView }; diff --git a/browser/components/loop/content/js/panel.jsx b/browser/components/loop/content/js/panel.jsx index cd7c5a7b42f8..3fe901b6e450 100644 --- a/browser/components/loop/content/js/panel.jsx +++ b/browser/components/loop/content/js/panel.jsx @@ -14,6 +14,7 @@ loop.panel = (function(_, mozL10n) { 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; @@ -21,12 +22,23 @@ loop.panel = (function(_, mozL10n) { 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}); @@ -37,6 +49,10 @@ loop.panel = (function(_, mozL10n) { 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) { @@ -442,6 +458,121 @@ loop.panel = (function(_, mozL10n) { } }); + /** + * 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 ( +
+

+ + {room.roomName} +

+

+ + {room.roomUrl} + +

+
+ ); + } + }); + + /** + * 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 ( +
+

{this._getListHeading()}

+ { + this.state.rooms.map(function(room, i) { + return ; + }, this) + } +
+ ); + } + }); + /** * Panel view. */ @@ -453,6 +584,10 @@ loop.panel = (function(_, mozL10n) { 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() { @@ -498,6 +633,22 @@ loop.panel = (function(_, mozL10n) { 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 ( + + + + ); + }, + startForm: function(name, contact) { this.refs[name].initForm(contact); this.selectTab(name); @@ -527,7 +678,8 @@ loop.panel = (function(_, mozL10n) {
- +
+ {this._renderRoomsTab()} @@ -575,11 +728,19 @@ loop.panel = (function(_, mozL10n) { 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(, 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()); @@ -597,6 +758,7 @@ loop.panel = (function(_, mozL10n) { AvailabilityDropdown: AvailabilityDropdown, CallUrlResult: CallUrlResult, PanelView: PanelView, + RoomList: RoomList, SettingsDropdown: SettingsDropdown, ToSView: ToSView }; diff --git a/browser/components/loop/content/panel.html b/browser/components/loop/content/panel.html index b169ace11226..2e19a2c9a834 100644 --- a/browser/components/loop/content/panel.html +++ b/browser/components/loop/content/panel.html @@ -25,6 +25,10 @@ + + + + diff --git a/browser/components/loop/content/shared/css/panel.css b/browser/components/loop/content/shared/css/panel.css index 2e4a90bbd3aa..0ee995a7f491 100644 --- a/browser/components/loop/content/shared/css/panel.css +++ b/browser/components/loop/content/shared/css/panel.css @@ -123,6 +123,70 @@ body { 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 { diff --git a/browser/components/loop/content/shared/js/actions.js b/browser/components/loop/content/shared/js/actions.js index ca2525a653ce..b77f65d6e6ec 100644 --- a/browser/components/loop/content/shared/js/actions.js +++ b/browser/components/loop/content/shared/js/actions.js @@ -118,6 +118,13 @@ loop.shared.actions = (function() { 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", { }) }; })(); diff --git a/browser/components/loop/content/shared/js/conversationStore.js b/browser/components/loop/content/shared/js/conversationStore.js index 9b4049fa73a3..2551e74af26e 100644 --- a/browser/components/loop/content/shared/js/conversationStore.js +++ b/browser/components/loop/content/shared/js/conversationStore.js @@ -5,8 +5,9 @@ /* 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; @@ -14,7 +15,7 @@ loop.store = (function() { * 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. @@ -31,7 +32,7 @@ loop.store = (function() { 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. @@ -52,7 +53,6 @@ loop.store = (function() { TERMINATED: "cs-terminated" }; - var ConversationStore = Backbone.Model.extend({ defaults: { // The current state of the call @@ -402,9 +402,5 @@ loop.store = (function() { } }); - return { - CALL_STATES: CALL_STATES, - ConversationStore: ConversationStore, - WS_STATES: WS_STATES - }; + return ConversationStore; })(); diff --git a/browser/components/loop/content/shared/js/roomListStore.js b/browser/components/loop/content/shared/js/roomListStore.js new file mode 100644 index 000000000000..2871f630d3cf --- /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; +})(); diff --git a/browser/components/loop/jar.mn b/browser/components/loop/jar.mn index 53838e4b9039..635d85708949 100644 --- a/browser/components/loop/jar.mn +++ b/browser/components/loop/jar.mn @@ -55,6 +55,7 @@ browser.jar: # 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) diff --git a/browser/components/loop/test/desktop-local/index.html b/browser/components/loop/test/desktop-local/index.html index c7618e26bf72..ef710643e3fb 100644 --- a/browser/components/loop/test/desktop-local/index.html +++ b/browser/components/loop/test/desktop-local/index.html @@ -43,6 +43,7 @@ + diff --git a/browser/components/loop/test/desktop-local/panel_test.js b/browser/components/loop/test/desktop-local/panel_test.js index dc5355f356a6..d37dc1f28211 100644 --- a/browser/components/loop/test/desktop-local/panel_test.js +++ b/browser/components/loop/test/desktop-local/panel_test.js @@ -7,13 +7,14 @@ 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 = []; @@ -32,8 +33,12 @@ describe("loop.panel", function() { 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(), @@ -47,6 +52,8 @@ describe("loop.panel", function() { }; document.mozL10n.initialize(navigator.mozLoop); + // XXX prevent a race whenever mozL10n hasn't been initialized yet + setTimeout(done, 0); }); afterEach(function() { @@ -126,7 +133,7 @@ describe("loop.panel", function() { }); describe("loop.panel.PanelView", function() { - var fakeClient, callUrlData, view, callTab, contactsTab; + var fakeClient, dispatcher, roomListStore, callUrlData; beforeEach(function() { callUrlData = { @@ -140,31 +147,94 @@ describe("loop.panel", function() { } }; - 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; - expect(contactsTab.getDOMNode().classList.contains("selected")) - .to.be.true; + 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; + }); + + 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; + } + }; - expect(callTab.getDOMNode().classList.contains("selected")) - .to.be.true; + view = createTestPanelView(); + + [callTab, 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; + }); + + 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; + }); }); }); @@ -174,6 +244,8 @@ describe("loop.panel", function() { navigator.mozLoop.loggedInToFxA = false; navigator.mozLoop.logInToFxA = sandbox.stub(); + var view = createTestPanelView(); + TestUtils.Simulate.click( view.getDOMNode().querySelector(".signin-link a")); @@ -193,8 +265,6 @@ describe("loop.panel", function() { }); describe("SettingsDropdown", function() { - var view; - beforeEach(function() { navigator.mozLoop.logInToFxA = sandbox.stub(); navigator.mozLoop.logOutFromFxA = sandbox.stub(); @@ -288,6 +358,8 @@ describe("loop.panel", function() { describe("#render", function() { it("should render a ToSView", function() { + var view = createTestPanelView(); + TestUtils.findRenderedComponentWithType(view, loop.panel.ToSView); }); }); @@ -550,6 +622,34 @@ describe("loop.panel", function() { }); }); + 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() { diff --git a/browser/components/loop/test/functional/test_1_browser_call.py b/browser/components/loop/test/functional/test_1_browser_call.py index 952e4da3a789..06f610caf8a4 100644 --- a/browser/components/loop/test/functional/test_1_browser_call.py +++ b/browser/components/loop/test/functional/test_1_browser_call.py @@ -131,12 +131,12 @@ class Test1BrowserCall(MarionetteTestCase): 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 diff --git a/browser/components/loop/test/shared/index.html b/browser/components/loop/test/shared/index.html index f11056db7445..28d29aa1371e 100644 --- a/browser/components/loop/test/shared/index.html +++ b/browser/components/loop/test/shared/index.html @@ -44,6 +44,7 @@ + @@ -56,6 +57,7 @@ + + + + diff --git a/browser/components/loop/ui/ui-showcase.css b/browser/components/loop/ui/ui-showcase.css index fc77da4da2ef..4f56411d5498 100644 --- a/browser/components/loop/ui/ui-showcase.css +++ b/browser/components/loop/ui/ui-showcase.css @@ -69,9 +69,16 @@ 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; diff --git a/browser/components/loop/ui/ui-showcase.js b/browser/components/loop/ui/ui-showcase.js index 086877789721..68fc3ec258e3 100644 --- a/browser/components/loop/ui/ui-showcase.js +++ b/browser/components/loop/ui/ui-showcase.js @@ -56,6 +56,12 @@ } ); + var dispatcher = new loop.Dispatcher(); + var roomListStore = new loop.store.RoomListStore({ + dispatcher: dispatcher, + mozLoop: {} + }); + // Local mocks var mockContact = { @@ -93,11 +99,18 @@ }); 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 @@ -150,26 +163,45 @@ ), 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"}) ) ), @@ -247,12 +279,15 @@ 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"}) ) ) ), @@ -262,7 +297,8 @@ style: {width: "260px", height: "265px"}}, React.DOM.div({className: "fx-embedded"}, DesktopPendingConversationView({callState: "gather", - contact: mockContact}) + contact: mockContact, + dispatcher: dispatcher}) ) ) ), @@ -271,7 +307,7 @@ Example({summary: "Call Failed", dashed: "true", style: {width: "260px", height: "265px"}}, React.DOM.div({className: "fx-embedded"}, - CallFailedView(null) + CallFailedView({dispatcher: dispatcher}) ) ) ), diff --git a/browser/components/loop/ui/ui-showcase.jsx b/browser/components/loop/ui/ui-showcase.jsx index 21216a8d1449..f642005c3a5d 100644 --- a/browser/components/loop/ui/ui-showcase.jsx +++ b/browser/components/loop/ui/ui-showcase.jsx @@ -56,6 +56,12 @@ } ); + var dispatcher = new loop.Dispatcher(); + var roomListStore = new loop.store.RoomListStore({ + dispatcher: dispatcher, + mozLoop: {} + }); + // Local mocks var mockContact = { @@ -93,11 +99,18 @@ }); var Example = React.createClass({ + makeId: function(prefix) { + return (prefix || "") + this.props.summary.toLowerCase().replace(/\s/g, "-"); + }, + render: function() { var cx = React.addons.classSet; return (
-

{this.props.summary}

+

+ {this.props.summary} +  ¶ +

{this.props.children} @@ -150,26 +163,45 @@

+ callUrl="http://invalid.example.url/" + dispatcher={dispatcher} + roomListStore={roomListStore} /> + userProfile={{email: "test@example.com"}} + dispatcher={dispatcher} + roomListStore={roomListStore} /> - + + userProfile={{email: "test@example.com"}} + dispatcher={dispatcher} + roomListStore={roomListStore} /> - + + userProfile={{email: "test@example.com"}} + dispatcher={dispatcher} + roomListStore={roomListStore} /> + + + @@ -247,12 +279,15 @@
- +
- +
@@ -262,7 +297,8 @@ style={{width: "260px", height: "265px"}}>
+ contact={mockContact} + dispatcher={dispatcher} />
@@ -271,7 +307,7 @@
- +
diff --git a/browser/locales/en-US/chrome/browser/loop/loop.properties b/browser/locales/en-US/chrome/browser/loop/loop.properties index 468cd5376129..4dc621ff1892 100644 --- a/browser/locales/en-US/chrome/browser/loop/loop.properties +++ b/browser/locales/en-US/chrome/browser/loop/loop.properties @@ -272,3 +272,8 @@ 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