Bug 1074674 - add button to copy room location to clipboard, r=NiKo a=loop-only
authorDan Mosedale <dmose@meer.net>
Wed, 29 Oct 2014 14:10:28 -0700
changeset 235103 f634a1289875070bc400531f45b1861a392beace
parent 235102 6dca7a6a8f339586c8f6c741323b5254d4ebfd97
child 235104 989937667860a51dc7ff3d2f013905951a71ace0
push id611
push userraliiev@mozilla.com
push dateMon, 05 Jan 2015 23:23:16 +0000
treeherdermozilla-release@345cd3b9c445 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersNiKo, loop-only
bugs1074674
milestone35.0a2
Bug 1074674 - add button to copy room location to clipboard, r=NiKo a=loop-only
browser/components/loop/content/js/panel.js
browser/components/loop/content/js/panel.jsx
browser/components/loop/content/shared/css/panel.css
browser/components/loop/content/shared/img/svg/checkmark-16x16.svg
browser/components/loop/content/shared/img/svg/copy-16x16.svg
browser/components/loop/jar.mn
browser/components/loop/test/desktop-local/panel_test.js
browser/components/loop/ui/fake-mozLoop.js
--- a/browser/components/loop/content/js/panel.js
+++ b/browser/components/loop/content/js/panel.js
@@ -466,42 +466,63 @@ loop.panel = (function(_, mozL10n) {
    * Room list entry.
    */
   var RoomEntry = React.createClass({displayName: 'RoomEntry',
     propTypes: {
       openRoom: React.PropTypes.func.isRequired,
       room:     React.PropTypes.instanceOf(loop.store.Room).isRequired
     },
 
+    getInitialState: function() {
+      return { urlCopied: false };
+    },
+
     shouldComponentUpdate: function(nextProps, nextState) {
-      return nextProps.room.ctime > this.props.room.ctime;
+      return (nextProps.room.ctime > this.props.room.ctime) ||
+        (nextState.urlCopied !== this.state.urlCopied);
     },
 
     handleClickRoom: function(event) {
       event.preventDefault();
       this.props.openRoom(this.props.room);
     },
 
+    handleCopyButtonClick: function(event) {
+      event.preventDefault();
+      navigator.mozLoop.copyString(this.props.room.roomUrl);
+      this.setState({urlCopied: true});
+    },
+
+    handleMouseLeave: function(event) {
+      this.setState({urlCopied: false});
+    },
+
     _isActive: function() {
       // XXX bug 1074679 will implement this properly
       return this.props.room.currSize > 0;
     },
 
     render: function() {
       var room = this.props.room;
       var roomClasses = React.addons.classSet({
         "room-entry": true,
         "room-active": this._isActive()
       });
+      var copyButtonClasses = React.addons.classSet({
+        'copy-link': true,
+        'checked': this.state.urlCopied
+      });
 
       return (
-        React.DOM.div({className: roomClasses}, 
+        React.DOM.div({className: roomClasses, onMouseLeave: this.handleMouseLeave}, 
           React.DOM.h2(null, 
             React.DOM.span({className: "room-notification"}), 
-            room.roomName
+              room.roomName, 
+            React.DOM.button({className: copyButtonClasses, 
+              onClick: this.handleCopyButtonClick})
           ), 
           React.DOM.p(null, 
             React.DOM.a({ref: "room", href: "#", onClick: this.handleClickRoom}, 
               room.roomUrl
             )
           )
         )
       );
@@ -757,15 +778,16 @@ loop.panel = (function(_, mozL10n) {
 
   return {
     init: init,
     UserIdentity: UserIdentity,
     AuthLink: AuthLink,
     AvailabilityDropdown: AvailabilityDropdown,
     CallUrlResult: CallUrlResult,
     PanelView: PanelView,
+    RoomEntry: RoomEntry,
     RoomList: RoomList,
     SettingsDropdown: SettingsDropdown,
     ToSView: ToSView
   };
 })(_, document.mozL10n);
 
 document.addEventListener('DOMContentLoaded', loop.panel.init);
--- a/browser/components/loop/content/js/panel.jsx
+++ b/browser/components/loop/content/js/panel.jsx
@@ -466,42 +466,63 @@ loop.panel = (function(_, mozL10n) {
    * Room list entry.
    */
   var RoomEntry = React.createClass({
     propTypes: {
       openRoom: React.PropTypes.func.isRequired,
       room:     React.PropTypes.instanceOf(loop.store.Room).isRequired
     },
 
+    getInitialState: function() {
+      return { urlCopied: false };
+    },
+
     shouldComponentUpdate: function(nextProps, nextState) {
-      return nextProps.room.ctime > this.props.room.ctime;
+      return (nextProps.room.ctime > this.props.room.ctime) ||
+        (nextState.urlCopied !== this.state.urlCopied);
     },
 
     handleClickRoom: function(event) {
       event.preventDefault();
       this.props.openRoom(this.props.room);
     },
 
+    handleCopyButtonClick: function(event) {
+      event.preventDefault();
+      navigator.mozLoop.copyString(this.props.room.roomUrl);
+      this.setState({urlCopied: true});
+    },
+
+    handleMouseLeave: function(event) {
+      this.setState({urlCopied: false});
+    },
+
     _isActive: function() {
       // XXX bug 1074679 will implement this properly
       return this.props.room.currSize > 0;
     },
 
     render: function() {
       var room = this.props.room;
       var roomClasses = React.addons.classSet({
         "room-entry": true,
         "room-active": this._isActive()
       });
+      var copyButtonClasses = React.addons.classSet({
+        'copy-link': true,
+        'checked': this.state.urlCopied
+      });
 
       return (
-        <div className={roomClasses}>
+        <div className={roomClasses} onMouseLeave={this.handleMouseLeave}>
           <h2>
             <span className="room-notification" />
-            {room.roomName}
+              {room.roomName}
+            <button className={copyButtonClasses}
+              onClick={this.handleCopyButtonClick}/>
           </h2>
           <p>
             <a ref="room" href="#" onClick={this.handleClickRoom}>
               {room.roomUrl}
             </a>
           </p>
         </div>
       );
@@ -757,15 +778,16 @@ loop.panel = (function(_, mozL10n) {
 
   return {
     init: init,
     UserIdentity: UserIdentity,
     AuthLink: AuthLink,
     AvailabilityDropdown: AvailabilityDropdown,
     CallUrlResult: CallUrlResult,
     PanelView: PanelView,
+    RoomEntry: RoomEntry,
     RoomList: RoomList,
     SettingsDropdown: SettingsDropdown,
     ToSView: ToSView
   };
 })(_, document.mozL10n);
 
 document.addEventListener('DOMContentLoaded', loop.panel.init);
--- a/browser/components/loop/content/shared/css/panel.css
+++ b/browser/components/loop/content/shared/css/panel.css
@@ -197,16 +197,59 @@ body {
   text-decoration: none;
 }
 
 .room-list > .room-entry > p > a:hover {
   opacity: 1;
   text-decoration: underline;
 }
 
+.room-list > .room-entry > h2 > .copy-link {
+  display: inline-block;
+  width: 24px;
+  height: 24px;
+  border: none;
+  margin: .1em .5em; /* relative to _this_ line's font, not the document's */
+  background-color: transparent;  /* override browser default for button tags */
+}
+
+@keyframes drop-and-fade-in {
+  from { opacity: 0; transform: translateY(-10px); }
+  to { opacity: 100; transform: translateY(0); }
+}
+
+.room-list > .room-entry:hover > h2 > .copy-link {
+  background: transparent url(../img/svg/copy-16x16.svg);
+  cursor: pointer;
+  animation: drop-and-fade-in 0.4s;
+  animation-fill-mode: forwards;
+}
+
+/* scale this up to 1.1x and then back to the original size */
+@keyframes pulse {
+  0%, 100% { transform: scale(1.0); }
+  50% { transform: scale(1.1); }
+}
+
+.room-list > .room-entry > h2 > .copy-link.checked {
+  background: transparent url(../img/svg/checkmark-16x16.svg);
+  animation: pulse .250s;
+  animation-timing-function: ease-in-out;
+}
+
+.room-list > .room-entry > h2 {
+  display: inline-block;
+}
+
+/* keep the various room-entry row pieces aligned with each other */
+.room-list > .room-entry > h2 > button,
+.room-list > .room-entry > h2 > span {
+  vertical-align: middle;
+}
+
 /* Buttons */
 
 .button-group {
   display: flex;
   flex-direction: row;
   width: 100%;
 }
 
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/img/svg/checkmark-16x16.svg
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg"
+     xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+     viewBox="0 0 16 16" enable-background="new 0 0 16 16" xml:space="preserve">
+  <circle fill-rule="evenodd" clip-rule="evenodd" fill="#0096DD" cx="8"
+          cy="8" r="8"/>
+  <path fill-rule="evenodd" clip-rule="evenodd" fill="#FFFFFF"
+        d="M7.236,12L12,5.007L10.956,4L7.224,9.465l-2.14-2.326L4,8.146L7.236,12z"/>
+</svg>
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/img/svg/copy-16x16.svg
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg"
+     xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+     viewBox="0 0 16 16" enable-background="new 0 0 16 16" xml:space="preserve">
+  <circle fill-rule="evenodd" clip-rule="evenodd" fill="#0096DD" cx="8" cy="8"
+           r="8"/>
+  <g>
+    <g>
+      <g>
+        <path fill-rule="evenodd" clip-rule="evenodd" fill="none"
+              stroke="#FFFFFF" stroke-width="0.75" stroke-miterlimit="10"
+              d="M10.815,6.286H7.556c-0.164,0-0.296,0.128-0.296,0.286v5.143C7.259,11.872,7.392,12,7.556,12h4.148
+                 C11.867,12,12,11.872,12,11.714V7.429L10.815,6.286z
+                 M8.741,6.275V5.143L7.556,4H7.528C6.509,4,4.593,4,4.593,4H4.296
+                 C4.133,4,4,4.128,4,4.286v5.143c0,0.158,0.133,0.286,0.296,0.286H7.25V6.561c0-0.158,0.133-0.286,0.296-0.286H8.741z"/>
+      </g>
+    </g>
+    <g>
+      <polygon fill-rule="evenodd" clip-rule="evenodd"
+               fill="#FFFFFF" points="10.222,8 10.222,6.857 11.407,8"/>
+    </g>
+    <g>
+      <polygon fill-rule="evenodd" clip-rule="evenodd" fill="#FFFFFF"
+               points="6.963,5.714 6.963,4.571 8.148,5.714"/>
+    </g>
+  </g>
+</svg>
--- a/browser/components/loop/jar.mn
+++ b/browser/components/loop/jar.mn
@@ -43,16 +43,18 @@ browser.jar:
   content/browser/loop/shared/img/video-inverse-14x14.png       (content/shared/img/video-inverse-14x14.png)
   content/browser/loop/shared/img/video-inverse-14x14@2x.png    (content/shared/img/video-inverse-14x14@2x.png)
   content/browser/loop/shared/img/dropdown-inverse.png          (content/shared/img/dropdown-inverse.png)
   content/browser/loop/shared/img/dropdown-inverse@2x.png       (content/shared/img/dropdown-inverse@2x.png)
   content/browser/loop/shared/img/svg/glyph-settings-16x16.svg  (content/shared/img/svg/glyph-settings-16x16.svg)
   content/browser/loop/shared/img/svg/glyph-account-16x16.svg   (content/shared/img/svg/glyph-account-16x16.svg)
   content/browser/loop/shared/img/svg/glyph-signin-16x16.svg    (content/shared/img/svg/glyph-signin-16x16.svg)
   content/browser/loop/shared/img/svg/glyph-signout-16x16.svg   (content/shared/img/svg/glyph-signout-16x16.svg)
+  content/browser/loop/shared/img/svg/copy-16x16.svg            (content/shared/img/svg/copy-16x16.svg)
+  content/browser/loop/shared/img/svg/checkmark-16x16.svg       (content/shared/img/svg/checkmark-16x16.svg)
   content/browser/loop/shared/img/audio-call-avatar.svg         (content/shared/img/audio-call-avatar.svg)
   content/browser/loop/shared/img/beta-ribbon.svg               (content/shared/img/beta-ribbon.svg)
   content/browser/loop/shared/img/icons-10x10.svg               (content/shared/img/icons-10x10.svg)
   content/browser/loop/shared/img/icons-14x14.svg               (content/shared/img/icons-14x14.svg)
   content/browser/loop/shared/img/icons-16x16.svg               (content/shared/img/icons-16x16.svg)
 
   # Shared scripts
   content/browser/loop/shared/js/actions.js           (content/shared/js/actions.js)
--- a/browser/components/loop/test/desktop-local/panel_test.js
+++ b/browser/components/loop/test/desktop-local/panel_test.js
@@ -632,16 +632,82 @@ describe("loop.panel", function() {
 
         sinon.assert.calledOnce(notifications.errorL10n);
         sinon.assert.calledWithExactly(notifications.errorL10n,
                                        "unable_retrieve_url");
       });
     });
   });
 
+  describe("loop.panel.RoomEntry", function() {
+    var buttonNode, roomData, roomEntry, roomStore, dispatcher;
+
+    beforeEach(function() {
+      dispatcher = new loop.Dispatcher();
+      roomData = {
+        roomToken: "QzBbvGmIZWU",
+        roomUrl: "http://sample/QzBbvGmIZWU",
+        roomName: "Second Room Name",
+        maxSize: 2,
+        participants: [
+          { displayName: "Alexis", account: "alexis@example.com",
+            roomConnectionId: "2a1787a6-4a73-43b5-ae3e-906ec1e763cb" },
+          { displayName: "Adam",
+            roomConnectionId: "781f012b-f1ea-4ce1-9105-7cfc36fb4ec7" }
+        ],
+        ctime: 1405517418
+      };
+      roomStore = new loop.store.Room(roomData);
+      roomEntry = mountRoomEntry();
+      buttonNode = roomEntry.getDOMNode().querySelector("button.copy-link");
+    });
+
+    function mountRoomEntry() {
+      return TestUtils.renderIntoDocument(loop.panel.RoomEntry({
+        openRoom: sandbox.stub(),
+        room: roomStore
+      }));
+    }
+
+    it("should not display copy-link button by default", function() {
+      expect(buttonNode).to.not.equal(null);
+    });
+
+    it("should copy the URL when the click event fires", function() {
+      TestUtils.Simulate.click(buttonNode);
+
+      sinon.assert.calledOnce(navigator.mozLoop.copyString);
+      sinon.assert.calledWithExactly(navigator.mozLoop.copyString,
+        roomData.roomUrl);
+    });
+
+    it("should set state.urlCopied when the click event fires", function() {
+      TestUtils.Simulate.click(buttonNode);
+
+      expect(roomEntry.state.urlCopied).to.equal(true);
+    });
+
+    it("should switch to displaying a check icon when the URL has been copied",
+      function() {
+        TestUtils.Simulate.click(buttonNode);
+
+        expect(buttonNode.classList.contains("checked")).eql(true);
+      });
+
+    it("should not display a check icon after mouse leaves the entry",
+      function() {
+        var roomNode = roomEntry.getDOMNode();
+        TestUtils.Simulate.click(buttonNode);
+
+        TestUtils.SimulateNative.mouseOut(roomNode);
+
+        expect(buttonNode.classList.contains("checked")).eql(false);
+      });
+  });
+
   describe("loop.panel.RoomList", function() {
     var roomListStore, dispatcher;
 
     beforeEach(function() {
       dispatcher = new loop.Dispatcher();
       roomListStore = new loop.store.RoomListStore({
         dispatcher: dispatcher,
         mozLoop: navigator.mozLoop
--- a/browser/components/loop/ui/fake-mozLoop.js
+++ b/browser/components/loop/ui/fake-mozLoop.js
@@ -52,16 +52,17 @@ navigator.mozLoop = {
   getLoopCharPref: function() {},
   getLoopBoolPref: function(pref) {
     // Ensure UI for rooms is displayed in the showcase.
     if (pref === "rooms.enabled") {
       return true;
     }
   },
   releaseCallData: function() {},
+  copyString: function() {},
   contacts: {
     getAll: function(callback) {
       callback(null, []);
     },
     on: function() {}
   },
   rooms: {
     getAll: function(callback) {