Bug 1162909: update the context views inside the Hello conversation window with latest UX updates. Make saving context data always work and more resilient to failure. r=Standard8
authorMike de Boer <mdeboer@mozilla.com>
Thu, 28 May 2015 11:04:42 +0200
changeset 246029 df0e99b0e7e59b046083d0990967551b3d39113f
parent 246028 9905af238c6c6bee2b2b11919a401136470d76fa
child 246030 9bd7e886acd38c872ecaeb600dab8561a1adc1c4
push id60333
push userryanvm@gmail.com
push dateThu, 28 May 2015 14:20:47 +0000
treeherdermozilla-inbound@8225a3b75df6 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersStandard8
bugs1162909
milestone41.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 1162909: update the context views inside the Hello conversation window with latest UX updates. Make saving context data always work and more resilient to failure. r=Standard8
browser/components/loop/content/js/conversation.js
browser/components/loop/content/js/conversation.jsx
browser/components/loop/content/js/roomStore.js
browser/components/loop/content/js/roomViews.js
browser/components/loop/content/js/roomViews.jsx
browser/components/loop/content/shared/css/conversation.css
browser/components/loop/content/shared/img/icons-10x10.svg
browser/components/loop/content/shared/js/actions.js
browser/components/loop/content/shared/js/views.js
browser/components/loop/content/shared/js/views.jsx
browser/components/loop/test/desktop-local/roomStore_test.js
browser/components/loop/test/desktop-local/roomViews_test.js
--- a/browser/components/loop/content/js/conversation.js
+++ b/browser/components/loop/content/js/conversation.js
@@ -158,17 +158,17 @@ loop.conversation = (function(mozL10n) {
       dispatcher.dispatch(new sharedActions.WindowUnload());
     });
 
     React.render(React.createElement(AppControllerView, {
       roomStore: roomStore, 
       dispatcher: dispatcher, 
       mozLoop: navigator.mozLoop}), document.querySelector("#main"));
 
-    document.body.setAttribute("dir", mozL10n.getDirection());
+    document.body.setAttribute("dir", "rtl");//mozL10n.getDirection());
     document.body.setAttribute("platform", loop.shared.utils.getPlatform());
 
     dispatcher.dispatch(new sharedActions.GetWindowData({
       windowId: windowId
     }));
   }
 
   return {
--- a/browser/components/loop/content/js/conversation.jsx
+++ b/browser/components/loop/content/js/conversation.jsx
@@ -158,17 +158,17 @@ loop.conversation = (function(mozL10n) {
       dispatcher.dispatch(new sharedActions.WindowUnload());
     });
 
     React.render(<AppControllerView
       roomStore={roomStore}
       dispatcher={dispatcher}
       mozLoop={navigator.mozLoop} />, document.querySelector("#main"));
 
-    document.body.setAttribute("dir", mozL10n.getDirection());
+    document.body.setAttribute("dir", "rtl");//mozL10n.getDirection());
     document.body.setAttribute("platform", loop.shared.utils.getPlatform());
 
     dispatcher.dispatch(new sharedActions.GetWindowData({
       windowId: windowId
     }));
   }
 
   return {
--- a/browser/components/loop/content/js/roomStore.js
+++ b/browser/components/loop/content/js/roomStore.js
@@ -86,16 +86,17 @@ loop.store = loop.store || {};
       "deleteRoom",
       "deleteRoomError",
       "emailRoomUrl",
       "getAllRooms",
       "getAllRoomsError",
       "openRoom",
       "shareRoomUrl",
       "updateRoomContext",
+      "updateRoomContextDone",
       "updateRoomContextError",
       "updateRoomList"
     ],
 
     initialize: function(options) {
       if (!options.mozLoop) {
         throw new Error("Missing option mozLoop");
       }
@@ -110,17 +111,18 @@ loop.store = loop.store || {};
     },
 
     getInitialStoreState: function() {
       return {
         activeRoom: this.activeRoomStore ? this.activeRoomStore.getStoreState() : {},
         error: null,
         pendingCreation: false,
         pendingInitialRetrieval: false,
-        rooms: []
+        rooms: [],
+        savingContext: false
       };
     },
 
     /**
      * Registers mozLoop.rooms events.
      */
     startListeningToRoomEvents: function() {
       // Rooms event registration
@@ -468,16 +470,17 @@ loop.store = loop.store || {};
     },
 
     /**
      * Updates the context data attached to a room.
      *
      * @param {sharedActions.UpdateRoomContext} actionData
      */
     updateRoomContext: function(actionData) {
+      this.setStoreState({ savingContext: true });
       this._mozLoop.rooms.get(actionData.roomToken, function(err, room) {
         if (err) {
           this.dispatchAction(new sharedActions.UpdateRoomContextError({
             error: err
           }));
           return;
         }
 
@@ -515,33 +518,43 @@ loop.store = loop.store || {};
         }
         // TODO: there currently is no clear UX defined on what to do when all
         // context data was cleared, e.g. when diff.removed contains all the
         // context properties. Until then, we can't deal with context removal here.
 
         // When no properties have been set on the roomData object, there's nothing
         // to save.
         if (!Object.getOwnPropertyNames(roomData).length) {
+          this.dispatchAction(new sharedActions.UpdateRoomContextDone());
           return;
         }
 
         this.setStoreState({error: null});
         this._mozLoop.rooms.update(actionData.roomToken, roomData,
           function(err, data) {
-            if (err) {
-              this.dispatchAction(new sharedActions.UpdateRoomContextError({
-                error: err
-              }));
-            }
+            var action = err ?
+              new sharedActions.UpdateRoomContextError({ error: err }) :
+              new sharedActions.UpdateRoomContextDone();
+            this.dispatchAction(action);
           }.bind(this));
       }.bind(this));
     },
 
     /**
+     * Handles the updateRoomContextDone action.
+     */
+    updateRoomContextDone: function() {
+      this.setStoreState({ savingContext: false });
+    },
+
+    /**
      * Updating the context data attached to a room error.
      *
      * @param {sharedActions.UpdateRoomContextError} actionData
      */
     updateRoomContextError: function(actionData) {
-      this.setStoreState({error: actionData.error});
+      this.setStoreState({
+        error: actionData.error,
+        savingContext: false
+      });
     }
   });
 })(document.mozL10n || navigator.mozL10n);
--- a/browser/components/loop/content/js/roomViews.js
+++ b/browser/components/loop/content/js/roomViews.js
@@ -24,16 +24,18 @@ loop.roomViews = (function(mozL10n) {
       roomStore: React.PropTypes.instanceOf(loop.store.RoomStore).isRequired
     },
 
     componentWillMount: function() {
       this.listenTo(this.props.roomStore, "change:activeRoom",
                     this._onActiveRoomStateChanged);
       this.listenTo(this.props.roomStore, "change:error",
                     this._onRoomError);
+      this.listenTo(this.props.roomStore, "change:savingContext",
+                    this._onRoomSavingContext);
     },
 
     componentWillUnmount: function() {
       this.stopListening(this.props.roomStore);
     },
 
     _onActiveRoomStateChanged: function() {
       // Only update the state if we're mounted, to avoid the problem where
@@ -48,21 +50,31 @@ loop.roomViews = (function(mozL10n) {
       // Only update the state if we're mounted, to avoid the problem where
       // stopListening doesn't nuke the active listeners during a event
       // processing.
       if (this.isMounted()) {
         this.setState({error: this.props.roomStore.getStoreState("error")});
       }
     },
 
+    _onRoomSavingContext: function() {
+      // Only update the state if we're mounted, to avoid the problem where
+      // stopListening doesn't nuke the active listeners during a event
+      // processing.
+      if (this.isMounted()) {
+        this.setState({savingContext: this.props.roomStore.getStoreState("savingContext")});
+      }
+    },
+
     getInitialState: function() {
       var storeState = this.props.roomStore.getStoreState("activeRoom");
       return _.extend({
         // Used by the UI showcase.
-        roomState: this.props.roomState || storeState.roomState
+        roomState: this.props.roomState || storeState.roomState,
+        savingContext: false
       }, storeState);
     }
   };
 
   var SocialShareDropdown = React.createClass({displayName: "SocialShareDropdown",
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       roomUrl: React.PropTypes.string,
@@ -169,16 +181,17 @@ loop.roomViews = (function(mozL10n) {
     mixins: [sharedMixins.DropdownMenuMixin(".room-invitation-overlay")],
 
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       error: React.PropTypes.object,
       mozLoop: React.PropTypes.object.isRequired,
       // This data is supplied by the activeRoomStore.
       roomData: React.PropTypes.object.isRequired,
+      savingContext: React.PropTypes.bool,
       show: React.PropTypes.bool.isRequired,
       showContext: React.PropTypes.bool.isRequired
     },
 
     getInitialState: function() {
       return {
         copiedUrl: false,
         editMode: false,
@@ -238,17 +251,21 @@ loop.roomViews = (function(mozL10n) {
             React.createElement("p", {className: cx({hide: this.state.editMode})}, 
               mozL10n.get("invite_header_text")
             ), 
             React.createElement("a", {className: cx({hide: !canAddContext, "room-invitation-addcontext": true}), 
                onClick: this.handleAddContextClick}, 
               mozL10n.get("context_add_some_label")
             )
           ), 
-          React.createElement("div", {className: "btn-group call-action-group"}, 
+          React.createElement("div", {className: cx({
+            "btn-group": true,
+            "call-action-group": true,
+            hide: this.state.editMode
+          })}, 
             React.createElement("button", {className: "btn btn-info btn-email", 
                     onClick: this.handleEmailButtonClick}, 
               mozL10n.get("email_link_button")
             ), 
             React.createElement("button", {className: "btn btn-info btn-copy", 
                     onClick: this.handleCopyButtonClick}, 
               this.state.copiedUrl ? mozL10n.get("copied_url_button") :
                                       mozL10n.get("copy_url_button2")
@@ -265,16 +282,17 @@ loop.roomViews = (function(mozL10n) {
             show: this.state.showMenu, 
             socialShareButtonAvailable: this.props.socialShareButtonAvailable, 
             socialShareProviders: this.props.socialShareProviders, 
             ref: "menu"}), 
           React.createElement(DesktopRoomContextView, {
             dispatcher: this.props.dispatcher, 
             editMode: this.state.editMode, 
             error: this.props.error, 
+            savingContext: this.props.savingContext, 
             mozLoop: this.props.mozLoop, 
             onEditModeChange: this.handleEditModeChange, 
             roomData: this.props.roomData, 
             show: this.props.showContext || this.state.editMode})
         )
       );
     }
   });
@@ -285,16 +303,17 @@ loop.roomViews = (function(mozL10n) {
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       editMode: React.PropTypes.bool,
       error: React.PropTypes.object,
       mozLoop: React.PropTypes.object.isRequired,
       onEditModeChange: React.PropTypes.func,
       // This data is supplied by the activeRoomStore.
       roomData: React.PropTypes.object.isRequired,
+      savingContext: React.PropTypes.bool.isRequired,
       show: React.PropTypes.bool.isRequired
     },
 
     componentWillReceiveProps: function(nextProps) {
       var newState = {};
       // When the 'show' prop is changed from outside this component, we do need
       // to update the state.
       if (("show" in nextProps) && nextProps.show !== this.props.show) {
@@ -374,16 +393,28 @@ loop.roomViews = (function(mozL10n) {
         if (this.props.onEditModeChange) {
           this.props.onEditModeChange(false);
         }
         return;
       }
       this.setState({ show: false });
     },
 
+    handleContextClick: function(event) {
+      event.stopPropagation();
+      event.preventDefault();
+
+      var url = this._getURL();
+      if (!url || !url.location) {
+        return;
+      }
+
+      this.props.mozLoop.openURL(url.location);
+    },
+
     handleEditClick: function(event) {
       event.preventDefault();
 
       this.setState({ editMode: true });
       if (this.props.onEditModeChange) {
         this.props.onEditModeChange(true);
       }
     },
@@ -392,23 +423,23 @@ loop.roomViews = (function(mozL10n) {
       if (state.checked) {
         // The checkbox was checked, prefill the fields with the values available
         // in `availableContext`.
         var context = this.state.availableContext;
         this.setState({
           newRoomURL: context.url,
           newRoomDescription: context.description,
           newRoomThumbnail: context.previewImage
-        }, this.handleFormSubmit);
+        });
       } else {
         this.setState({
           newRoomURL: "",
           newRoomDescription: "",
           newRoomThumbnail: ""
-        }, this.handleFormSubmit);
+        });
       }
     },
 
     handleFormSubmit: function(event) {
       event && event.preventDefault();
 
       this.props.dispatcher.dispatch(new sharedActions.UpdateRoomContext({
         roomToken: this.props.roomData.roomToken,
@@ -464,96 +495,100 @@ loop.roomViews = (function(mozL10n) {
       if (!this.state.show && !this.state.editMode) {
         return null;
       }
 
       var url = this._getURL();
       var thumbnail = url && url.thumbnail || "";
       var urlDescription = url && url.description || "";
       var location = url && url.location || "";
-      var checkboxLabel = null;
       var locationData = null;
       if (location) {
         locationData = checkboxLabel = sharedUtils.formatURL(location);
       }
       if (!checkboxLabel) {
         try {
           checkboxLabel = sharedUtils.formatURL((this.state.availableContext ?
             this.state.availableContext.url : ""));
         } catch (ex) {}
       }
 
       var cx = React.addons.classSet;
       if (this.state.editMode) {
+        var availableContext = this.state.availableContext;
+        // The checkbox shows as checked when there's already context data
+        // attached to this room.
+        var checked = !!urlDescription;
+        var checkboxLabel = urlDescription || (availableContext && availableContext.url ?
+          availableContext.description : "");
+
         return (
-          React.createElement("div", {className: "room-context"}, 
-            React.createElement("div", {className: "room-context-content"}, 
-              React.createElement("p", {className: cx({"error": !!this.props.error,
-                                "error-display-area": true})}, 
-                mozL10n.get("rooms_change_failed_label")
-              ), 
-              React.createElement("div", {className: "room-context-label"}, mozL10n.get("context_inroom_label")), 
-              React.createElement(sharedViews.Checkbox, {
-                checked: !!url, 
-                disabled: !!url || !checkboxLabel, 
-                label: mozL10n.get("context_edit_activate_label", {
-                  title: checkboxLabel ? checkboxLabel.hostname : ""
-                }), 
-                onChange: this.handleCheckboxChange, 
-                value: location}), 
-              React.createElement("form", {onSubmit: this.handleFormSubmit}, 
-                React.createElement("textarea", {rows: "2", type: "text", className: "room-context-name", 
-                  onBlur: this.handleFormSubmit, 
-                  onKeyDown: this.handleTextareaKeyDown, 
-                  placeholder: mozL10n.get("context_edit_name_placeholder"), 
-                  valueLink: this.linkState("newRoomName")}), 
-                React.createElement("input", {type: "text", className: "room-context-url", 
-                  onBlur: this.handleFormSubmit, 
-                  onKeyDown: this.handleTextareaKeyDown, 
-                  placeholder: "https://", 
-                  valueLink: this.linkState("newRoomURL")}), 
-                React.createElement("textarea", {rows: "4", type: "text", className: "room-context-comments", 
-                  onBlur: this.handleFormSubmit, 
-                  onKeyDown: this.handleTextareaKeyDown, 
-                  placeholder: mozL10n.get("context_edit_comments_placeholder"), 
-                  valueLink: this.linkState("newRoomDescription")})
-              ), 
-              React.createElement("button", {className: "room-context-btn-close", 
-                      onClick: this.handleCloseClick, 
-                      title: mozL10n.get("cancel_button")})
-            )
+          React.createElement("div", {className: "room-context editMode"}, 
+            React.createElement("p", {className: cx({"error": !!this.props.error,
+                              "error-display-area": true})}, 
+              mozL10n.get("rooms_change_failed_label")
+            ), 
+            React.createElement("div", {className: "room-context-label"}, mozL10n.get("context_inroom_label")), 
+            React.createElement(sharedViews.Checkbox, {
+              additionalClass: cx({ hide: !checkboxLabel }), 
+              checked: checked, 
+              disabled: checked, 
+              label: checkboxLabel, 
+              onChange: this.handleCheckboxChange, 
+              value: location}), 
+            React.createElement("form", {onSubmit: this.handleFormSubmit}, 
+              React.createElement("input", {type: "text", className: "room-context-name", 
+                onKeyDown: this.handleTextareaKeyDown, 
+                placeholder: mozL10n.get("context_edit_name_placeholder"), 
+                valueLink: this.linkState("newRoomName")}), 
+              React.createElement("input", {type: "text", className: "room-context-url", 
+                onKeyDown: this.handleTextareaKeyDown, 
+                placeholder: "https://", 
+                disabled: availableContext && availableContext.url === this.state.newRoomURL, 
+                valueLink: this.linkState("newRoomURL")}), 
+              React.createElement("textarea", {rows: "3", type: "text", className: "room-context-comments", 
+                onKeyDown: this.handleTextareaKeyDown, 
+                placeholder: mozL10n.get("context_edit_comments_placeholder"), 
+                valueLink: this.linkState("newRoomDescription")})
+            ), 
+            React.createElement("button", {className: "btn btn-info", 
+                    disabled: this.props.savingContext, 
+                    onClick: this.handleFormSubmit}, 
+              mozL10n.get("context_save_label")
+            ), 
+            React.createElement("button", {className: "room-context-btn-close", 
+                    onClick: this.handleCloseClick, 
+                    title: mozL10n.get("cancel_button")})
           )
         );
       }
 
       if (!locationData) {
         return null;
       }
 
       return (
         React.createElement("div", {className: "room-context"}, 
-          React.createElement("img", {className: "room-context-thumbnail", src: thumbnail}), 
-          React.createElement("div", {className: "room-context-content"}, 
-            React.createElement("div", {className: "room-context-label"}, mozL10n.get("context_inroom_label")), 
+          React.createElement("div", {className: "room-context-label"}, mozL10n.get("context_inroom_label")), 
+          React.createElement("div", {className: "room-context-content", 
+               onClick: this.handleContextClick}, 
+            React.createElement("img", {className: "room-context-thumbnail", src: thumbnail}), 
             React.createElement("div", {className: "room-context-description", 
-                 title: urlDescription}, this._truncate(urlDescription)), 
-            React.createElement("a", {className: "room-context-url", 
-               href: location, 
-               target: "_blank", 
-               title: locationData.location}, locationData.hostname), 
-            this.props.roomData.roomDescription ?
-              React.createElement("div", {className: "room-context-comment"}, this.props.roomData.roomDescription) :
-              null, 
-            React.createElement("button", {className: "room-context-btn-close", 
-                    onClick: this.handleCloseClick, 
-                    title: mozL10n.get("context_hide_tooltip")}), 
-            React.createElement("button", {className: "room-context-btn-edit", 
-                    onClick: this.handleEditClick, 
-                    title: mozL10n.get("context_edit_tooltip")})
-          )
+                 title: urlDescription}, 
+              this._truncate(urlDescription), 
+              React.createElement("a", {className: "room-context-url", 
+                 title: locationData.location}, locationData.hostname)
+            )
+          ), 
+          React.createElement("button", {className: "room-context-btn-close", 
+                  onClick: this.handleCloseClick, 
+                  title: mozL10n.get("context_hide_tooltip")}), 
+          React.createElement("button", {className: "room-context-btn-edit", 
+                  onClick: this.handleEditClick, 
+                  title: mozL10n.get("context_edit_tooltip")})
         )
       );
     }
   });
 
   /**
    * Desktop room conversation view.
    */
@@ -666,16 +701,17 @@ loop.roomViews = (function(mozL10n) {
           return (
             React.createElement("div", {className: "room-conversation-wrapper"}, 
               React.createElement(sharedViews.TextChatView, {dispatcher: this.props.dispatcher}), 
               React.createElement(DesktopRoomInvitationView, {
                 dispatcher: this.props.dispatcher, 
                 error: this.state.error, 
                 mozLoop: this.props.mozLoop, 
                 roomData: roomData, 
+                savingContext: this.state.savingContext, 
                 show: shouldRenderInvitationOverlay, 
                 showContext: shouldRenderContextView, 
                 socialShareButtonAvailable: this.state.socialShareButtonAvailable, 
                 socialShareProviders: this.state.socialShareProviders}), 
               React.createElement("div", {className: "video-layout-wrapper"}, 
                 React.createElement("div", {className: "conversation room-conversation"}, 
                   React.createElement("div", {className: "media nested"}, 
                     React.createElement("div", {className: "video_wrapper remote_wrapper"}, 
@@ -691,16 +727,17 @@ loop.roomViews = (function(mozL10n) {
                     publishStream: this.publishStream, 
                     hangup: this.leaveRoom, 
                     screenShare: screenShareData})
                 )
               ), 
               React.createElement(DesktopRoomContextView, {
                 dispatcher: this.props.dispatcher, 
                 error: this.state.error, 
+                savingContext: this.state.savingContext, 
                 mozLoop: this.props.mozLoop, 
                 roomData: roomData, 
                 show: !shouldRenderInvitationOverlay && shouldRenderContextView})
             )
           );
         }
       }
     }
--- a/browser/components/loop/content/js/roomViews.jsx
+++ b/browser/components/loop/content/js/roomViews.jsx
@@ -24,16 +24,18 @@ loop.roomViews = (function(mozL10n) {
       roomStore: React.PropTypes.instanceOf(loop.store.RoomStore).isRequired
     },
 
     componentWillMount: function() {
       this.listenTo(this.props.roomStore, "change:activeRoom",
                     this._onActiveRoomStateChanged);
       this.listenTo(this.props.roomStore, "change:error",
                     this._onRoomError);
+      this.listenTo(this.props.roomStore, "change:savingContext",
+                    this._onRoomSavingContext);
     },
 
     componentWillUnmount: function() {
       this.stopListening(this.props.roomStore);
     },
 
     _onActiveRoomStateChanged: function() {
       // Only update the state if we're mounted, to avoid the problem where
@@ -48,21 +50,31 @@ loop.roomViews = (function(mozL10n) {
       // Only update the state if we're mounted, to avoid the problem where
       // stopListening doesn't nuke the active listeners during a event
       // processing.
       if (this.isMounted()) {
         this.setState({error: this.props.roomStore.getStoreState("error")});
       }
     },
 
+    _onRoomSavingContext: function() {
+      // Only update the state if we're mounted, to avoid the problem where
+      // stopListening doesn't nuke the active listeners during a event
+      // processing.
+      if (this.isMounted()) {
+        this.setState({savingContext: this.props.roomStore.getStoreState("savingContext")});
+      }
+    },
+
     getInitialState: function() {
       var storeState = this.props.roomStore.getStoreState("activeRoom");
       return _.extend({
         // Used by the UI showcase.
-        roomState: this.props.roomState || storeState.roomState
+        roomState: this.props.roomState || storeState.roomState,
+        savingContext: false
       }, storeState);
     }
   };
 
   var SocialShareDropdown = React.createClass({
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       roomUrl: React.PropTypes.string,
@@ -169,16 +181,17 @@ loop.roomViews = (function(mozL10n) {
     mixins: [sharedMixins.DropdownMenuMixin(".room-invitation-overlay")],
 
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       error: React.PropTypes.object,
       mozLoop: React.PropTypes.object.isRequired,
       // This data is supplied by the activeRoomStore.
       roomData: React.PropTypes.object.isRequired,
+      savingContext: React.PropTypes.bool,
       show: React.PropTypes.bool.isRequired,
       showContext: React.PropTypes.bool.isRequired
     },
 
     getInitialState: function() {
       return {
         copiedUrl: false,
         editMode: false,
@@ -238,17 +251,21 @@ loop.roomViews = (function(mozL10n) {
             <p className={cx({hide: this.state.editMode})}>
               {mozL10n.get("invite_header_text")}
             </p>
             <a className={cx({hide: !canAddContext, "room-invitation-addcontext": true})}
                onClick={this.handleAddContextClick}>
               {mozL10n.get("context_add_some_label")}
             </a>
           </div>
-          <div className="btn-group call-action-group">
+          <div className={cx({
+            "btn-group": true,
+            "call-action-group": true,
+            hide: this.state.editMode
+          })}>
             <button className="btn btn-info btn-email"
                     onClick={this.handleEmailButtonClick}>
               {mozL10n.get("email_link_button")}
             </button>
             <button className="btn btn-info btn-copy"
                     onClick={this.handleCopyButtonClick}>
               {this.state.copiedUrl ? mozL10n.get("copied_url_button") :
                                       mozL10n.get("copy_url_button2")}
@@ -265,16 +282,17 @@ loop.roomViews = (function(mozL10n) {
             show={this.state.showMenu}
             socialShareButtonAvailable={this.props.socialShareButtonAvailable}
             socialShareProviders={this.props.socialShareProviders}
             ref="menu" />
           <DesktopRoomContextView
             dispatcher={this.props.dispatcher}
             editMode={this.state.editMode}
             error={this.props.error}
+            savingContext={this.props.savingContext}
             mozLoop={this.props.mozLoop}
             onEditModeChange={this.handleEditModeChange}
             roomData={this.props.roomData}
             show={this.props.showContext || this.state.editMode} />
         </div>
       );
     }
   });
@@ -285,16 +303,17 @@ loop.roomViews = (function(mozL10n) {
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       editMode: React.PropTypes.bool,
       error: React.PropTypes.object,
       mozLoop: React.PropTypes.object.isRequired,
       onEditModeChange: React.PropTypes.func,
       // This data is supplied by the activeRoomStore.
       roomData: React.PropTypes.object.isRequired,
+      savingContext: React.PropTypes.bool.isRequired,
       show: React.PropTypes.bool.isRequired
     },
 
     componentWillReceiveProps: function(nextProps) {
       var newState = {};
       // When the 'show' prop is changed from outside this component, we do need
       // to update the state.
       if (("show" in nextProps) && nextProps.show !== this.props.show) {
@@ -374,16 +393,28 @@ loop.roomViews = (function(mozL10n) {
         if (this.props.onEditModeChange) {
           this.props.onEditModeChange(false);
         }
         return;
       }
       this.setState({ show: false });
     },
 
+    handleContextClick: function(event) {
+      event.stopPropagation();
+      event.preventDefault();
+
+      var url = this._getURL();
+      if (!url || !url.location) {
+        return;
+      }
+
+      this.props.mozLoop.openURL(url.location);
+    },
+
     handleEditClick: function(event) {
       event.preventDefault();
 
       this.setState({ editMode: true });
       if (this.props.onEditModeChange) {
         this.props.onEditModeChange(true);
       }
     },
@@ -392,23 +423,23 @@ loop.roomViews = (function(mozL10n) {
       if (state.checked) {
         // The checkbox was checked, prefill the fields with the values available
         // in `availableContext`.
         var context = this.state.availableContext;
         this.setState({
           newRoomURL: context.url,
           newRoomDescription: context.description,
           newRoomThumbnail: context.previewImage
-        }, this.handleFormSubmit);
+        });
       } else {
         this.setState({
           newRoomURL: "",
           newRoomDescription: "",
           newRoomThumbnail: ""
-        }, this.handleFormSubmit);
+        });
       }
     },
 
     handleFormSubmit: function(event) {
       event && event.preventDefault();
 
       this.props.dispatcher.dispatch(new sharedActions.UpdateRoomContext({
         roomToken: this.props.roomData.roomToken,
@@ -464,96 +495,100 @@ loop.roomViews = (function(mozL10n) {
       if (!this.state.show && !this.state.editMode) {
         return null;
       }
 
       var url = this._getURL();
       var thumbnail = url && url.thumbnail || "";
       var urlDescription = url && url.description || "";
       var location = url && url.location || "";
-      var checkboxLabel = null;
       var locationData = null;
       if (location) {
         locationData = checkboxLabel = sharedUtils.formatURL(location);
       }
       if (!checkboxLabel) {
         try {
           checkboxLabel = sharedUtils.formatURL((this.state.availableContext ?
             this.state.availableContext.url : ""));
         } catch (ex) {}
       }
 
       var cx = React.addons.classSet;
       if (this.state.editMode) {
+        var availableContext = this.state.availableContext;
+        // The checkbox shows as checked when there's already context data
+        // attached to this room.
+        var checked = !!urlDescription;
+        var checkboxLabel = urlDescription || (availableContext && availableContext.url ?
+          availableContext.description : "");
+
         return (
-          <div className="room-context">
-            <div className="room-context-content">
-              <p className={cx({"error": !!this.props.error,
-                                "error-display-area": true})}>
-                {mozL10n.get("rooms_change_failed_label")}
-              </p>
-              <div className="room-context-label">{mozL10n.get("context_inroom_label")}</div>
-              <sharedViews.Checkbox
-                checked={!!url}
-                disabled={!!url || !checkboxLabel}
-                label={mozL10n.get("context_edit_activate_label", {
-                  title: checkboxLabel ? checkboxLabel.hostname : ""
-                })}
-                onChange={this.handleCheckboxChange}
-                value={location} />
-              <form onSubmit={this.handleFormSubmit}>
-                <textarea rows="2" type="text" className="room-context-name"
-                  onBlur={this.handleFormSubmit}
-                  onKeyDown={this.handleTextareaKeyDown}
-                  placeholder={mozL10n.get("context_edit_name_placeholder")}
-                  valueLink={this.linkState("newRoomName")} />
-                <input type="text" className="room-context-url"
-                  onBlur={this.handleFormSubmit}
-                  onKeyDown={this.handleTextareaKeyDown}
-                  placeholder="https://"
-                  valueLink={this.linkState("newRoomURL")} />
-                <textarea rows="4" type="text" className="room-context-comments"
-                  onBlur={this.handleFormSubmit}
-                  onKeyDown={this.handleTextareaKeyDown}
-                  placeholder={mozL10n.get("context_edit_comments_placeholder")}
-                  valueLink={this.linkState("newRoomDescription")} />
-              </form>
-              <button className="room-context-btn-close"
-                      onClick={this.handleCloseClick}
-                      title={mozL10n.get("cancel_button")}/>
-            </div>
+          <div className="room-context editMode">
+            <p className={cx({"error": !!this.props.error,
+                              "error-display-area": true})}>
+              {mozL10n.get("rooms_change_failed_label")}
+            </p>
+            <div className="room-context-label">{mozL10n.get("context_inroom_label")}</div>
+            <sharedViews.Checkbox
+              additionalClass={cx({ hide: !checkboxLabel })}
+              checked={checked}
+              disabled={checked}
+              label={checkboxLabel}
+              onChange={this.handleCheckboxChange}
+              value={location} />
+            <form onSubmit={this.handleFormSubmit}>
+              <input type="text" className="room-context-name"
+                onKeyDown={this.handleTextareaKeyDown}
+                placeholder={mozL10n.get("context_edit_name_placeholder")}
+                valueLink={this.linkState("newRoomName")} />
+              <input type="text" className="room-context-url"
+                onKeyDown={this.handleTextareaKeyDown}
+                placeholder="https://"
+                disabled={availableContext && availableContext.url === this.state.newRoomURL}
+                valueLink={this.linkState("newRoomURL")} />
+              <textarea rows="3" type="text" className="room-context-comments"
+                onKeyDown={this.handleTextareaKeyDown}
+                placeholder={mozL10n.get("context_edit_comments_placeholder")}
+                valueLink={this.linkState("newRoomDescription")} />
+            </form>
+            <button className="btn btn-info"
+                    disabled={this.props.savingContext}
+                    onClick={this.handleFormSubmit}>
+              {mozL10n.get("context_save_label")}
+            </button>
+            <button className="room-context-btn-close"
+                    onClick={this.handleCloseClick}
+                    title={mozL10n.get("cancel_button")}/>
           </div>
         );
       }
 
       if (!locationData) {
         return null;
       }
 
       return (
         <div className="room-context">
-          <img className="room-context-thumbnail" src={thumbnail}/>
-          <div className="room-context-content">
-            <div className="room-context-label">{mozL10n.get("context_inroom_label")}</div>
+          <div className="room-context-label">{mozL10n.get("context_inroom_label")}</div>
+          <div className="room-context-content"
+               onClick={this.handleContextClick}>
+            <img className="room-context-thumbnail" src={thumbnail}/>
             <div className="room-context-description"
-                 title={urlDescription}>{this._truncate(urlDescription)}</div>
-            <a className="room-context-url"
-               href={location}
-               target="_blank"
-               title={locationData.location}>{locationData.hostname}</a>
-            {this.props.roomData.roomDescription ?
-              <div className="room-context-comment">{this.props.roomData.roomDescription}</div> :
-              null}
-            <button className="room-context-btn-close"
-                    onClick={this.handleCloseClick}
-                    title={mozL10n.get("context_hide_tooltip")}/>
-            <button className="room-context-btn-edit"
-                    onClick={this.handleEditClick}
-                    title={mozL10n.get("context_edit_tooltip")}/>
+                 title={urlDescription}>
+              {this._truncate(urlDescription)}
+              <a className="room-context-url"
+                 title={locationData.location}>{locationData.hostname}</a>
+            </div>
           </div>
+          <button className="room-context-btn-close"
+                  onClick={this.handleCloseClick}
+                  title={mozL10n.get("context_hide_tooltip")}/>
+          <button className="room-context-btn-edit"
+                  onClick={this.handleEditClick}
+                  title={mozL10n.get("context_edit_tooltip")}/>
         </div>
       );
     }
   });
 
   /**
    * Desktop room conversation view.
    */
@@ -666,16 +701,17 @@ loop.roomViews = (function(mozL10n) {
           return (
             <div className="room-conversation-wrapper">
               <sharedViews.TextChatView dispatcher={this.props.dispatcher} />
               <DesktopRoomInvitationView
                 dispatcher={this.props.dispatcher}
                 error={this.state.error}
                 mozLoop={this.props.mozLoop}
                 roomData={roomData}
+                savingContext={this.state.savingContext}
                 show={shouldRenderInvitationOverlay}
                 showContext={shouldRenderContextView}
                 socialShareButtonAvailable={this.state.socialShareButtonAvailable}
                 socialShareProviders={this.state.socialShareProviders} />
               <div className="video-layout-wrapper">
                 <div className="conversation room-conversation">
                   <div className="media nested">
                     <div className="video_wrapper remote_wrapper">
@@ -691,16 +727,17 @@ loop.roomViews = (function(mozL10n) {
                     publishStream={this.publishStream}
                     hangup={this.leaveRoom}
                     screenShare={screenShareData} />
                 </div>
               </div>
               <DesktopRoomContextView
                 dispatcher={this.props.dispatcher}
                 error={this.state.error}
+                savingContext={this.state.savingContext}
                 mozLoop={this.props.mozLoop}
                 roomData={roomData}
                 show={!shouldRenderInvitationOverlay && shouldRenderContextView} />
             </div>
           );
         }
       }
     }
--- a/browser/components/loop/content/shared/css/conversation.css
+++ b/browser/components/loop/content/shared/css/conversation.css
@@ -308,17 +308,18 @@
 }
 
 .call-action-group {
   display: flex;
   padding: 2.5em 4px 0 4px;
   width: 100%;
 }
 
-.call-action-group > .btn {
+.call-action-group > .btn,
+.room-context > .btn {
   min-height: 26px;
   border-radius: 2px;
   margin: 0 4px;
   min-width: 64px;
 }
 
 .call-action-group .btn-group-chevron,
 .call-action-group .btn-group {
@@ -1000,157 +1001,198 @@ body[dir=rtl] .share-service-dropdown .s
   padding: .5rem;
   max-height: 400px;
   position: absolute;
   left: 0;
   bottom: 0;
   width: 100%;
   font-size: .9em;
   display: flex;
-  flex-flow: row nowrap;
+  flex-flow: column nowrap;
   align-content: flex-start;
   align-items: flex-start;
   overflow-x: hidden;
   overflow-y: auto;
+  /* Make the context view float atop the video elements. */
+  z-index: 2;
+}
+
+.room-context.editMode {
+  /* Stretch to the maximum available space whilst not covering the conversation
+     toolbar (26px). */
+  height: calc(100% - 26px);
 }
 
 .room-invitation-overlay .room-context {
   position: relative;
   left: auto;
   bottom: auto;
   flex: 0 1 auto;
 }
 
+.room-invitation-overlay .room-context.editMode {
+  height: 100%;
+}
+
+.room-context-content {
+  flex: 1 1 auto;
+  text-align: start;
+  display: flex;
+  flex-flow: row nowrap;
+  font-size: .9em;
+}
+
 .room-context-thumbnail {
-  width: 16px;
+  /* 16px icon size + 3px border width. */
+  width: 19px;
+  max-height: 19px;
+  border: 3px solid #fff;
+  border-radius: 3px;
+  background-color: #fff;
   -moz-margin-end: 1ch;
-  margin-bottom: 1em;
-  order: 1;
   flex: 0 1 auto;
 }
 
 .room-context-thumbnail[src=""] {
   display: none;
 }
 
-.room-context-content {
-  order: 2;
-  flex: 1 1 auto;
-  text-align: start;
-}
-
-.room-context-content > .error-display-area.error {
+.room-context > .error-display-area.error {
   display: block;
   background-color: rgba(215,67,69,.8);
   border-radius: 3px;
   padding: .5em;
 }
 
-.room-context-content > .error-display-area {
+.room-context > .error-display-area {
   display: none;
 }
 
-.room-context-content > .error-display-area.error {
+.room-context > .error-display-area.error {
   margin: 1em 0 .5em 0;
   text-align: center;
   text-shadow: 1px 1px 0 rgba(0,0,0,.3);
 }
 
-.room-context-content > .checkbox-wrapper {
+.room-context > .checkbox-wrapper {
   margin-bottom: .5em;
 }
 
 .room-context-label {
   margin-bottom: 1em;
 }
 
-.room-context-label,
 .room-context-description,
-.room-context-content > .checkbox-wrapper > label {
+.room-context > .checkbox-wrapper > label {
   color: #fff;
 }
 
 .room-context-comment {
   color: #707070;
 }
 
 .room-context-description,
 .room-context-comment {
   word-wrap: break-word;
 }
 
-.room-context-url {
-  color: #59A1D7;
+:not(input).room-context-url {
+  color: #0095dd;
   font-style: italic;
   text-decoration: none;
-  margin-bottom: 1em;
+  display: block;
+  cursor: pointer;
 }
 
 .room-context-url:hover {
   text-decoration: underline;
 }
 
-.room-context-content > form > textarea,
-.room-context-content > form > input[type="text"] {
+.room-context > form {
+  width: 100%;
+}
+
+.room-context > form > textarea,
+.room-context > form > input[type="text"] {
   display: block;
   background: rgba(0,0,0,.5);
-  color: #fff;
   font-family: "Helvetica Neue", Arial, sans;
-  font-size: 1em;
-  border: 1px solid #999;
+  border: 1px solid rgba(255,255,255,.2);
   width: 100%;
-  padding: .2em .4em;
+  padding: .5em;
   border-radius: 3px;
   resize: none;
+  color: #fff;
+}
+
+.room-context > form > textarea {
+  font-size: 1em;
+}
+
+.room-context > form > input:not([disabled]).room-context-url {
+  color: #0095dd;
 }
 
-.room-context-content > form > textarea:not(:last-of-type),
-.room-context-content > form > input[type="text"] {
+.room-context > form > input[disabled] {
+  background-color: rgba(255,255,255,.2);
+  color: rgba(255,255,255,.4);
+}
+
+.room-context > form > textarea:not(:last-of-type),
+.room-context > form > input[type="text"] {
   margin: 0 0 .5em 0;
 }
 
+.room-context > .btn {
+  margin: .5em 0 0;
+  font-size: 1.1em;
+  padding: 0 .5em;
+  align-self: flex-end;
+}
+
 .room-context-btn-close,
 .room-context-btn-edit {
   position: absolute;
-  right: 5px;
-  top: 5px;
+  right: 8px;
+  /* 8px offset + 2px border-top */
+  top: 10px;
   width: 8px;
   height: 8px;
   background-color: transparent;
-  background-image: url("../img/icons-10x10.svg#close");
+  background-image: url("../img/icons-10x10.svg#close-darkergrey");
   background-size: 8px 8px;
   background-repeat: no-repeat;
   border: 0;
   padding: 0;
   cursor: pointer;
 }
 
 .room-context-btn-edit {
-  right: 18px;
-  background-image: url("../img/icons-10x10.svg#edit");
+  right: 20px;
+  background-image: url("../img/icons-10x10.svg#edit-darkergrey");
 }
 
 .room-context-btn-edit:hover,
 .room-context-btn-edit:hover:active {
   background-image: url("../img/icons-10x10.svg#edit-active");
 }
 
 .room-context-btn-close:hover,
 .room-context-btn-close:hover:active {
   background-image: url("../img/icons-10x10.svg#close-active");
 }
 
 body[dir=rtl] .room-context-btn-close,
 body[dir=rtl] .room-context-btn-edit {
   right: auto;
-  left: 5px;
+  left: 8px;
 }
 
 body[dir=rtl] .room-context-btn-edit {
-  left: 18px;
+  left: 20px;
 }
 
 /* Standalone rooms */
 
 .standalone .room-conversation-wrapper {
   position: relative;
   height: 100%;
   background: #000;
--- a/browser/components/loop/content/shared/img/icons-10x10.svg
+++ b/browser/components/loop/content/shared/img/icons-10x10.svg
@@ -17,33 +17,38 @@
       fill: #0095dd;
     }
     use[id$="-white"] {
       fill: rgba(255,255,255,0.8);
     }
     use[id$="-disabled"] {
       fill: rgba(255,255,255,0.4);
     }
+    use[id$="-darkergrey"] {
+      fill: #999;
+    }
   </style>
   <defs>
     <polygon id="close-shape" points="10,1.717 8.336,0.049 5.024,3.369 1.663,0 0,1.668 3.36,5.037 0.098,8.307 1.762,9.975 5.025,6.705 8.311,10 9.975,8.332 6.688,5.037"/>
     <path id="dropdown-shape" fill-rule="evenodd" d="M9,3L4.984,7L1,3H9z"/>
     <polygon id="expand-shape" points="10,0 4.838,0 6.506,1.669 0,8.175 1.825,10 8.331,3.494 10,5.162"/>
     <path id="edit-shape" d="M5.493,1.762l2.745,2.745L2.745,10H0V7.255L5.493,1.762z M2.397,9.155l0.601-0.601L1.446,7.002L0.845,7.603 V8.31H1.69v0.845H2.397z M5.849,3.028c0-0.096-0.048-0.144-0.146-0.144c-0.044,0-0.081,0.015-0.112,0.046L2.014,6.508 C1.983,6.538,1.968,6.577,1.968,6.619c0,0.098,0.048,0.146,0.144,0.146c0.044,0,0.081-0.015,0.112-0.046l3.579-3.577 C5.834,3.111,5.849,3.073,5.849,3.028z M10,2.395c0,0.233-0.081,0.431-0.245,0.595L8.66,4.085L5.915,1.34L7.01,0.25 C7.168,0.083,7.366,0,7.605,0c0.233,0,0.433,0.083,0.601,0.25l1.55,1.544C9.919,1.966,10,2.166,10,2.395z"/>
     <rect id="minimize-shape" y="3.6" width="10" height="2.8"/>
   </defs>
   <use id="close" xlink:href="#close-shape"/>
   <use id="close-active" xlink:href="#close-shape"/>
   <use id="close-disabled" xlink:href="#close-shape"/>
+  <use id="close-darkergrey" xlink:href="#close-shape"/>
   <use id="dropdown" xlink:href="#dropdown-shape"/>
   <use id="dropdown-white" xlink:href="#dropdown-shape"/>
   <use id="dropdown-active" xlink:href="#dropdown-shape"/>
   <use id="dropdown-disabled" xlink:href="#dropdown-shape"/>
   <use id="edit" xlink:href="#edit-shape"/>
   <use id="edit-active" xlink:href="#edit-shape"/>
   <use id="edit-disabled" xlink:href="#edit-shape"/>
+  <use id="edit-darkergrey" xlink:href="#edit-shape"/>
   <use id="expand" xlink:href="#expand-shape"/>
   <use id="expand-active" xlink:href="#expand-shape"/>
   <use id="expand-disabled" xlink:href="#expand-shape"/>
   <use id="minimize" xlink:href="#minimize-shape"/>
   <use id="minimize-active" xlink:href="#minimize-shape"/>
   <use id="minimize-disabled" xlink:href="#minimize-shape"/>
 </svg>
--- a/browser/components/loop/content/shared/js/actions.js
+++ b/browser/components/loop/content/shared/js/actions.js
@@ -364,16 +364,22 @@ loop.shared.actions = (function() {
     /**
      * Updating the context data attached to a room error.
      */
     UpdateRoomContextError: Action.define("updateRoomContextError", {
       error: [Error, Object]
     }),
 
     /**
+     * Updating the context data attached to a room finished successfully.
+     */
+    UpdateRoomContextDone: Action.define("updateRoomContextDone", {
+    }),
+
+    /**
      * Copy a room url into the user's clipboard.
      * XXX: should move to some roomActions module - refs bug 1079284
      */
     CopyRoomUrl: Action.define("copyRoomUrl", {
       roomUrl: String
     }),
 
     /**
--- a/browser/components/loop/content/shared/js/views.js
+++ b/browser/components/loop/content/shared/js/views.js
@@ -658,17 +658,17 @@ loop.shared.views = (function(_, l10n) {
         disabled: this.props.disabled
       };
       var checkClasses = {
         checkbox: true,
         checked: this.state.checked,
         disabled: this.props.disabled
       };
       if (this.props.additionalClass) {
-        checkClasses[this.props.additionalClass] = true;
+        wrapperClasses[this.props.additionalClass] = true;
       }
       return (
         React.createElement("div", {className: cx(wrapperClasses), 
              disabled: this.props.disabled, 
              onClick: this._handleClick}, 
           React.createElement("div", {className: cx(checkClasses)}), 
           this.props.label ?
             React.createElement("label", null, this.props.label) :
--- a/browser/components/loop/content/shared/js/views.jsx
+++ b/browser/components/loop/content/shared/js/views.jsx
@@ -658,17 +658,17 @@ loop.shared.views = (function(_, l10n) {
         disabled: this.props.disabled
       };
       var checkClasses = {
         checkbox: true,
         checked: this.state.checked,
         disabled: this.props.disabled
       };
       if (this.props.additionalClass) {
-        checkClasses[this.props.additionalClass] = true;
+        wrapperClasses[this.props.additionalClass] = true;
       }
       return (
         <div className={cx(wrapperClasses)}
              disabled={this.props.disabled}
              onClick={this._handleClick}>
           <div className={cx(checkClasses)} />
           {this.props.label ?
             <label>{this.props.label}</label> :
--- a/browser/components/loop/test/desktop-local/roomStore_test.js
+++ b/browser/components/loop/test/desktop-local/roomStore_test.js
@@ -639,39 +639,59 @@ describe("loop.store.RoomStore", functio
 
       sinon.assert.calledOnce(fakeMozLoop.rooms.get);
       sinon.assert.calledOnce(fakeMozLoop.rooms.update);
       sinon.assert.calledWith(fakeMozLoop.rooms.update, "42abc", {
         roomName: "silly name"
       });
     });
 
+    it("should flag the the store as saving context", function() {
+      expect(store.getStoreState().savingContext).to.eql(false);
+
+      sandbox.stub(fakeMozLoop.rooms, "update", function(roomToken, roomData, cb) {
+        expect(store.getStoreState().savingContext).to.eql(true);
+        cb();
+      });
+
+      dispatcher.dispatch(new sharedActions.UpdateRoomContext({
+        roomToken: "42abc",
+        newRoomName: "silly name"
+      }));
+
+      expect(store.getStoreState().savingContext).to.eql(false);
+    });
+
     it("should store any update-encountered error", function() {
       var err = new Error("fake");
       sandbox.stub(fakeMozLoop.rooms, "update", function(roomToken, roomData, cb) {
+        expect(store.getStoreState().savingContext).to.eql(true);
         cb(err);
       });
 
       dispatcher.dispatch(new sharedActions.UpdateRoomContext({
         roomToken: "42abc",
         newRoomName: "silly name"
       }));
 
-      expect(store.getStoreState().error).eql(err);
+      var state = store.getStoreState();
+      expect(state.error).eql(err);
+      expect(state.savingContext).to.eql(false);
     });
 
     it("should ensure only submitting a non-empty room name", function() {
       fakeMozLoop.rooms.update = sinon.spy();
 
       dispatcher.dispatch(new sharedActions.UpdateRoomContext({
         roomToken: "42abc",
         newRoomName: " \t  \t "
       }));
 
       sinon.assert.notCalled(fakeMozLoop.rooms.update);
+      expect(store.getStoreState().savingContext).to.eql(false);
     });
 
     it("should save updated context information", function() {
       fakeMozLoop.rooms.update = sinon.spy();
 
       dispatcher.dispatch(new sharedActions.UpdateRoomContext({
         roomToken: "42abc",
         // Room name doesn't need to change.
@@ -718,11 +738,12 @@ describe("loop.store.RoomStore", functio
           // Room name doesn't need to change.
           newRoomName: "sillier name",
           newRoomDescription: "",
           newRoomThumbnail: "",
           newRoomURL: ""
         }));
 
         sinon.assert.notCalled(fakeMozLoop.rooms.update);
+        expect(store.getStoreState().savingContext).to.eql(false);
       });
   });
 });
--- a/browser/components/loop/test/desktop-local/roomViews_test.js
+++ b/browser/components/loop/test/desktop-local/roomViews_test.js
@@ -91,17 +91,17 @@ describe("loop.roomViews", function () {
         render: function() { return React.DOM.div(); }
       });
 
       var testView = TestUtils.renderIntoDocument(
         React.createElement(TestView, {
           roomStore: roomStore
         }));
 
-      var expectedState = _.extend({foo: "bar"},
+      var expectedState = _.extend({foo: "bar", savingContext: false},
         activeRoomStore.getInitialStoreState());
 
       expect(testView.state).eql(expectedState);
     });
 
     it("should listen to store changes", function() {
       var TestView = React.createClass({
         mixins: [loop.roomViews.ActiveRoomStoreMixin],
@@ -671,20 +671,18 @@ describe("loop.roomViews", function () {
             roomContextUrls: [fakeContextURL]
           }
         });
 
         var node = view.getDOMNode();
         expect(node).to.not.eql(null);
         expect(node.querySelector(".room-context-thumbnail").src).to.
           eql(fakeContextURL.thumbnail);
-        expect(node.querySelector(".room-context-description").textContent).to.
-          eql(fakeContextURL.description);
-        expect(node.querySelector(".room-context-comment").textContent).to.
-          eql(view.props.roomData.roomDescription);
+        expect(node.querySelector(".room-context-description").firstChild.textContent).
+          to.eql(fakeContextURL.description);
       });
 
       it("should not render optional data", function() {
         view = mountTestComponent({
           roomData: { roomContextUrls: [fakeContextURL] }
         });
 
         expect(view.getDOMNode().querySelector(".room-context-comment")).to.
@@ -722,22 +720,23 @@ describe("loop.roomViews", function () {
         var node = view.getDOMNode();
         expect(node.querySelector("form")).to.not.eql(null);
         // Check the contents of the form fields.
         expect(node.querySelector(".room-context-name").value).to.eql(roomName);
         expect(node.querySelector(".room-context-url").value).to.eql(fakeContextURL.location);
         expect(node.querySelector(".room-context-comments").value).to.eql(fakeContextURL.description);
       });
 
-      it("should show the checkbox as disabled when no context is available", function() {
+      it("should show the checkbox as disabled when context is already set", function() {
         view = mountTestComponent({
           editMode: true,
           roomData: {
             roomToken: "fakeToken",
-            roomName: "fakeName"
+            roomName: "fakeName",
+            roomContextUrls: [fakeContextURL]
           }
         });
 
         var checkbox = view.getDOMNode().querySelector(".checkbox");
         expect(checkbox.classList.contains("disabled")).to.eql(true);
       });
 
       it("should render the editMode view when the edit button is clicked", function(next) {
@@ -753,23 +752,46 @@ describe("loop.roomViews", function () {
         // Switch to editMode via setting the prop, since we can control that
         // better.
         view.setProps({ editMode: true }, function() {
           // First check if availableContext is set correctly.
           expect(view.state.availableContext).to.not.eql(null);
           expect(view.state.availableContext.previewImage).to.eql(favicon);
 
           var node = view.getDOMNode();
+          expect(node.querySelector(".checkbox-wrapper").classList.contains("disabled")).to.eql(true);
           expect(node.querySelector(".room-context-name").value).to.eql(roomName);
           expect(node.querySelector(".room-context-url").value).to.eql(fakeContextURL.location);
           expect(node.querySelector(".room-context-comments").value).to.eql(fakeContextURL.description);
 
           next();
         });
       });
+
+      it("should hide the checkbox when no context data is stored or available", function(next) {
+        view = mountTestComponent({
+          roomData: {
+            roomToken: "fakeToken",
+            roomName: "Hello, is it me you're looking for?"
+          }
+        });
+
+        // Switch to editMode via setting the prop, since we can control that
+        // better.
+        view.setProps({ editMode: true }, function() {
+          // First check if availableContext is set correctly.
+          expect(view.state.availableContext).to.not.eql(null);
+          expect(view.state.availableContext.previewImage).to.eql(favicon);
+
+          var node = view.getDOMNode();
+          expect(node.querySelector(".checkbox-wrapper").classList.contains("hide")).to.eql(true);
+
+          next();
+        });
+      });
     });
 
     describe("Update Room", function() {
       var roomNameBox;
 
       beforeEach(function() {
         sandbox.stub(dispatcher, "dispatch");
 
@@ -780,23 +802,23 @@ describe("loop.roomViews", function () {
             roomName: "fakeName",
             roomContextUrls: [fakeContextURL]
           }
         });
 
         roomNameBox = view.getDOMNode().querySelector(".room-context-name");
       });
 
-      it("should dispatch a UpdateRoomContext action when the focus is lost",
+      it("should dispatch a UpdateRoomContext action when the save button is clicked",
         function() {
           React.addons.TestUtils.Simulate.change(roomNameBox, { target: {
             value: "reallyFake"
           }});
 
-          React.addons.TestUtils.Simulate.blur(roomNameBox);
+          React.addons.TestUtils.Simulate.click(view.getDOMNode().querySelector(".btn-info"));
 
           sinon.assert.calledOnce(dispatcher.dispatch);
           sinon.assert.calledWithExactly(dispatcher.dispatch,
             new sharedActions.UpdateRoomContext({
               roomToken: "fakeToken",
               newRoomName: "reallyFake",
               newRoomDescription: fakeContextURL.description,
               newRoomURL: fakeContextURL.location,