Bug 1086512 - Added feedback form to Loop desktop room window. r=Standard8

This commit is contained in:
Nicolas Perriault 2014-11-25 13:19:34 +01:00
parent 728a1cc90d
commit 4fc8bdf986
23 changed files with 162 additions and 98 deletions

View File

@ -223,6 +223,8 @@ loop.conversation = (function(mozL10n) {
* At the moment, it does more than that, these parts need refactoring out.
*/
var IncomingConversationView = React.createClass({displayName: 'IncomingConversationView',
mixins: [sharedMixins.AudioMixin],
propTypes: {
client: React.PropTypes.instanceOf(loop.Client).isRequired,
conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
@ -230,7 +232,8 @@ loop.conversation = (function(mozL10n) {
sdk: React.PropTypes.object.isRequired,
conversationAppStore: React.PropTypes.instanceOf(
loop.store.ConversationAppStore).isRequired,
feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
feedbackStore:
React.PropTypes.instanceOf(loop.store.FeedbackStore).isRequired
},
getInitialState: function() {
@ -302,6 +305,8 @@ loop.conversation = (function(mozL10n) {
document.title = mozL10n.get("conversation_has_ended");
this.play("terminated");
return (
sharedViews.FeedbackView({
feedbackStore: this.props.feedbackStore,
@ -552,7 +557,8 @@ loop.conversation = (function(mozL10n) {
.isRequired,
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
roomStore: React.PropTypes.instanceOf(loop.store.RoomStore),
feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
feedbackStore:
React.PropTypes.instanceOf(loop.store.FeedbackStore).isRequired
},
getInitialState: function() {

View File

@ -223,6 +223,8 @@ loop.conversation = (function(mozL10n) {
* At the moment, it does more than that, these parts need refactoring out.
*/
var IncomingConversationView = React.createClass({
mixins: [sharedMixins.AudioMixin],
propTypes: {
client: React.PropTypes.instanceOf(loop.Client).isRequired,
conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
@ -230,7 +232,8 @@ loop.conversation = (function(mozL10n) {
sdk: React.PropTypes.object.isRequired,
conversationAppStore: React.PropTypes.instanceOf(
loop.store.ConversationAppStore).isRequired,
feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
feedbackStore:
React.PropTypes.instanceOf(loop.store.FeedbackStore).isRequired
},
getInitialState: function() {
@ -302,6 +305,8 @@ loop.conversation = (function(mozL10n) {
document.title = mozL10n.get("conversation_has_ended");
this.play("terminated");
return (
<sharedViews.FeedbackView
feedbackStore={this.props.feedbackStore}
@ -552,7 +557,8 @@ loop.conversation = (function(mozL10n) {
.isRequired,
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
roomStore: React.PropTypes.instanceOf(loop.store.RoomStore),
feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
feedbackStore:
React.PropTypes.instanceOf(loop.store.FeedbackStore).isRequired
},
getInitialState: function() {

View File

@ -428,6 +428,8 @@ loop.conversationViews = (function(mozL10n) {
* the different views that need displaying.
*/
var OutgoingConversationView = React.createClass({displayName: 'OutgoingConversationView',
mixins: [sharedMixins.AudioMixin],
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
store: React.PropTypes.instanceOf(
@ -493,6 +495,7 @@ loop.conversationViews = (function(mozL10n) {
);
}
case CALL_STATES.FINISHED: {
this.play("terminated");
return this._renderFeedbackView();
}
case CALL_STATES.INIT: {

View File

@ -428,6 +428,8 @@ loop.conversationViews = (function(mozL10n) {
* the different views that need displaying.
*/
var OutgoingConversationView = React.createClass({
mixins: [sharedMixins.AudioMixin],
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
store: React.PropTypes.instanceOf(
@ -493,6 +495,7 @@ loop.conversationViews = (function(mozL10n) {
);
}
case CALL_STATES.FINISHED: {
this.play("terminated");
return this._renderFeedbackView();
}
case CALL_STATES.INIT: {

View File

@ -16,8 +16,6 @@ loop.roomViews = (function(mozL10n) {
var ROOM_STATES = loop.store.ROOM_STATES;
var sharedViews = loop.shared.views;
function noop() {}
/**
* ActiveRoomStore mixin.
* @type {Object}
@ -140,7 +138,9 @@ loop.roomViews = (function(mozL10n) {
],
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
feedbackStore:
React.PropTypes.instanceOf(loop.store.FeedbackStore).isRequired,
},
_renderInvitationOverlay: function() {
@ -215,6 +215,13 @@ loop.roomViews = (function(mozL10n) {
return this.getDOMNode().querySelector(className);
},
/**
* User clicked on the "Leave" button.
*/
leaveRoom: function() {
this.props.dispatcher.dispatch(new sharedActions.LeaveRoom());
},
/**
* Closes the window if the cancel button is pressed in the generic failure view.
*/
@ -257,6 +264,12 @@ loop.roomViews = (function(mozL10n) {
cancelCall: this.closeWindow}
);
}
case ROOM_STATES.ENDED: {
return sharedViews.FeedbackView({
feedbackStore: this.props.feedbackStore,
onAfterFeedbackReceived: this.closeWindow}
);
}
default: {
return (
React.DOM.div({className: "room-conversation-wrapper"},
@ -273,7 +286,7 @@ loop.roomViews = (function(mozL10n) {
video: {enabled: !this.state.videoMuted, visible: true},
audio: {enabled: !this.state.audioMuted, visible: true},
publishStream: this.publishStream,
hangup: noop})
hangup: this.leaveRoom})
)
)
)

View File

@ -16,8 +16,6 @@ loop.roomViews = (function(mozL10n) {
var ROOM_STATES = loop.store.ROOM_STATES;
var sharedViews = loop.shared.views;
function noop() {}
/**
* ActiveRoomStore mixin.
* @type {Object}
@ -140,7 +138,9 @@ loop.roomViews = (function(mozL10n) {
],
propTypes: {
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
feedbackStore:
React.PropTypes.instanceOf(loop.store.FeedbackStore).isRequired,
},
_renderInvitationOverlay: function() {
@ -215,6 +215,13 @@ loop.roomViews = (function(mozL10n) {
return this.getDOMNode().querySelector(className);
},
/**
* User clicked on the "Leave" button.
*/
leaveRoom: function() {
this.props.dispatcher.dispatch(new sharedActions.LeaveRoom());
},
/**
* Closes the window if the cancel button is pressed in the generic failure view.
*/
@ -257,6 +264,12 @@ loop.roomViews = (function(mozL10n) {
cancelCall={this.closeWindow}
/>;
}
case ROOM_STATES.ENDED: {
return <sharedViews.FeedbackView
feedbackStore={this.props.feedbackStore}
onAfterFeedbackReceived={this.closeWindow}
/>;
}
default: {
return (
<div className="room-conversation-wrapper">
@ -273,7 +286,7 @@ loop.roomViews = (function(mozL10n) {
video={{enabled: !this.state.videoMuted, visible: true}}
audio={{enabled: !this.state.audioMuted, visible: true}}
publishStream={this.publishStream}
hangup={noop} />
hangup={this.leaveRoom} />
</div>
</div>
</div>

View File

@ -744,11 +744,8 @@ html, .fx-embedded, #main,
color: #555;
}
/**
* Hides the hangup button for room conversations.
*/
.room-conversation .conversation-toolbar .btn-hangup-entry {
display: none;
.fx-embedded .room-conversation .conversation-toolbar .btn-hangup {
background-image: url("../img/icons-16x16.svg#leave");
}
.room-invitation-overlay {

View File

@ -118,6 +118,10 @@ use[id$="-red"] {
<polyline fill="#FFFFFF" points="9.25,7.542 8.75,7.542 8.75,11.542 9.25,11.542 "/>
<rect x="6.75" y="7.542" fill="#FFFFFF" width="0.5" height="4"/>
</g>
<g id="leave-shape">
<polygon fill="#FFFFFF" points="2.08,11.52 2.08,4 8,4 8,2.24 0.32,2.24 0.32,13.28 8,13.28 8,11.52"/>
<polygon fill="#FFFFFF" points="15.66816,7.77344 9.6,2.27456 9.6,5.6 3.68,5.6 3.68,9.92 9.6,9.92 9.6,13.27232"/>
</g>
</defs>
<use id="audio" xlink:href="#audio-shape"/>
<use id="audio-hover" xlink:href="#audio-shape"/>
@ -137,6 +141,7 @@ use[id$="-red"] {
<use id="history" xlink:href="#history-shape"/>
<use id="history-hover" xlink:href="#history-shape"/>
<use id="history-active" xlink:href="#history-shape"/>
<use id="leave" xlink:href="#leave-shape"/>
<use id="precall" xlink:href="#precall-shape"/>
<use id="precall-hover" xlink:href="#precall-shape"/>
<use id="precall-active" xlink:href="#precall-shape"/>

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -37,7 +37,9 @@ loop.store.ActiveRoomStore = (function() {
// There was an issue with the room
FAILED: "room-failed",
// The room is full
FULL: "room-full"
FULL: "room-full",
// The room conversation has ended
ENDED: "room-ended"
};
/**
@ -424,7 +426,7 @@ loop.store.ActiveRoomStore = (function() {
}
this.setStoreState({
roomState: nextState ? nextState : ROOM_STATES.READY
roomState: nextState ? nextState : ROOM_STATES.ENDED
});
}
});

View File

@ -222,7 +222,7 @@ loop.shared.views.FeedbackView = (function(l10n) {
* Feedback view.
*/
var FeedbackView = React.createClass({displayName: 'FeedbackView',
mixins: [Backbone.Events, sharedMixins.AudioMixin],
mixins: [Backbone.Events],
propTypes: {
feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore),
@ -242,10 +242,6 @@ loop.shared.views.FeedbackView = (function(l10n) {
this.listenTo(this.props.feedbackStore, "change", this._onStoreStateChanged);
},
componentDidMount: function() {
this.play("terminated");
},
componentWillUnmount: function() {
this.stopListening(this.props.feedbackStore);
},

View File

@ -222,7 +222,7 @@ loop.shared.views.FeedbackView = (function(l10n) {
* Feedback view.
*/
var FeedbackView = React.createClass({
mixins: [Backbone.Events, sharedMixins.AudioMixin],
mixins: [Backbone.Events],
propTypes: {
feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore),
@ -242,10 +242,6 @@ loop.shared.views.FeedbackView = (function(l10n) {
this.listenTo(this.props.feedbackStore, "change", this._onStoreStateChanged);
},
componentDidMount: function() {
this.play("terminated");
},
componentWillUnmount: function() {
this.stopListening(this.props.feedbackStore);
},

View File

@ -238,17 +238,18 @@ loop.shared.mixins = (function() {
function isConnectedToRoom(state) {
return state === ROOM_STATES.HAS_PARTICIPANTS ||
state === ROOM_STATES.SESSION_CONNECTED;
state === ROOM_STATES.SESSION_CONNECTED;
}
function notConnectedToRoom(state) {
// Failed and full are states that the user is not
// really connected o the room, but we don't want to
// really connected to the room, but we don't want to
// catch those here, as they get their own sounds.
return state === ROOM_STATES.INIT ||
state === ROOM_STATES.GATHER ||
state === ROOM_STATES.READY ||
state === ROOM_STATES.JOINED;
state === ROOM_STATES.GATHER ||
state === ROOM_STATES.READY ||
state === ROOM_STATES.JOINED ||
state === ROOM_STATES.ENDED;
}
// Joining the room.

View File

@ -52,13 +52,15 @@ loop.standaloneRoomViews = (function(mozL10n) {
return mozL10n.get("rooms_unavailable_notification_message");
default:
return mozL10n.get("status_error");
};
}
},
_renderContent: function() {
switch(this.props.roomState) {
case ROOM_STATES.INIT:
case ROOM_STATES.READY: {
case ROOM_STATES.READY:
case ROOM_STATES.ENDED: {
// XXX: In ENDED state, we should rather display the feedback form.
return (
React.DOM.button({className: "btn btn-join btn-info",
onClick: this.props.joinRoom},
@ -219,13 +221,14 @@ loop.standaloneRoomViews = (function(mozL10n) {
},
/**
* Watches for when we transition from READY to JOINED room state, so we can
* request user media access.
* Watches for when we transition to JOINED room state, so we can request
* user media access.
*
* @param {Object} nextProps (Unused)
* @param {Object} nextState Next state object.
*/
componentWillUpdate: function(nextProps, nextState) {
if (this.state.roomState === ROOM_STATES.READY &&
if (this.state.roomState !== ROOM_STATES.JOINED &&
nextState.roomState === ROOM_STATES.JOINED) {
this.props.dispatcher.dispatch(new sharedActions.SetupStreamElements({
publisherConfig: this._getPublisherConfig(),

View File

@ -52,13 +52,15 @@ loop.standaloneRoomViews = (function(mozL10n) {
return mozL10n.get("rooms_unavailable_notification_message");
default:
return mozL10n.get("status_error");
};
}
},
_renderContent: function() {
switch(this.props.roomState) {
case ROOM_STATES.INIT:
case ROOM_STATES.READY: {
case ROOM_STATES.READY:
case ROOM_STATES.ENDED: {
// XXX: In ENDED state, we should rather display the feedback form.
return (
<button className="btn btn-join btn-info"
onClick={this.props.joinRoom}>
@ -219,13 +221,14 @@ loop.standaloneRoomViews = (function(mozL10n) {
},
/**
* Watches for when we transition from READY to JOINED room state, so we can
* request user media access.
* Watches for when we transition to JOINED room state, so we can request
* user media access.
*
* @param {Object} nextProps (Unused)
* @param {Object} nextState Next state object.
*/
componentWillUpdate: function(nextProps, nextState) {
if (this.state.roomState === ROOM_STATES.READY &&
if (this.state.roomState !== ROOM_STATES.JOINED &&
nextState.roomState === ROOM_STATES.JOINED) {
this.props.dispatcher.dispatch(new sharedActions.SetupStreamElements({
publisherConfig: this._getPublisherConfig(),

View File

@ -510,6 +510,22 @@ describe("loop.conversationViews", function () {
loop.shared.views.FeedbackView);
});
it("should play the terminated sound when the call state is 'finished'",
function() {
var fakeAudio = {
play: sinon.spy(),
pause: sinon.spy(),
removeAttribute: sinon.spy()
};
sandbox.stub(window, "Audio").returns(fakeAudio);
store.set({callState: CALL_STATES.FINISHED});
view = mountTestComponent();
sinon.assert.calledOnce(fakeAudio.play);
});
it("should update the rendered views when the state is changed.",
function() {
store.set({

View File

@ -199,7 +199,10 @@ describe("loop.roomViews", function () {
return TestUtils.renderIntoDocument(
new loop.roomViews.DesktopRoomConversationView({
dispatcher: dispatcher,
roomStore: roomStore
roomStore: roomStore,
feedbackStore: new loop.store.FeedbackStore(dispatcher, {
feedbackClient: {}
})
}));
}
@ -316,6 +319,16 @@ describe("loop.roomViews", function () {
TestUtils.findRenderedComponentWithType(view,
loop.roomViews.DesktopRoomConversationView);
});
it("should render the FeedbackView if roomState is `ENDED`",
function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.ENDED});
view = mountTestComponent();
TestUtils.findRenderedComponentWithType(view,
loop.shared.views.FeedbackView);
});
});
});
});

View File

@ -574,10 +574,10 @@ describe("loop.store.ActiveRoomStore", function () {
"fakeToken", "1627384950");
});
it("should set the state to ready", function() {
it("should set the state to ENDED", function() {
store.windowUnload();
expect(store._storeState.roomState).eql(ROOM_STATES.READY);
expect(store._storeState.roomState).eql(ROOM_STATES.ENDED);
});
});
@ -619,10 +619,10 @@ describe("loop.store.ActiveRoomStore", function () {
"fakeToken", "1627384950");
});
it("should set the state to ready", function() {
it("should set the state to ENDED", function() {
store.leaveRoom();
expect(store._storeState.roomState).eql(ROOM_STATES.READY);
expect(store._storeState.roomState).eql(ROOM_STATES.ENDED);
});
});

View File

@ -16,28 +16,15 @@ var FEEDBACK_STATES = loop.store.FEEDBACK_STATES;
describe("loop.shared.views.FeedbackView", function() {
"use strict";
var sandbox, comp, dispatcher, feedbackStore, fakeAudioXHR, fakeFeedbackClient;
var sandbox, comp, dispatcher, fakeFeedbackClient, feedbackStore;
beforeEach(function() {
sandbox = sinon.sandbox.create();
fakeAudioXHR = {
open: sinon.spy(),
send: function() {},
abort: function() {},
getResponseHeader: function(header) {
if (header === "Content-Type")
return "audio/ogg";
},
responseType: null,
response: new ArrayBuffer(10),
onload: null
};
dispatcher = new loop.Dispatcher();
fakeFeedbackClient = {send: sandbox.stub()};
feedbackStore = new loop.store.FeedbackStore(dispatcher, {
feedbackClient: fakeFeedbackClient
});
sandbox.stub(window, "XMLHttpRequest").returns(fakeAudioXHR);
comp = TestUtils.renderIntoDocument(sharedViews.FeedbackView({
feedbackStore: feedbackStore
}));

View File

@ -38,27 +38,42 @@ describe("loop.standaloneRoomViews", function() {
}));
}
function expectActionDispatched(view) {
sinon.assert.calledOnce(dispatch);
sinon.assert.calledWithExactly(dispatch,
sinon.match.instanceOf(sharedActions.SetupStreamElements));
sinon.assert.calledWithExactly(dispatch, sinon.match(function(value) {
return value.getLocalElementFunc() ===
view.getDOMNode().querySelector(".local");
}));
sinon.assert.calledWithExactly(dispatch, sinon.match(function(value) {
return value.getRemoteElementFunc() ===
view.getDOMNode().querySelector(".remote");
}));
}
describe("#componentWillUpdate", function() {
it("dispatch an `SetupStreamElements` action on room joined", function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.READY});
var view = mountTestComponent();
it("should dispatch a `SetupStreamElements` action on room joined",
function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.READY});
var view = mountTestComponent();
sinon.assert.notCalled(dispatch);
sinon.assert.notCalled(dispatch);
activeRoomStore.setStoreState({roomState: ROOM_STATES.JOINED});
activeRoomStore.setStoreState({roomState: ROOM_STATES.JOINED});
sinon.assert.calledOnce(dispatch);
sinon.assert.calledWithExactly(dispatch,
sinon.match.instanceOf(sharedActions.SetupStreamElements));
sinon.assert.calledWithExactly(dispatch, sinon.match(function(value) {
return value.getLocalElementFunc() ===
view.getDOMNode().querySelector(".local");
}));
sinon.assert.calledWithExactly(dispatch, sinon.match(function(value) {
return value.getRemoteElementFunc() ===
view.getDOMNode().querySelector(".remote");
}));
});
expectActionDispatched(view);
});
it("should dispatch a `SetupStreamElements` action on room rejoined",
function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.ENDED});
var view = mountTestComponent();
activeRoomStore.setStoreState({roomState: ROOM_STATES.JOINED});
expectActionDispatched(view);
});
});
describe("#publishStream", function() {

View File

@ -1057,22 +1057,6 @@ describe("loop.webapp", function() {
it("should render a FeedbackView", function() {
TestUtils.findRenderedComponentWithType(view, sharedViews.FeedbackView);
});
describe("#componentDidMount", function() {
it("should play a terminating sound, once", function() {
fakeAudioXHR.onload();
sinon.assert.called(fakeAudioXHR.open);
sinon.assert.calledWithExactly(
fakeAudioXHR.open, "GET", "shared/sounds/terminated.ogg", true);
sinon.assert.calledOnce(fakeAudio.play);
expect(fakeAudio.loop).to.not.equal(true);
});
});
});
describe("PromoteFirefoxView", function() {

View File

@ -49,12 +49,14 @@ var fakeRooms = [
*/
navigator.mozLoop = {
ensureRegistered: function() {},
getAudioBlob: function(){},
getLoopPref: function(pref) {
// Ensure UI for rooms is displayed in the showcase.
if (pref === "rooms.enabled") {
return true;
}
},
setLoopPref: function(){},
releaseCallData: function() {},
copyString: function() {},
contacts: {

View File

@ -128,7 +128,7 @@
"audio", "audio-hover", "audio-active", "block",
"block-red", "block-hover", "block-active", "contacts", "contacts-hover",
"contacts-active", "copy", "checkmark", "google", "google-hover",
"google-active", "history", "history-hover", "history-active",
"google-active", "history", "history-hover", "history-active", "leave",
"precall", "precall-hover", "precall-active", "settings", "settings-hover",
"settings-active", "tag", "tag-hover", "tag-active", "trash", "unblock",
"unblock-hover", "unblock-active", "video", "video-hover", "video-active"

View File

@ -128,7 +128,7 @@
"audio", "audio-hover", "audio-active", "block",
"block-red", "block-hover", "block-active", "contacts", "contacts-hover",
"contacts-active", "copy", "checkmark", "google", "google-hover",
"google-active", "history", "history-hover", "history-active",
"google-active", "history", "history-hover", "history-active", "leave",
"precall", "precall-hover", "precall-active", "settings", "settings-hover",
"settings-active", "tag", "tag-hover", "tag-active", "trash", "unblock",
"unblock-hover", "unblock-active", "video", "video-hover", "video-active"