Bug 1147609 - Make Loop's standalone UI work with roomName as an unecrypted parameter or as an encrypted part of context. r=mikedeboer

This commit is contained in:
Mark Banner 2015-04-01 12:04:08 +01:00
parent 8769806b40
commit a68bae1728
15 changed files with 348 additions and 36 deletions

View File

@ -972,11 +972,14 @@ html, .fx-embedded, #main,
max-width: 400px;
}
.standalone .room-conversation h2.room-name {
.standalone .room-conversation h2.room-name,
.standalone .room-conversation h2.room-info-failure {
position: absolute;
display: inline-block;
top: 0;
right: 0;
right: 10px;
/* 20px is 10px for left and right margins. */
width: calc(25% - 20px);
color: #fff;
z-index: 2000000;
font-size: 1.2em;

View File

@ -43,7 +43,8 @@ loop.shared.actions = (function() {
* Extract the token information and type for the standalone window
*/
ExtractTokenInfo: Action.define("extractTokenInfo", {
windowPath: String
windowPath: String,
windowHash: String
}),
/**
@ -65,6 +66,7 @@ loop.shared.actions = (function() {
* token.
*/
FetchServerData: Action.define("fetchServerData", {
// cryptoKey: String - Optional.
token: String,
windowType: String
}),
@ -386,6 +388,7 @@ loop.shared.actions = (function() {
* @see https://wiki.mozilla.org/Loop/Architecture/Rooms#GET_.2Frooms.2F.7Btoken.7D
*/
UpdateRoomInfo: Action.define("updateRoomInfo", {
// context: Object - Optional.
// roomName: String - Optional.
roomOwner: String,
roomUrl: String

View File

@ -11,6 +11,7 @@ loop.store.ActiveRoomStore = (function() {
"use strict";
var sharedActions = loop.shared.actions;
var crypto = loop.crypto;
var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS;
var SCREEN_SHARE_STATES = loop.shared.utils.SCREEN_SHARE_STATES;
@ -20,6 +21,8 @@ loop.store.ActiveRoomStore = (function() {
var ROOM_STATES = loop.store.ROOM_STATES;
var ROOM_INFO_FAILURES = loop.shared.utils.ROOM_INFO_FAILURES;
/**
* Active room store.
*
@ -76,7 +79,11 @@ loop.store.ActiveRoomStore = (function() {
localVideoDimensions: {},
remoteVideoDimensions: {},
screenSharingState: SCREEN_SHARE_STATES.INACTIVE,
receivingScreenShare: false
receivingScreenShare: false,
// The roomCryptoKey to decode the context data if necessary.
roomCryptoKey: null,
// Room information failed to be obtained for a reason. See ROOM_INFO_FAILURES.
roomInfoFailure: null
};
},
@ -199,6 +206,7 @@ loop.store.ActiveRoomStore = (function() {
this.setStoreState({
roomToken: actionData.token,
roomCryptoKey: actionData.cryptoKey,
roomState: ROOM_STATES.READY
});
@ -207,8 +215,11 @@ loop.store.ActiveRoomStore = (function() {
this._mozLoop.rooms.on("delete:" + actionData.roomToken,
this._handleRoomDelete.bind(this));
this._mozLoop.rooms.get(this._storeState.roomToken,
function(err, result) {
this._getRoomDataForStandalone();
},
_getRoomDataForStandalone: function() {
this._mozLoop.rooms.get(this._storeState.roomToken, function(err, result) {
if (err) {
// XXX Bug 1110937 will want to handle the error results here
// e.g. room expired/invalid.
@ -216,7 +227,51 @@ loop.store.ActiveRoomStore = (function() {
return;
}
this.dispatcher.dispatch(new sharedActions.UpdateRoomInfo(result));
var roomInfoData = new sharedActions.UpdateRoomInfo({
roomOwner: result.roomOwner,
roomUrl: result.roomUrl
});
if (!result.context && !result.roomName) {
roomInfoData.roomInfoFailure = ROOM_INFO_FAILURES.NO_DATA;
this.dispatcher.dispatch(roomInfoData);
return;
}
// This handles 'legacy', non-encrypted room names.
if (result.roomName && !result.context) {
roomInfoData.roomName = result.roomName;
this.dispatcher.dispatch(roomInfoData);
return;
}
if (!crypto.isSupported()) {
roomInfoData.roomInfoFailure = ROOM_INFO_FAILURES.WEB_CRYPTO_UNSUPPORTED;
this.dispatcher.dispatch(roomInfoData);
return;
}
var roomCryptoKey = this.getStoreState("roomCryptoKey");
if (!roomCryptoKey) {
roomInfoData.roomInfoFailure = ROOM_INFO_FAILURES.NO_CRYPTO_KEY;
this.dispatcher.dispatch(roomInfoData);
return;
}
var dispatcher = this.dispatcher;
crypto.decryptBytes(roomCryptoKey, result.context.value)
.then(function(decryptedResult) {
var realResult = JSON.parse(decryptedResult);
roomInfoData.roomName = realResult.roomName;
dispatcher.dispatch(roomInfoData);
}, function(err) {
roomInfoData.roomInfoFailure = ROOM_INFO_FAILURES.DECRYPT_FAILED;
dispatcher.dispatch(roomInfoData);
});
}.bind(this));
},
@ -254,6 +309,7 @@ loop.store.ActiveRoomStore = (function() {
*/
updateRoomInfo: function(actionData) {
this.setStoreState({
roomInfoFailure: actionData.roomInfoFailure,
roomName: actionData.roomName,
roomOwner: actionData.roomOwner,
roomUrl: actionData.roomUrl

View File

@ -43,6 +43,17 @@ loop.shared.utils = (function(mozL10n) {
UNKNOWN: "reason-unknown"
};
var ROOM_INFO_FAILURES = {
// There's no data available from the server.
NO_DATA: "no_data",
// WebCrypto is unsupported in this browser.
WEB_CRYPTO_UNSUPPORTED: "web_crypto_unsupported",
// The room is missing the crypto key information.
NO_CRYPTO_KEY: "no_crypto_key",
// Decryption failed.
DECRYPT_FAILED: "decrypt_failed"
};
var STREAM_PROPERTIES = {
VIDEO_DIMENSIONS: "videoDimensions",
HAS_AUDIO: "hasAudio",
@ -397,6 +408,7 @@ loop.shared.utils = (function(mozL10n) {
WEBSOCKET_REASONS: WEBSOCKET_REASONS,
STREAM_PROPERTIES: STREAM_PROPERTIES,
SCREEN_SHARE_STATES: SCREEN_SHARE_STATES,
ROOM_INFO_FAILURES: ROOM_INFO_FAILURES,
composeCallUrlEmail: composeCallUrlEmail,
formatDate: formatDate,
getBoolPreference: getBoolPreference,

View File

@ -108,6 +108,7 @@
<!-- app scripts -->
<script type="text/javascript" src="config.js"></script>
<script type="text/javascript" src="shared/js/utils.js"></script>
<script type="text/javascript" src="shared/js/crypto.js"></script>
<script type="text/javascript" src="shared/js/models.js"></script>
<script type="text/javascript" src="shared/js/mixins.js"></script>
<script type="text/javascript" src="shared/js/feedbackApiClient.js"></script>

View File

@ -95,9 +95,21 @@ loop.store.StandaloneAppStore = (function() {
return [windowType, match && match[1] ? match[1] : null];
},
/**
* Extracts the crypto key from the hash for the page.
*/
_extractCryptoKey: function(windowHash) {
if (windowHash && windowHash[0] === "#") {
return windowHash.substring(1, windowHash.length);
}
return null;
},
/**
* Handles the extract token info action - obtains the token information
* and its type; updates the store and notifies interested components.
* and its type; extracts any crypto information; updates the store and
* notifies interested components.
*
* @param {sharedActions.GetWindowData} actionData The action data
*/
@ -135,6 +147,7 @@ loop.store.StandaloneAppStore = (function() {
// it.
if (token) {
this._dispatcher.dispatch(new loop.shared.actions.FetchServerData({
cryptoKey: this._extractCryptoKey(actionData.windowHash),
token: token,
windowType: windowType
}));

View File

@ -12,6 +12,7 @@ loop.standaloneRoomViews = (function(mozL10n) {
"use strict";
var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS;
var ROOM_INFO_FAILURES = loop.shared.utils.ROOM_INFO_FAILURES;
var ROOM_STATES = loop.store.ROOM_STATES;
var sharedActions = loop.shared.actions;
var sharedMixins = loop.shared.mixins;
@ -198,6 +199,29 @@ loop.standaloneRoomViews = (function(mozL10n) {
}
});
var StandaloneRoomContextView = React.createClass({displayName: "StandaloneRoomContextView",
propTypes: {
roomName: React.PropTypes.string,
roomInfoFailure: React.PropTypes.string
},
render: function() {
if (this.props.roomInfoFailure === ROOM_INFO_FAILURES.WEB_CRYPTO_UNSUPPORTED) {
return (React.createElement("h2", {className: "room-info-failure"},
mozL10n.get("room_information_failure_unsupported_browser")
));
} else if (this.props.roomInfoFailure) {
return (React.createElement("h2", {className: "room-info-failure"},
mozL10n.get("room_information_failure_not_available")
));
}
return (
React.createElement("h2", {className: "room-name"}, this.props.roomName)
);
}
});
var StandaloneRoomView = React.createClass({displayName: "StandaloneRoomView",
mixins: [
Backbone.Events,
@ -458,7 +482,8 @@ loop.standaloneRoomViews = (function(mozL10n) {
roomUsed: this.state.used}),
React.createElement("div", {className: "video-layout-wrapper"},
React.createElement("div", {className: "conversation room-conversation"},
React.createElement("h2", {className: "room-name"}, this.state.roomName),
React.createElement(StandaloneRoomContextView, {roomName: this.state.roomName,
roomInfoFailure: this.state.roomInfoFailure}),
React.createElement("div", {className: "media nested"},
React.createElement("span", {className: "self-view-hidden-message"},
mozL10n.get("self_view_hidden_message")
@ -491,6 +516,7 @@ loop.standaloneRoomViews = (function(mozL10n) {
});
return {
StandaloneRoomContextView: StandaloneRoomContextView,
StandaloneRoomView: StandaloneRoomView
};
})(navigator.mozL10n);

View File

@ -12,6 +12,7 @@ loop.standaloneRoomViews = (function(mozL10n) {
"use strict";
var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS;
var ROOM_INFO_FAILURES = loop.shared.utils.ROOM_INFO_FAILURES;
var ROOM_STATES = loop.store.ROOM_STATES;
var sharedActions = loop.shared.actions;
var sharedMixins = loop.shared.mixins;
@ -198,6 +199,29 @@ loop.standaloneRoomViews = (function(mozL10n) {
}
});
var StandaloneRoomContextView = React.createClass({
propTypes: {
roomName: React.PropTypes.string,
roomInfoFailure: React.PropTypes.string
},
render: function() {
if (this.props.roomInfoFailure === ROOM_INFO_FAILURES.WEB_CRYPTO_UNSUPPORTED) {
return (<h2 className="room-info-failure">
{mozL10n.get("room_information_failure_unsupported_browser")}
</h2>);
} else if (this.props.roomInfoFailure) {
return (<h2 className="room-info-failure">
{mozL10n.get("room_information_failure_not_available")}
</h2>);
}
return (
<h2 className="room-name">{this.props.roomName}</h2>
);
}
});
var StandaloneRoomView = React.createClass({
mixins: [
Backbone.Events,
@ -458,7 +482,8 @@ loop.standaloneRoomViews = (function(mozL10n) {
roomUsed={this.state.used} />
<div className="video-layout-wrapper">
<div className="conversation room-conversation">
<h2 className="room-name">{this.state.roomName}</h2>
<StandaloneRoomContextView roomName={this.state.roomName}
roomInfoFailure={this.state.roomInfoFailure} />
<div className="media nested">
<span className="self-view-hidden-message">
{mozL10n.get("self_view_hidden_message")}
@ -491,6 +516,7 @@ loop.standaloneRoomViews = (function(mozL10n) {
});
return {
StandaloneRoomContextView: StandaloneRoomContextView,
StandaloneRoomView: StandaloneRoomView
};
})(navigator.mozL10n);

View File

@ -1109,7 +1109,8 @@ loop.webapp = (function($, _, OT, mozL10n) {
var locationData = sharedUtils.locationData();
dispatcher.dispatch(new sharedActions.ExtractTokenInfo({
windowPath: locationData.pathname
windowPath: locationData.pathname,
windowHash: locationData.hash
}));
}

View File

@ -1109,7 +1109,8 @@ loop.webapp = (function($, _, OT, mozL10n) {
var locationData = sharedUtils.locationData();
dispatcher.dispatch(new sharedActions.ExtractTokenInfo({
windowPath: locationData.pathname
windowPath: locationData.pathname,
windowHash: locationData.hash
}));
}

View File

@ -127,6 +127,8 @@ rooms_room_join_label=Join the conversation
rooms_display_name_guest=Guest
rooms_unavailable_notification_message=Sorry, you cannot join this conversation. The link may be expired or invalid.
rooms_media_denied_message=We could not get access to your microphone or camera. Please reload the page to try again.
room_information_failure_not_available=No information about this conversation is available. Please request a new link from the person who sent it to you.
room_information_failure_unsupported_browser=Your browser cannot access any information about this conversation. Please make sure you're using the latest version.
## LOCALIZATION_NOTE(standalone_title_with_status): {{clientShortname}} will be
## replaced by the brand name and {{currentStatus}} will be replaced

View File

@ -10,6 +10,7 @@ describe("loop.store.ActiveRoomStore", function () {
var ROOM_STATES = loop.store.ROOM_STATES;
var FAILURE_DETAILS = loop.shared.utils.FAILURE_DETAILS;
var SCREEN_SHARE_STATES = loop.shared.utils.SCREEN_SHARE_STATES;
var ROOM_INFO_FAILURES = loop.shared.utils.ROOM_INFO_FAILURES;
var sandbox, dispatcher, store, fakeMozLoop, fakeSdkDriver;
var fakeMultiplexGum;
@ -347,6 +348,24 @@ describe("loop.store.ActiveRoomStore", function () {
sinon.assert.calledOnce(fakeMozLoop.rooms.get);
});
it("should dispatch an UpdateRoomInfo message with 'no data' failure if neither roomName nor context are supplied", function() {
fakeMozLoop.rooms.get.callsArgWith(1, null, {
roomOwner: "Dan",
roomUrl: "http://invalid"
});
store.fetchServerData(fetchServerAction);
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithExactly(dispatcher.dispatch,
new sharedActions.UpdateRoomInfo({
roomInfoFailure: ROOM_INFO_FAILURES.NO_DATA,
roomOwner: "Dan",
roomUrl: "http://invalid"
}));
});
describe("mozLoop.rooms.get returns roomName as a separate field (no context)", function() {
it("should dispatch UpdateRoomInfo if mozLoop.rooms.get is successful", function() {
var roomDetails = {
roomName: "fakeName",
@ -364,6 +383,95 @@ describe("loop.store.ActiveRoomStore", function () {
});
});
describe("mozLoop.rooms.get returns encryptedContext", function() {
var roomDetails, expectedDetails;
beforeEach(function() {
roomDetails = {
context: {
value: "fakeContext"
},
roomUrl: "http://invalid",
roomOwner: "Mark"
};
expectedDetails = {
roomUrl: "http://invalid",
roomOwner: "Mark"
};
fakeMozLoop.rooms.get.callsArgWith(1, null, roomDetails);
sandbox.stub(loop.crypto, "isSupported").returns(true);
});
it("should dispatch UpdateRoomInfo message with 'unsupported' failure if WebCrypto is unsupported", function() {
loop.crypto.isSupported.returns(false);
store.fetchServerData(fetchServerAction);
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithExactly(dispatcher.dispatch,
new sharedActions.UpdateRoomInfo(_.extend({
roomInfoFailure: ROOM_INFO_FAILURES.WEB_CRYPTO_UNSUPPORTED
}, expectedDetails)));
});
it("should dispatch UpdateRoomInfo message with 'no crypto key' failure if there is no crypto key", function() {
store.fetchServerData(fetchServerAction);
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithExactly(dispatcher.dispatch,
new sharedActions.UpdateRoomInfo(_.extend({
roomInfoFailure: ROOM_INFO_FAILURES.NO_CRYPTO_KEY
}, expectedDetails)));
});
it("should dispatch UpdateRoomInfo message with 'decrypt failed' failure if decryption failed", function() {
fetchServerAction.cryptoKey = "fakeKey";
// This is a work around to turn promise into a sync action to make handling test failures
// easier.
sandbox.stub(loop.crypto, "decryptBytes", function() {
return {
then: function(resolve, reject) {
reject(new Error("Operation unsupported"));
}
};
});
store.fetchServerData(fetchServerAction);
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithExactly(dispatcher.dispatch,
new sharedActions.UpdateRoomInfo(_.extend({
roomInfoFailure: ROOM_INFO_FAILURES.DECRYPT_FAILED
}, expectedDetails)));
});
it("should dispatch UpdateRoomInfo message with the room name if decryption was successful", function() {
fetchServerAction.cryptoKey = "fakeKey";
// This is a work around to turn promise into a sync action to make handling test failures
// easier.
sandbox.stub(loop.crypto, "decryptBytes", function() {
return {
then: function(resolve, reject) {
resolve(JSON.stringify({roomName: "The wonderful Loopy room"}));
}
};
});
store.fetchServerData(fetchServerAction);
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithExactly(dispatcher.dispatch,
new sharedActions.UpdateRoomInfo(_.extend({
roomName: "The wonderful Loopy room"
}, expectedDetails)));
});
});
});
describe("#feedbackComplete", function() {
it("should reset the room store state", function() {
var initialState = store.getInitialStoreState();

View File

@ -54,7 +54,8 @@ describe("loop.store.StandaloneAppStore", function () {
beforeEach(function() {
fakeGetWindowData = {
windowPath: ""
windowPath: "",
windowHash: ""
};
sandbox.stub(loop.shared.utils, "getUnsupportedPlatform").returns();
@ -177,7 +178,7 @@ describe("loop.store.StandaloneAppStore", function () {
});
});
it("should set the loopToken on the conversation for call paths",
it("should dispatch a FetchServerData action for call paths",
function() {
fakeGetWindowData.windowPath = "/c/fakecalltoken";
@ -187,14 +188,15 @@ describe("loop.store.StandaloneAppStore", function () {
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithExactly(dispatcher.dispatch,
new sharedActions.FetchServerData({
cryptoKey: null,
windowType: "outgoing",
token: "fakecalltoken"
}));
});
it("should set the loopToken on the conversation for room paths",
it("should dispatch a FetchServerData action for room paths",
function() {
fakeGetWindowData.windowPath = "/c/fakeroomtoken";
fakeGetWindowData.windowPath = "/fakeroomtoken";
store.extractTokenInfo(
new sharedActions.ExtractTokenInfo(fakeGetWindowData));
@ -202,11 +204,29 @@ describe("loop.store.StandaloneAppStore", function () {
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithExactly(dispatcher.dispatch,
new sharedActions.FetchServerData({
windowType: "outgoing",
cryptoKey: null,
windowType: "room",
token: "fakeroomtoken"
}));
});
it("should dispatch a FetchServerData action with a crypto key extracted from the hash", function() {
fakeGetWindowData = {
windowPath: "/fakeroomtoken",
windowHash: "#fakeKey"
};
store.extractTokenInfo(
new sharedActions.ExtractTokenInfo(fakeGetWindowData));
sinon.assert.calledOnce(dispatcher.dispatch);
sinon.assert.calledWithExactly(dispatcher.dispatch,
new sharedActions.FetchServerData({
cryptoKey: "fakeKey",
windowType: "room",
token: "fakeroomtoken"
}));
});
});
});

View File

@ -11,6 +11,7 @@ describe("loop.standaloneRoomViews", function() {
var ROOM_STATES = loop.store.ROOM_STATES;
var FEEDBACK_STATES = loop.store.FEEDBACK_STATES;
var ROOM_INFO_FAILURES = loop.shared.utils.ROOM_INFO_FAILURES;
var sharedActions = loop.shared.actions;
var sandbox, dispatcher, activeRoomStore, feedbackStore, dispatch;
@ -38,6 +39,44 @@ describe("loop.standaloneRoomViews", function() {
sandbox.restore();
});
describe("StandaloneRoomContextView", function() {
beforeEach(function() {
sandbox.stub(navigator.mozL10n, "get").returnsArg(0);
});
function mountTestComponent(props) {
return TestUtils.renderIntoDocument(
React.createElement(
loop.standaloneRoomViews.StandaloneRoomContextView, props));
}
it("should display the room name if no failures are known", function() {
var view = mountTestComponent({
roomName: "Mike's room"
});
expect(view.getDOMNode().textContent).eql("Mike's room");
});
it("should display an unsupported browser message if crypto is unsupported", function() {
var view = mountTestComponent({
roomName: "Mark's room",
roomInfoFailure: ROOM_INFO_FAILURES.WEB_CRYPTO_UNSUPPORTED
});
expect(view.getDOMNode().textContent).match(/unsupported/);
});
it("should display a general error message for any other failure", function() {
var view = mountTestComponent({
roomName: "Mark's room",
roomInfoFailure: ROOM_INFO_FAILURES.NO_DATA
});
expect(view.getDOMNode().textContent).match(/not_available/);
});
});
describe("StandaloneRoomView", function() {
function mountTestComponent() {
return TestUtils.renderIntoDocument(

View File

@ -71,10 +71,10 @@ describe("loop.webapp", function() {
}));
});
it("should dispatch a ExtractTokenInfo action with the path",
it("should dispatch a ExtractTokenInfo action with the path and hash",
function() {
sandbox.stub(loop.shared.utils, "locationData").returns({
hash: "",
hash: "#fakeKey",
pathname: "/c/faketoken"
});
@ -83,7 +83,8 @@ describe("loop.webapp", function() {
sinon.assert.calledOnce(loop.Dispatcher.prototype.dispatch);
sinon.assert.calledWithExactly(loop.Dispatcher.prototype.dispatch,
new sharedActions.ExtractTokenInfo({
windowPath: "/c/faketoken"
windowPath: "/c/faketoken",
windowHash: "#fakeKey"
}));
});
});