Bug 1147609 - Make Loop's standalone UI work with roomName as an unecrypted parameter or as an encrypted part of context. r=mikedeboer
authorMark Banner <standard8@mozilla.com>
Wed, 01 Apr 2015 12:04:08 +0100
changeset 237031 c434080d10fec7e636b3fa0cb05e7a9fec9f33fb
parent 237030 b0ed0d8f967ae5740047eede74f17db0b848c583
child 237032 a8790b0879388b36ad234a2ac18b69fbb4e0e94d
push id28522
push userkwierso@gmail.com
push dateWed, 01 Apr 2015 20:46:02 +0000
treeherdermozilla-central@e044f4d172e2 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmikedeboer
bugs1147609
milestone40.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1147609 - Make Loop's standalone UI work with roomName as an unecrypted parameter or as an encrypted part of context. r=mikedeboer
browser/components/loop/content/shared/css/conversation.css
browser/components/loop/content/shared/js/actions.js
browser/components/loop/content/shared/js/activeRoomStore.js
browser/components/loop/content/shared/js/utils.js
browser/components/loop/standalone/content/index.html
browser/components/loop/standalone/content/js/standaloneAppStore.js
browser/components/loop/standalone/content/js/standaloneRoomViews.js
browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
browser/components/loop/standalone/content/js/webapp.js
browser/components/loop/standalone/content/js/webapp.jsx
browser/components/loop/standalone/content/l10n/en-US/loop.properties
browser/components/loop/test/shared/activeRoomStore_test.js
browser/components/loop/test/standalone/standaloneAppStore_test.js
browser/components/loop/test/standalone/standaloneRoomViews_test.js
browser/components/loop/test/standalone/webapp_test.js
--- a/browser/components/loop/content/shared/css/conversation.css
+++ b/browser/components/loop/content/shared/css/conversation.css
@@ -967,21 +967,24 @@ html, .fx-embedded, #main,
 
 .standalone .room-conversation-wrapper .room-inner-info-area a.btn {
   padding: .5em 3em .3em 3em;
   border-radius: 3px;
   font-weight: normal;
   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;
   padding: .4em;
 }
 
 .standalone .room-conversation .media {
   background: #000;
--- a/browser/components/loop/content/shared/js/actions.js
+++ b/browser/components/loop/content/shared/js/actions.js
@@ -38,17 +38,18 @@ loop.shared.actions = (function() {
     GetWindowData: Action.define("getWindowData", {
       windowId: String
     }),
 
     /**
      * Extract the token information and type for the standalone window
      */
     ExtractTokenInfo: Action.define("extractTokenInfo", {
-      windowPath: String
+      windowPath: String,
+      windowHash: String
     }),
 
     /**
      * Used to pass round the window data so that stores can
      * record the appropriate data.
      */
     SetupWindowData: Action.define("setupWindowData", {
       windowId: String,
@@ -60,16 +61,17 @@ loop.shared.actions = (function() {
       // data.
     }),
 
     /**
      * Used to fetch the data from the server for a room or call for the
      * token.
      */
     FetchServerData: Action.define("fetchServerData", {
+      // cryptoKey: String - Optional.
       token: String,
       windowType: String
     }),
 
     /**
      * Used to signal when the window is being unloaded.
      */
     WindowUnload: Action.define("windowUnload", {
@@ -381,16 +383,17 @@ loop.shared.actions = (function() {
 
     /**
      * Updates the room information when it is received.
      * XXX: should move to some roomActions module - refs bug 1079284
      *
      * @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
     }),
 
     /**
      * Starts the process for the user to join the room.
      * XXX: should move to some roomActions module - refs bug 1079284
--- a/browser/components/loop/content/shared/js/activeRoomStore.js
+++ b/browser/components/loop/content/shared/js/activeRoomStore.js
@@ -6,25 +6,28 @@
 
 var loop = loop || {};
 loop.store = loop.store || {};
 
 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;
 
   // Error numbers taken from
   // https://github.com/mozilla-services/loop-server/blob/master/loop/errno.json
   var REST_ERRNOS = loop.shared.utils.REST_ERRNOS;
 
   var ROOM_STATES = loop.store.ROOM_STATES;
 
+  var ROOM_INFO_FAILURES = loop.shared.utils.ROOM_INFO_FAILURES;
+
   /**
    * Active room store.
    *
    * @param {loop.Dispatcher} dispatcher  The dispatcher for dispatching actions
    *                                      and registering to consume actions.
    * @param {Object} options Options object:
    * - {mozLoop}     mozLoop    The MozLoop API object.
    * - {OTSdkDriver} sdkDriver  The SDK driver instance.
@@ -71,17 +74,21 @@ loop.store.ActiveRoomStore = (function()
         // Tracks if the room has been used during this
         // session. 'Used' means at least one call has been placed
         // with it. Entering and leaving the room without seeing
         // anyone is not considered as 'used'
         used: false,
         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
       };
     },
 
     /**
      * Handles a room failure.
      *
      * @param {sharedActions.RoomFailure} actionData
      */
@@ -194,35 +201,83 @@ loop.store.ActiveRoomStore = (function()
         // Nothing for us to do here, leave it to other stores.
         return;
       }
 
       this._registerPostSetupActions();
 
       this.setStoreState({
         roomToken: actionData.token,
+        roomCryptoKey: actionData.cryptoKey,
         roomState: ROOM_STATES.READY
       });
 
       this._mozLoop.rooms.on("update:" + actionData.roomToken,
         this._handleRoomUpdate.bind(this));
       this._mozLoop.rooms.on("delete:" + actionData.roomToken,
         this._handleRoomDelete.bind(this));
 
-      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.
-            console.error("Failed to get room data:", err);
-            return;
-          }
+      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.
+          console.error("Failed to get room data:", err);
+          return;
+        }
+
+        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.dispatcher.dispatch(new sharedActions.UpdateRoomInfo(result));
-        }.bind(this));
+        // 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));
     },
 
     /**
      * Handles the setupRoomInfo action. Sets up the initial room data and
      * sets the state to `READY`.
      *
      * @param {sharedActions.SetupRoomInfo} actionData
      */
@@ -249,16 +304,17 @@ loop.store.ActiveRoomStore = (function()
 
     /**
      * Handles the updateRoomInfo action. Updates the room data.
      *
      * @param {sharedActions.UpdateRoomInfo} actionData
      */
     updateRoomInfo: function(actionData) {
       this.setStoreState({
+        roomInfoFailure: actionData.roomInfoFailure,
         roomName: actionData.roomName,
         roomOwner: actionData.roomOwner,
         roomUrl: actionData.roomUrl
       });
     },
 
     /**
      * Handles room updates notified by the mozLoop rooms API.
--- a/browser/components/loop/content/shared/js/utils.js
+++ b/browser/components/loop/content/shared/js/utils.js
@@ -38,16 +38,27 @@ loop.shared.utils = (function(mozL10n) {
     MEDIA_DENIED: "reason-media-denied",
     UNABLE_TO_PUBLISH_MEDIA: "unable-to-publish-media",
     COULD_NOT_CONNECT: "reason-could-not-connect",
     NETWORK_DISCONNECTED: "reason-network-disconnected",
     EXPIRED_OR_INVALID: "reason-expired-or-invalid",
     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",
     HAS_VIDEO: "hasVideo"
   };
 
   var SCREEN_SHARE_STATES = {
     INACTIVE: "ss-inactive",
@@ -392,16 +403,17 @@ loop.shared.utils = (function(mozL10n) {
 
   return {
     CALL_TYPES: CALL_TYPES,
     FAILURE_DETAILS: FAILURE_DETAILS,
     REST_ERRNOS: REST_ERRNOS,
     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,
     isChrome: isChrome,
     isFirefox: isFirefox,
     isFirefoxOS: isFirefoxOS,
     isOpera: isOpera,
     getUnsupportedPlatform: getUnsupportedPlatform,
--- a/browser/components/loop/standalone/content/index.html
+++ b/browser/components/loop/standalone/content/index.html
@@ -103,16 +103,17 @@
     <script type="text/javascript" src="shared/libs/react-0.12.2.js"></script>
     <script type="text/javascript" src="shared/libs/jquery-2.1.0.js"></script>
     <script type="text/javascript" src="shared/libs/lodash-2.4.1.js"></script>
     <script type="text/javascript" src="shared/libs/backbone-1.1.2.js"></script>
 
     <!-- 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>
     <script type="text/javascript" src="shared/js/actions.js"></script>
     <script type="text/javascript" src="shared/js/validate.js"></script>
     <script type="text/javascript" src="shared/js/dispatcher.js"></script>
     <script type="text/javascript" src="shared/js/websocket.js"></script>
     <script type="text/javascript" src="shared/js/otSdkDriver.js"></script>
--- a/browser/components/loop/standalone/content/js/standaloneAppStore.js
+++ b/browser/components/loop/standalone/content/js/standaloneAppStore.js
@@ -91,18 +91,30 @@ loop.store.StandaloneAppStore = (functio
             windowType = "room";
           }
         }
       }
       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
      */
     extractTokenInfo: function(actionData) {
       var windowType = "home";
       var token;
 
       // Check if we're on a supported device/platform.
@@ -130,16 +142,17 @@ loop.store.StandaloneAppStore = (functio
         isFirefox: sharedUtils.isFirefox(navigator.userAgent),
         unsupportedPlatform: unsupportedPlatform
       });
 
       // If we've not got a window ID, don't dispatch the action, as we don't need
       // it.
       if (token) {
         this._dispatcher.dispatch(new loop.shared.actions.FetchServerData({
+          cryptoKey: this._extractCryptoKey(actionData.windowHash),
           token: token,
           windowType: windowType
         }));
       }
     }
   }, Backbone.Events);
 
   return StandaloneAppStore;
--- a/browser/components/loop/standalone/content/js/standaloneRoomViews.js
+++ b/browser/components/loop/standalone/content/js/standaloneRoomViews.js
@@ -7,16 +7,17 @@
 /* global loop:true, React */
 /* jshint newcap:false, maxlen:false */
 
 var loop = loop || {};
 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;
   var sharedViews = loop.shared.views;
 
   var StandaloneRoomInfoArea = React.createClass({displayName: "StandaloneRoomInfoArea",
     propTypes: {
       isFirefox: React.PropTypes.bool.isRequired,
@@ -193,16 +194,39 @@ loop.standaloneRoomViews = (function(moz
         React.createElement("footer", null, 
           React.createElement("p", {dangerouslySetInnerHTML: {__html: this._getContent()}}), 
           React.createElement("div", {className: "footer-logo"})
         )
       );
     }
   });
 
+  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,
       sharedMixins.MediaSetupMixin,
       sharedMixins.RoomsAudioMixin
     ],
 
     propTypes: {
@@ -453,17 +477,18 @@ loop.standaloneRoomViews = (function(moz
           React.createElement(StandaloneRoomInfoArea, {roomState: this.state.roomState, 
                                   failureReason: this.state.failureReason, 
                                   joinRoom: this.joinRoom, 
                                   isFirefox: this.props.isFirefox, 
                                   activeRoomStore: this.props.activeRoomStore, 
                                   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")
                 ), 
                 React.createElement("div", {className: "video_wrapper remote_wrapper"}, 
                   React.createElement("div", {className: remoteStreamClasses}), 
                   React.createElement("div", {className: screenShareStreamClasses})
                 ), 
@@ -486,11 +511,12 @@ loop.standaloneRoomViews = (function(moz
             onMarketplaceMessage: this.state.onMarketplaceMessage}), 
           React.createElement(StandaloneRoomFooter, null)
         )
       );
     }
   });
 
   return {
+    StandaloneRoomContextView: StandaloneRoomContextView,
     StandaloneRoomView: StandaloneRoomView
   };
 })(navigator.mozL10n);
--- a/browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
+++ b/browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
@@ -7,16 +7,17 @@
 /* global loop:true, React */
 /* jshint newcap:false, maxlen:false */
 
 var loop = loop || {};
 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;
   var sharedViews = loop.shared.views;
 
   var StandaloneRoomInfoArea = React.createClass({
     propTypes: {
       isFirefox: React.PropTypes.bool.isRequired,
@@ -193,16 +194,39 @@ loop.standaloneRoomViews = (function(moz
         <footer>
           <p dangerouslySetInnerHTML={{__html: this._getContent()}}></p>
           <div className="footer-logo" />
         </footer>
       );
     }
   });
 
+  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,
       sharedMixins.MediaSetupMixin,
       sharedMixins.RoomsAudioMixin
     ],
 
     propTypes: {
@@ -453,17 +477,18 @@ loop.standaloneRoomViews = (function(moz
           <StandaloneRoomInfoArea roomState={this.state.roomState}
                                   failureReason={this.state.failureReason}
                                   joinRoom={this.joinRoom}
                                   isFirefox={this.props.isFirefox}
                                   activeRoomStore={this.props.activeRoomStore}
                                   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")}
                 </span>
                 <div className="video_wrapper remote_wrapper">
                   <div className={remoteStreamClasses}></div>
                   <div className={screenShareStreamClasses}></div>
                 </div>
@@ -486,11 +511,12 @@ loop.standaloneRoomViews = (function(moz
             onMarketplaceMessage={this.state.onMarketplaceMessage} />
           <StandaloneRoomFooter />
         </div>
       );
     }
   });
 
   return {
+    StandaloneRoomContextView: StandaloneRoomContextView,
     StandaloneRoomView: StandaloneRoomView
   };
 })(navigator.mozL10n);
--- a/browser/components/loop/standalone/content/js/webapp.js
+++ b/browser/components/loop/standalone/content/js/webapp.js
@@ -1104,17 +1104,18 @@ loop.webapp = (function($, _, OT, mozL10
     // Set the 'lang' and 'dir' attributes to <html> when the page is translated
     document.documentElement.lang = mozL10n.language.code;
     document.documentElement.dir = mozL10n.language.direction;
     document.title = mozL10n.get("clientShortname2");
 
     var locationData = sharedUtils.locationData();
 
     dispatcher.dispatch(new sharedActions.ExtractTokenInfo({
-      windowPath: locationData.pathname
+      windowPath: locationData.pathname,
+      windowHash: locationData.hash
     }));
   }
 
   return {
     CallUrlExpiredView: CallUrlExpiredView,
     PendingConversationView: PendingConversationView,
     GumPromptConversationView: GumPromptConversationView,
     WaitingConversationView: WaitingConversationView,
--- a/browser/components/loop/standalone/content/js/webapp.jsx
+++ b/browser/components/loop/standalone/content/js/webapp.jsx
@@ -1104,17 +1104,18 @@ loop.webapp = (function($, _, OT, mozL10
     // Set the 'lang' and 'dir' attributes to <html> when the page is translated
     document.documentElement.lang = mozL10n.language.code;
     document.documentElement.dir = mozL10n.language.direction;
     document.title = mozL10n.get("clientShortname2");
 
     var locationData = sharedUtils.locationData();
 
     dispatcher.dispatch(new sharedActions.ExtractTokenInfo({
-      windowPath: locationData.pathname
+      windowPath: locationData.pathname,
+      windowHash: locationData.hash
     }));
   }
 
   return {
     CallUrlExpiredView: CallUrlExpiredView,
     PendingConversationView: PendingConversationView,
     GumPromptConversationView: GumPromptConversationView,
     WaitingConversationView: WaitingConversationView,
--- a/browser/components/loop/standalone/content/l10n/en-US/loop.properties
+++ b/browser/components/loop/standalone/content/l10n/en-US/loop.properties
@@ -122,16 +122,18 @@ rooms_panel_title=Choose a conversation 
 rooms_room_full_label=There are already two people in this conversation.
 rooms_room_full_call_to_action_nonFx_label=Download {{brandShortname}} to start your own
 rooms_room_full_call_to_action_label=Learn more about {{clientShortname}} »
 rooms_room_joined_label=Someone has joined the conversation!
 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
 ## by the current call status (Connecting, Ringing, etc.)
 standalone_title_with_status={{clientShortname}} — {{currentStatus}}
 status_in_conversation=In conversation
 status_conversation_ended=Conversation ended
 status_error=Something went wrong
--- a/browser/components/loop/test/shared/activeRoomStore_test.js
+++ b/browser/components/loop/test/shared/activeRoomStore_test.js
@@ -5,16 +5,17 @@ var sharedActions = loop.shared.actions;
 
 describe("loop.store.ActiveRoomStore", function () {
   "use strict";
 
   var REST_ERRNOS = loop.shared.utils.REST_ERRNOS;
   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;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
     sandbox.useFakeTimers();
 
     dispatcher = new loop.Dispatcher();
@@ -342,30 +343,137 @@ describe("loop.store.ActiveRoomStore", f
     });
 
     it("should call mozLoop.rooms.get to get the room data", function() {
       store.fetchServerData(fetchServerAction);
 
       sinon.assert.calledOnce(fakeMozLoop.rooms.get);
     });
 
-    it("should dispatch UpdateRoomInfo if mozLoop.rooms.get is successful", function() {
-      var roomDetails = {
-        roomName: "fakeName",
-        roomUrl: "http://invalid",
-        roomOwner: "gavin"
-      };
-
-      fakeMozLoop.rooms.get.callsArgWith(1, null, roomDetails);
+    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(roomDetails));
+        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",
+          roomUrl: "http://invalid",
+          roomOwner: "gavin"
+        };
+
+        fakeMozLoop.rooms.get.callsArgWith(1, null, roomDetails);
+
+        store.fetchServerData(fetchServerAction);
+
+        sinon.assert.calledOnce(dispatcher.dispatch);
+        sinon.assert.calledWithExactly(dispatcher.dispatch,
+          new sharedActions.UpdateRoomInfo(roomDetails));
+      });
+    });
+
+    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();
       store.setStoreState({
         roomState: ROOM_STATES.ENDED,
--- a/browser/components/loop/test/standalone/standaloneAppStore_test.js
+++ b/browser/components/loop/test/standalone/standaloneAppStore_test.js
@@ -49,17 +49,18 @@ describe("loop.store.StandaloneAppStore"
     });
   });
 
   describe("#extractTokenInfo", function() {
     var store, fakeGetWindowData, fakeSdk, fakeConversation, helper;
 
     beforeEach(function() {
       fakeGetWindowData = {
-        windowPath: ""
+        windowPath: "",
+        windowHash: ""
       };
 
       sandbox.stub(loop.shared.utils, "getUnsupportedPlatform").returns();
       sandbox.stub(loop.shared.utils, "isFirefox").returns(true);
 
       fakeSdk = {
         checkSystemRequirements: sinon.stub().returns(true)
       };
@@ -172,41 +173,60 @@ describe("loop.store.StandaloneAppStore"
           new sharedActions.ExtractTokenInfo(fakeGetWindowData));
 
         sinon.assert.calledOnce(fakeConversation.set);
         sinon.assert.calledWithExactly(fakeConversation.set, {
           loopToken: "fakeroomtoken"
         });
       });
 
-    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";
 
         store.extractTokenInfo(
           new sharedActions.ExtractTokenInfo(fakeGetWindowData));
 
         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));
 
         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"
+        }));
+    });
   });
 
 });
--- a/browser/components/loop/test/standalone/standaloneRoomViews_test.js
+++ b/browser/components/loop/test/standalone/standaloneRoomViews_test.js
@@ -6,16 +6,17 @@
 
 var expect = chai.expect;
 
 describe("loop.standaloneRoomViews", function() {
   "use strict";
 
   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;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
     dispatcher = new loop.Dispatcher();
     dispatch = sandbox.stub(dispatcher, "dispatch");
@@ -33,16 +34,54 @@ describe("loop.standaloneRoomViews", fun
     // Prevents audio request errors in the test console.
     sandbox.useFakeXMLHttpRequest();
   });
 
   afterEach(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(
         React.createElement(
           loop.standaloneRoomViews.StandaloneRoomView, {
             dispatcher: dispatcher,
             activeRoomStore: activeRoomStore,
             isFirefox: true
--- a/browser/components/loop/test/standalone/webapp_test.js
+++ b/browser/components/loop/test/standalone/webapp_test.js
@@ -66,29 +66,30 @@ describe("loop.webapp", function() {
       sinon.assert.calledOnce(React.render);
       sinon.assert.calledWith(React.render,
         sinon.match(function(value) {
           return TestUtils.isCompositeComponentElement(value,
             loop.webapp.WebappRootView);
       }));
     });
 
-    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"
         });
 
       loop.webapp.init();
 
       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"
         }));
     });
   });
 
   describe("OutgoingConversationView", function() {
     var ocView, conversation, client;
 
     function mountTestComponent(props) {