From a2e7c4aa770f34ba32bdeb634c2dae495de627b5 Mon Sep 17 00:00:00 2001
From: Matthew Hodgson <matthew@matrix.org>
Date: Fri, 14 Aug 2015 19:15:41 +0100
Subject: [PATCH 1/8] WIP for fixing the popovers

---
 skins/base/css/molecules/MemberTile.css  |  16 ++++++-
 skins/base/img/delete.png                | Bin 0 -> 1006 bytes
 skins/base/views/molecules/MemberTile.js |  54 ++++++++++++++++++++---
 3 files changed, 61 insertions(+), 9 deletions(-)
 create mode 100644 skins/base/img/delete.png

diff --git a/skins/base/css/molecules/MemberTile.css b/skins/base/css/molecules/MemberTile.css
index f296668f..bd622018 100644
--- a/skins/base/css/molecules/MemberTile.css
+++ b/skins/base/css/molecules/MemberTile.css
@@ -68,6 +68,17 @@ limitations under the License.
     text-overflow: ellipsis;
 }
 
+.mx_MemberTile_hover {
+    background-color: #f0f0f0;
+    font-size: 12px;
+    color: #747474;    
+}
+
+.mx_MemberTile_userId {
+    font-weight: bold;
+}
+
+/*
 .mx_MemberTile_nameWrapper {
     display: table-cell;
     vertical-align: middle;
@@ -77,19 +88,20 @@ limitations under the License.
 
 .mx_MemberTile_nameSpan {
 }
+*/
 
 .mx_MemberTile_unavailable .mx_MemberTile_avatar,
 .mx_MemberTile_unavailable .mx_MemberTile_name,
 .mx_MemberTile_unavailable .mx_MemberTile_nameSpan
 {
-    opacity: 0.75;
+    opacity: 0.66;
 }
 
 .mx_MemberTile_offline .mx_MemberTile_avatar,
 .mx_MemberTile_offline .mx_MemberTile_name,
 .mx_MemberTile_offline .mx_MemberTile_nameSpan
 {
-    opacity: 0.5;
+    opacity: 0.25;
 }
 
 .mx_MemberTile_zalgo {
diff --git a/skins/base/img/delete.png b/skins/base/img/delete.png
new file mode 100644
index 0000000000000000000000000000000000000000..8ff20a116d4340453072e38604cd8de5c894f30a
GIT binary patch
literal 1006
zcmaJ=PfXKL7_T^tkfCTeh<bP_9>BQ%-B#LAacehOvJx_uxPvBk?SnPYzS2He;il0<
zO;8dO!@;vhuaX$!#e*JDkH$npNIY}#rUzfgHas|c==<K6@Av!u{ocd5+3Q1t=Lac@
z8Y<1`6>`S*;K>u@zbSlrLyl2gsNs3EfSYCjsho}K5R_bV5mumSudjT7QxtW4+^N=Z
z%_u7tav3v@VItQjXo{Mej(pQvf*91{qT{Lb!Rxm)aBP*nkuf;K*I>h$*$CkL#%$Hv
zSh8fBp1umEB83pR5St)!m%UJlRJz5h5Ic6WG-!q3C6zubs%Fdq4FwQnm~6`8vJ#MF
zhR;Y+TDk%Rj?b{1$ckJ_5S28iqy^CaXp$|k7nF)#Z0903m2P0{D=gb=Hkqc#pkR^Z
zWm#r9ffa-liAaU(9yX(t7mjrpbQoHJ<6{STAZ9e{Xcen8sr0Y~*YDDLVS7wuU~FXi
zEYEOpOPxT&_<yMDcB4aFfk*ZJlQ^ud`;e``5UmClS=_={oXS_U0Gb#DRfLv1Q=Drc
zjKT);fu^;l2F4B3a=h3%(Lx!9Qu0D<dKN6{DouD8$FY@SK35R3lcJc@WS%c%<a|Dz
z%kt^ToXlyGUhH6XWUac;!yT-BgiZIv#)5Etl39m=vj**AfLzdOTyc8W!uRBBW9{Cx
z2tBbZDTa;L``5ZVE~25>?24CMy7Gq}(R@I}ePQijpFE74B|TS-zSf_gN+grX%(dUo
zj!D}e6PJWLJN*ytuT1ofzDxYrt0Z4-4wQy(@sFO?K9}F$ySzv3pS7DJKRmGQ?@XNe
twp;#k@$v3%d2+;zw)&sYBd7Zk=cpIoM}G;mt)KA=EEQ(;m-*Xw{{X0DIb#3-

literal 0
HcmV?d00001

diff --git a/skins/base/views/molecules/MemberTile.js b/skins/base/views/molecules/MemberTile.js
index bd184051..244a9b6f 100644
--- a/skins/base/views/molecules/MemberTile.js
+++ b/skins/base/views/molecules/MemberTile.js
@@ -47,6 +47,33 @@ module.exports = React.createClass({
         this.setState({ 'hover': false });
     },
 
+    getDuration: function(time) {
+        if (!time) return;
+        var t = parseInt(time / 1000);
+        var s = t % 60;
+        var m = parseInt(t / 60) % 60;
+        var h = parseInt(t / (60 * 60)) % 24;
+        var d = parseInt(t / (60 * 60 * 24));
+        if (t < 60) {
+            if (t < 0) {
+                return "0s";
+            }
+            return s + "s";
+        }
+        if (t < 60 * 60) {
+            return m + "m";
+        }
+        if (t < 24 * 60 * 60) {
+            return h + "h";
+        }
+        return d + "d ";
+    },
+
+    getPrettyPresence: function(user) {
+        var presence = user.presence;
+        return presence.charAt(0).toUpperCase() + presence.slice(1);
+    },
+
     render: function() {
         var isMyUser = MatrixClientPeg.get().credentials.userId == this.props.member.userId;
 
@@ -66,30 +93,43 @@ module.exports = React.createClass({
             }
         }
         mainClassName += presenceClass;
+        if (this.state.hover) {
+            mainClassName += " mx_MemberTile_hover";
+        }
 
         var name = this.props.member.name;
         if (isMyUser) name += " (me)";
-        var leave = isMyUser ? <span className="mx_MemberTile_leave" onClick={this.onLeaveClick}>X</span> : null;
+        var leave = isMyUser ? <img className="mx_MemberTile_leave" src="img/delete.png" width="10" height="10" onClick={this.onLeaveClick}/> : null;
 
-        var nameClass = this.state.hover ? "mx_MemberTile_nameSpan" : "mx_MemberTile_name";
+        var nameClass = "mx_MemberTile_name";
         if (zalgo.test(name)) {
             nameClass += " mx_MemberTile_zalgo";
         }
 
         var nameEl;
         if (this.state.hover) {
+            var presence;
+            // FIXME: make presence data update whenever User.presence changes...
+            var active = this.props.member.user.lastActiveAgo || -1;
+            if (active >= 0) {
+                presence = <div className="mx_MemberTile_presence">{ this.getPrettyPresence(this.props.member.user) } for { this.getDuration(active) }</div>;
+            }
+            else {
+                presence = <div className="mx_MemberTile_presence">{ this.getPrettyPresence(this.props.member.user) }</div>;
+            }
+
             nameEl =
-                <div className="mx_MemberTile_nameWrapper">
+                <div className="mx_MemberTile_details">
                     <MemberInfo member={this.props.member} />
-                    <span className={nameClass}>{name}</span>
-                    {leave}
+                    <div className="mx_MemberTile_userId">{ this.props.member.userId }</div>
+                    { presence }
+                    { leave }
                 </div>
         }
         else {
             nameEl =
                 <div className={nameClass}>
-                    {name}
-                    {leave}
+                    { name }
                 </div>
         }
 

From 80c3b2c8a3358c512ca84ab4021c2b6ec487a9ce Mon Sep 17 00:00:00 2001
From: Matthew Hodgson <matthew@matrix.org>
Date: Fri, 14 Aug 2015 21:14:05 +0100
Subject: [PATCH 2/8] match the design

---
 skins/base/css/molecules/MemberTile.css  | 31 ++++++++++++++++++------
 skins/base/css/organisms/MemberList.css  |  3 +--
 skins/base/views/atoms/EditableText.js   |  1 -
 skins/base/views/molecules/MemberTile.js | 14 +++++++----
 skins/base/views/organisms/MemberList.js |  7 +-----
 5 files changed, 35 insertions(+), 21 deletions(-)

diff --git a/skins/base/css/molecules/MemberTile.css b/skins/base/css/molecules/MemberTile.css
index bd622018..a4310d20 100644
--- a/skins/base/css/molecules/MemberTile.css
+++ b/skins/base/css/molecules/MemberTile.css
@@ -15,13 +15,14 @@ limitations under the License.
 */
 
 .mx_MemberTile {
-    cursor: pointer;
     display: table-row;
     height: 49px;
+    position: relative;
 }
 
 .mx_MemberTile_avatar {
     display: table-cell;
+    padding-left: 14px;
     padding-right: 12px;
     padding-top: 3px;
     padding-bottom: 3px;
@@ -31,6 +32,10 @@ limitations under the License.
     position: relative;
 }
 
+.mx_MemberTile_inviteTile {
+    cursor: pointer;
+}
+
 .mx_MemberTile_inviteEditing {
     display: initial ! important;
 }
@@ -50,14 +55,14 @@ limitations under the License.
     font-size: 14px;
     padding: 9px;
     margin-top: 6px;
+    margin-left: 14px;
 }
 
 .mx_MemberTile_power {
-    z-index: 10;
     position: absolute;
     width: 48px;
     height: 48px;
-    left: -4px;
+    left: 10px;
     top: -1px;
 }
 
@@ -68,6 +73,12 @@ limitations under the License.
     text-overflow: ellipsis;
 }
 
+.mx_MemberTile_details {
+    display: table-cell;
+    padding-right: 14px;
+    vertical-align: middle;
+}
+
 .mx_MemberTile_hover {
     background-color: #f0f0f0;
     font-size: 12px;
@@ -76,6 +87,16 @@ limitations under the License.
 
 .mx_MemberTile_userId {
     font-weight: bold;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.mx_MemberTile_leave {
+    cursor: pointer;
+    margin-top: 8px;
+    margin-right: -4px;
+    margin-left: 6px;
+    float: right;
 }
 
 /*
@@ -107,7 +128,3 @@ limitations under the License.
 .mx_MemberTile_zalgo {
     font-family: Helvetica, Arial, Sans-Serif;
 }
-
-.mx_MemberTile_leave {
-    float: right;
-}
diff --git a/skins/base/css/organisms/MemberList.css b/skins/base/css/organisms/MemberList.css
index 4d2e7215..aab0def4 100644
--- a/skins/base/css/organisms/MemberList.css
+++ b/skins/base/css/organisms/MemberList.css
@@ -42,7 +42,6 @@ limitations under the License.
     border: 1px solid #a9dbf4;
     overflow-y: auto;
     border-radius: 8px;
-    padding: 20px 14px 14px 24px;
     background-color: #fff;
 
     order: 1;
@@ -57,5 +56,5 @@ limitations under the License.
 }
 
 .mx_MemberList h2 {
-    margin-top: 0px;
+    margin: 14px;
 }
diff --git a/skins/base/views/atoms/EditableText.js b/skins/base/views/atoms/EditableText.js
index 38aa5c8d..d4aa2857 100644
--- a/skins/base/views/atoms/EditableText.js
+++ b/skins/base/views/atoms/EditableText.js
@@ -33,7 +33,6 @@ module.exports = React.createClass({
     },
 
     onClickDiv: function() {
-        console.log("onClickDiv triggered");
         this.setState({
             phase: this.Phases.Edit,
         })
diff --git a/skins/base/views/molecules/MemberTile.js b/skins/base/views/molecules/MemberTile.js
index 244a9b6f..6e3cb859 100644
--- a/skins/base/views/molecules/MemberTile.js
+++ b/skins/base/views/molecules/MemberTile.js
@@ -70,8 +70,12 @@ module.exports = React.createClass({
     },
 
     getPrettyPresence: function(user) {
+        if (!user) return "Unknown";
         var presence = user.presence;
-        return presence.charAt(0).toUpperCase() + presence.slice(1);
+        if (presence === "online") return "Online";
+        if (presence === "unavailable") return "Idle"; // XXX: is this actually right?
+        if (presence === "offline") return "Offline";
+        return "Unknown";
     },
 
     render: function() {
@@ -110,7 +114,7 @@ module.exports = React.createClass({
         if (this.state.hover) {
             var presence;
             // FIXME: make presence data update whenever User.presence changes...
-            var active = this.props.member.user.lastActiveAgo || -1;
+            var active = this.props.member.user ? (this.props.member.user.lastActiveAgo || -1) : -1;
             if (active >= 0) {
                 presence = <div className="mx_MemberTile_presence">{ this.getPrettyPresence(this.props.member.user) } for { this.getDuration(active) }</div>;
             }
@@ -118,12 +122,12 @@ module.exports = React.createClass({
                 presence = <div className="mx_MemberTile_presence">{ this.getPrettyPresence(this.props.member.user) }</div>;
             }
 
+            // <MemberInfo member={this.props.member} />
             nameEl =
                 <div className="mx_MemberTile_details">
-                    <MemberInfo member={this.props.member} />
-                    <div className="mx_MemberTile_userId">{ this.props.member.userId }</div>
-                    { presence }
                     { leave }
+                    <div className="mx_MemberTile_userId" title={ this.props.member.userId }>{ this.props.member.userId }</div>
+                    { presence }
                 </div>
         }
         else {
diff --git a/skins/base/views/organisms/MemberList.js b/skins/base/views/organisms/MemberList.js
index 3d30d125..a9a3c782 100644
--- a/skins/base/views/organisms/MemberList.js
+++ b/skins/base/views/organisms/MemberList.js
@@ -75,14 +75,9 @@ module.exports = React.createClass({
     },
 
     inviteTile: function() {
-        // if (this.state.inviting) {
-        //     return (
-        //         <div></div>
-        //     );
-        // }
-
         var classes = classNames({
             mx_MemberTile: true,
+            mx_MemberTile_inviteTile: true,
             mx_MemberTile_inviteEditing: this.state.editing,
         });
 

From e3798e1b8572693f1471fe9f544b8470a85613b7 Mon Sep 17 00:00:00 2001
From: Matthew Hodgson <matthew@matrix.org>
Date: Sat, 15 Aug 2015 03:06:21 +0100
Subject: [PATCH 3/8] WIP fixing up the member list - just needs CSS and
 testing

---
 skins/base/img/edit.png                  | Bin 0 -> 498 bytes
 skins/base/views/molecules/MemberTile.js |  61 +++++-
 skins/base/views/organisms/RoomView.js   |   2 +-
 src/controllers/molecules/MemberTile.js  | 245 ++++++++++++++++++++++-
 src/controllers/organisms/MemberList.js  |   4 +-
 5 files changed, 296 insertions(+), 16 deletions(-)
 create mode 100644 skins/base/img/edit.png

diff --git a/skins/base/img/edit.png b/skins/base/img/edit.png
new file mode 100644
index 0000000000000000000000000000000000000000..2686885f792ed69bdb88518bfe6850301719e7f0
GIT binary patch
literal 498
zcmV<O0S*3%P)<h;3K|Lk000e1NJLTq000sI000sQ1^@s6R?d!B00001b5ch_0Itp)
z=>Px$tVu*cR5%f(l)+BIKoEwfO92Z+Lr{4D6%!NV(UTtZDbRyF8YUWIV)P00Y@!!0
zCIz`G2_n)KOUu^%*OY950?|3N&CYy(XJ%(tqbXK1t>d&#6$<ERNK`;bf~ZXz-Mc<j
z%iFoTaZFPeE?>G^MmkYTF;*(&li78Hv5fv3;`jlz`kdE0p6&6#bX@=BbZ4cZ^78wt
z-aEW*THVIyV8}TS;tW2-Ahu?H9YlnX2^?>>AIDFuBNhx;h%r1+&R!vvD`rF|W|-{-
z^w9DMWg->~SRgtYi=J#O>rymMr{{4xZ+uhR4M-Dh+h0o(7_;M-puw;^XsiZrjHx24
zOB(Iwbr}f;EHoIl7`qdrLE|E4n+AnD2}ZI)L&uz`4pk5$RZ-4|C<mETgh)0S%^WN=
zW@a)^MExnl#9|jzTO^Y(9xOB%#vT(JI`F=Don4L^D_NnzaCR{EV&X=A+b1d-Y-&$N
zLE-}pSZK)go2K=Iv7Z><ISXKJoAx6_WV8G3u>9N}SnrPL|AK~CFgUys<iCeDG8Btm
oBLi`ppbGUGi3o)${^er(0mAFFToUNEk^lez07*qoM6N<$g8kFZ4*&oF

literal 0
HcmV?d00001

diff --git a/skins/base/views/molecules/MemberTile.js b/skins/base/views/molecules/MemberTile.js
index 6e3cb859..7c66616b 100644
--- a/skins/base/views/molecules/MemberTile.js
+++ b/skins/base/views/molecules/MemberTile.js
@@ -34,11 +34,6 @@ module.exports = React.createClass({
     displayName: 'MemberTile',
     mixins: [MemberTileController],
 
-    // XXX: should these be in the controller?
-    getInitialState: function() {
-        return { 'hover': false };
-    },
-
     mouseEnter: function(e) {
         this.setState({ 'hover': true });
     },
@@ -47,6 +42,11 @@ module.exports = React.createClass({
         this.setState({ 'hover': false });
     },
 
+    onClick: function(e) {
+        this.setState({ 'menu': true });
+        this.setState(this._calculateOpsPermissions());        
+    },
+
     getDuration: function(time) {
         if (!time) return;
         var t = parseInt(time / 1000);
@@ -78,6 +78,14 @@ module.exports = React.createClass({
         return "Unknown";
     },
 
+    getPowerLabel: function() {
+        var label = this.props.member.userId;
+        if (this.state.isTargetMod) {
+            label += " - Mod (" + this.props.member.powerLevelNorm + "%)";
+        }
+        return label;
+    },
+
     render: function() {
         var isMyUser = MatrixClientPeg.get().credentials.userId == this.props.member.userId;
 
@@ -102,7 +110,7 @@ module.exports = React.createClass({
         }
 
         var name = this.props.member.name;
-        if (isMyUser) name += " (me)";
+        // if (isMyUser) name += " (me)"; // this does nothing other than introduce line wrapping and pain
         var leave = isMyUser ? <img className="mx_MemberTile_leave" src="img/delete.png" width="10" height="10" onClick={this.onLeaveClick}/> : null;
 
         var nameClass = "mx_MemberTile_name";
@@ -110,6 +118,41 @@ module.exports = React.createClass({
             nameClass += " mx_MemberTile_zalgo";
         }
 
+        var menu;
+        if (this.state.menu) {
+            var kickButton, banButton, muteButton, giveModButton;
+            if (this.state.can.kick) {
+                kickButton = <div className="mx_MemberTile_menuItem" onClick={this.onKick}>
+                    Kick
+                </div>;
+            }
+            if (this.state.can.ban) {
+                banButton = <div className="mx_MemberTile_menuItem" onClick={this.onBan}>
+                    Ban
+                </div>;
+            }
+            if (this.state.can.mute) {
+                var muteLabel = this.state.muted ? "Unmute" : "Mute";
+                muteButton = <div className="mx_MemberTile_menuItem" onClick={this.onMuteToggle}>
+                    {muteLabel}
+                </div>;
+            }
+            if (this.state.can.modifyLevel) {
+                var giveOpLabel = this.state.isTargetMod ? "Revoke Mod" : "Make Mod";
+                giveModButton = <div className="mx_MemberTile_menuItem" onClick={this.onModToggle}>
+                    {giveOpLabel}
+                </div>
+            }
+            menu = <div className="mx_MemberTile_menu">
+                        <img className="mx_MemberTile_chevron" src="img/chevron-right.png" width="9" height="16" />
+                        <div className="mx_MemberTile_menuItem" onClick={this.onChatClick}>Chat</div>
+                        {muteButton}
+                        {kickButton}
+                        {banButton}
+                        {giveModButton}
+                   </div>;
+        }
+
         var nameEl;
         if (this.state.hover) {
             var presence;
@@ -122,11 +165,10 @@ module.exports = React.createClass({
                 presence = <div className="mx_MemberTile_presence">{ this.getPrettyPresence(this.props.member.user) }</div>;
             }
 
-            // <MemberInfo member={this.props.member} />
             nameEl =
                 <div className="mx_MemberTile_details">
                     { leave }
-                    <div className="mx_MemberTile_userId" title={ this.props.member.userId }>{ this.props.member.userId }</div>
+                    <div className="mx_MemberTile_userId">{ this.props.member.userId }</div>
                     { presence }
                 </div>
         }
@@ -138,7 +180,8 @@ module.exports = React.createClass({
         }
 
         return (
-            <div className={mainClassName} onMouseEnter={ this.mouseEnter } onMouseLeave={ this.mouseLeave }>
+            <div className={mainClassName} title={ this.getPowerLabel } onClick={ this.onClick } onMouseEnter={ this.mouseEnter } onMouseLeave={ this.mouseLeave }>
+                { menu }
                 <div className="mx_MemberTile_avatar">
                     <MemberAvatar member={this.props.member} />
                      { power }
diff --git a/skins/base/views/organisms/RoomView.js b/skins/base/views/organisms/RoomView.js
index b6bfedf1..eff25d96 100644
--- a/skins/base/views/organisms/RoomView.js
+++ b/skins/base/views/organisms/RoomView.js
@@ -72,7 +72,7 @@ module.exports = React.createClass({
         if (!this.state.numUnreadMessages) {
             return "";
         }
-        return this.state.numUnreadMessages + " new messages";
+        return this.state.numUnreadMessages + " new message" + (this.state.numUnreadMessages > 1 ? "s" : "");
     },
 
     scrollToBottom: function() {
diff --git a/src/controllers/molecules/MemberTile.js b/src/controllers/molecules/MemberTile.js
index ae468284..19c08731 100644
--- a/src/controllers/molecules/MemberTile.js
+++ b/src/controllers/molecules/MemberTile.js
@@ -25,13 +25,169 @@ var Loader = require("react-loader");
 var MatrixClientPeg = require("../../MatrixClientPeg");
 
 module.exports = {
-    onClick: function() {
-        dis.dispatch({
-            action: 'view_user',
-            user_id: this.props.member.userId
+    // onClick: function() {
+    //     dis.dispatch({
+    //         action: 'view_user',
+    //         user_id: this.props.member.userId
+    //     });
+    // },
+
+    onKick: function() {
+        var roomId = this.props.member.roomId;
+        var target = this.props.member.userId;
+        var self = this;
+        MatrixClientPeg.get().kick(roomId, target).done(function() {
+            // NO-OP; rely on the m.room.member event coming down else we could
+            // get out of sync if we force setState here!
+            console.log("Kick success");
+        }, function(err) {
+            Modal.createDialog(ErrorDialog, {
+                title: "Kick error",
+                description: err.message
+            });
         });
     },
 
+    onBan: function() {
+        var roomId = this.props.member.roomId;
+        var target = this.props.member.userId;
+        var self = this;
+        MatrixClientPeg.get().ban(roomId, target).done(function() {
+            // NO-OP; rely on the m.room.member event coming down else we could
+            // get out of sync if we force setState here!
+            console.log("Ban success");
+        }, function(err) {
+            Modal.createDialog(ErrorDialog, {
+                title: "Ban error",
+                description: err.message
+            });
+        });
+    },
+
+    onMuteToggle: function() {
+        var roomId = this.props.member.roomId;
+        var target = this.props.member.userId;
+        var self = this;
+        var room = MatrixClientPeg.get().getRoom(roomId);
+        if (!room) {
+            return;
+        }
+        var powerLevelEvent = room.currentState.getStateEvents(
+            "m.room.power_levels", ""
+        );
+        if (!powerLevelEvent) {
+            return;
+        }
+        var isMuted = this.state.muted;
+        var powerLevels = powerLevelEvent.getContent();
+        var levelToSend = (
+            (powerLevels.events ? powerLevels.events["m.room.message"] : null) ||
+            powerLevels.events_default
+        );
+        var level;
+        if (isMuted) { // unmute
+            level = levelToSend;
+        }
+        else { // mute
+            level = levelToSend - 1;
+        }
+
+        MatrixClientPeg.get().setPowerLevel(roomId, target, level, powerLevelEvent).done(
+        function() {
+            // NO-OP; rely on the m.room.member event coming down else we could
+            // get out of sync if we force setState here!
+            console.log("Mute toggle success");
+        }, function(err) {
+            Modal.createDialog(ErrorDialog, {
+                title: "Mute error",
+                description: err.message
+            });
+        });
+    },
+
+    onModToggle: function() {
+        var roomId = this.props.member.roomId;
+        var target = this.props.member.userId;
+        var room = MatrixClientPeg.get().getRoom(roomId);
+        if (!room) {
+            return;
+        }
+        var powerLevelEvent = room.currentState.getStateEvents(
+            "m.room.power_levels", ""
+        );
+        if (!powerLevelEvent) {
+            return;
+        }
+        var me = room.getMember(MatrixClientPeg.get().credentials.userId);
+        if (!me) {
+            return;
+        }
+        var defaultLevel = powerLevelEvent.getContent().users_default;
+        var modLevel = me.powerLevel - 1;
+        // toggle the level
+        var newLevel = this.state.isTargetMod ? defaultLevel : modLevel;
+        MatrixClientPeg.get().setPowerLevel(roomId, target, newLevel, powerLevelEvent).done(
+        function() {
+            // NO-OP; rely on the m.room.member event coming down else we could
+            // get out of sync if we force setState here!
+            console.log("Mod toggle success");
+        }, function(err) {
+            Modal.createDialog(ErrorDialog, {
+                title: "Mod error",
+                description: err.message
+            });
+        });
+    },
+
+    onChatClick: function() {
+        // check if there are any existing rooms with just us and them (1:1)
+        // If so, just view that room. If not, create a private room with them.
+        var rooms = MatrixClientPeg.get().getRooms();
+        var userIds = [
+            this.props.member.userId,
+            MatrixClientPeg.get().credentials.userId
+        ];
+        var existingRoomId = null;
+        for (var i = 0; i < rooms.length; i++) {
+            var members = rooms[i].getJoinedMembers();
+            if (members.length === 2) {
+                var hasTargetUsers = true;
+                for (var j = 0; j < members.length; j++) {
+                    if (userIds.indexOf(members[j].userId) === -1) {
+                        hasTargetUsers = false;
+                        break;
+                    }
+                }
+                if (hasTargetUsers) {
+                    existingRoomId = rooms[i].roomId;
+                    break;
+                }
+            }
+        }
+
+        if (existingRoomId) {
+            dis.dispatch({
+                action: 'view_room',
+                room_id: existingRoomId
+            });
+        }
+        else {
+            MatrixClientPeg.get().createRoom({
+                invite: [this.props.member.userId],
+                preset: "private_chat"
+            }).done(function(res) {
+                dis.dispatch({
+                    action: 'view_room',
+                    room_id: res.room_id
+                });
+            }, function(err) {
+                console.error(
+                    "Failed to create room: %s", JSON.stringify(err)
+                );
+            });
+        }
+    },
+
     onLeaveClick: function() {
         var roomId = this.props.member.roomId;
         Modal.createDialog(QuestionDialog, {
@@ -56,5 +212,84 @@ module.exports = {
                 }
             }
         });
-    }
+    },
+
+    getInitialState: function() {
+        return {
+            hover: false,
+            menu: false,
+
+            // presence: "offline",
+            // active: -1,
+            can: {
+                kick: false,
+                ban: false,
+                mute: false,
+                modifyLevel: false
+            },
+            muted: false,
+            isTargetMod: false,
+        }
+    },
+
+    _calculateOpsPermissions: function() {
+        var defaultPerms = {
+            can: {},
+            muted: false,
+            modifyLevel: false
+        };
+        var room = MatrixClientPeg.get().getRoom(this.props.member.roomId);
+        if (!room) {
+            return defaultPerms;
+        }
+        var powerLevels = room.currentState.getStateEvents(
+            "m.room.power_levels", ""
+        );
+        if (!powerLevels) {
+            return defaultPerms;
+        }
+        var me = room.getMember(MatrixClientPeg.get().credentials.userId);
+        var them = this.props.member;
+        return {
+            can: this._calculateCanPermissions(
+                me, them, powerLevels.getContent()
+            ),
+            muted: this._isMuted(them, powerLevels.getContent()),
+            isTargetMod: them.powerLevel > powerLevels.getContent().users_default
+        };
+    },
+
+    _calculateCanPermissions: function(me, them, powerLevels) {
+        var can = {
+            kick: false,
+            ban: false,
+            mute: false,
+            modifyLevel: false
+        };
+        var canAffectUser = them.powerLevel < me.powerLevel;
+        if (!canAffectUser) {
+            //console.log("Cannot affect user: %s >= %s", them.powerLevel, me.powerLevel);
+            return can;
+        }
+        var editPowerLevel = (
+            (powerLevels.events ? powerLevels.events["m.room.power_levels"] : null) ||
+            powerLevels.state_default
+        );
+        can.kick = me.powerLevel >= powerLevels.kick;
+        can.ban = me.powerLevel >= powerLevels.ban;
+        can.mute = me.powerLevel >= editPowerLevel;
+        can.modifyLevel = me.powerLevel > them.powerLevel;
+        return can;
+    },
+
+    _isMuted: function(member, powerLevelContent) {
+        if (!powerLevelContent || !member) {
+            return false;
+        }
+        var levelToSend = (
+            (powerLevelContent.events ? powerLevelContent.events["m.room.message"] : null) ||
+            powerLevelContent.events_default
+        );
+        return member.powerLevel < levelToSend;
+    },
 };
diff --git a/src/controllers/organisms/MemberList.js b/src/controllers/organisms/MemberList.js
index 912b142a..3eef007e 100644
--- a/src/controllers/organisms/MemberList.js
+++ b/src/controllers/organisms/MemberList.js
@@ -61,7 +61,9 @@ module.exports = {
         function updateUserState(event, user) {
             var tile = self.refs[user.userId];
             if (tile) {
-                tile.forceUpdate();
+                // update the whole list to get the order right, not just this cell...
+                self.forceUpdate();
+                // tile.forceUpdate();
             }
         }
         MatrixClientPeg.get().on("User.presence", updateUserState);

From ce2632bbe66e63e6cecc8885fe3f815b055acc17 Mon Sep 17 00:00:00 2001
From: Matthew Hodgson <matthew@matrix.org>
Date: Tue, 15 Sep 2015 15:18:39 +0100
Subject: [PATCH 4/8] thinko

---
 skins/base/views/molecules/MemberTile.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/skins/base/views/molecules/MemberTile.js b/skins/base/views/molecules/MemberTile.js
index 7c66616b..a60fbb06 100644
--- a/skins/base/views/molecules/MemberTile.js
+++ b/skins/base/views/molecules/MemberTile.js
@@ -180,7 +180,7 @@ module.exports = React.createClass({
         }
 
         return (
-            <div className={mainClassName} title={ this.getPowerLabel } onClick={ this.onClick } onMouseEnter={ this.mouseEnter } onMouseLeave={ this.mouseLeave }>
+            <div className={mainClassName} title={ this.getPowerLabel() } onClick={ this.onClick } onMouseEnter={ this.mouseEnter } onMouseLeave={ this.mouseLeave }>
                 { menu }
                 <div className="mx_MemberTile_avatar">
                     <MemberAvatar member={this.props.member} />

From 048260bb1b2bbc96d281d47d3eb0a743a139c19b Mon Sep 17 00:00:00 2001
From: Matthew Hodgson <matthew@matrix.org>
Date: Mon, 21 Sep 2015 19:22:29 +0200
Subject: [PATCH 5/8] WIP at turning MemberInfo into a ContextualMenu

---
 skins/base/css/common.css                |  36 +++-
 skins/base/css/molecules/MemberInfo.css  |  57 -----
 skins/base/views/molecules/MemberInfo.js |  62 +-----
 skins/base/views/molecules/MemberTile.js |  44 +---
 src/Modal.js                             |   4 +-
 src/controllers/molecules/MemberInfo.js  |  43 ----
 src/controllers/molecules/MemberTile.js  | 255 -----------------------
 7 files changed, 47 insertions(+), 454 deletions(-)

diff --git a/skins/base/css/common.css b/skins/base/css/common.css
index d90ce11a..9527b253 100644
--- a/skins/base/css/common.css
+++ b/skins/base/css/common.css
@@ -43,9 +43,39 @@ html {
     overflow: -moz-scrollbars-none;
 }
 
-/* FIXME: why is all the dialog stuff in here rather than in per-component files? */
+.mx_ContextualMenu_background {
+    position: fixed;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    opacity: 1.0;
+    z-index: 2000;
+}
 
-.mx_Dialog_Background {
+.mx_ContextualMenu {
+    text-align: center;
+    border: 1px solid #a9dbf4;
+    border-radius: 8px;
+    background-color: #fff;
+    position: fixed;
+    z-index: 1000;
+    padding: 6px;
+}
+
+.mx_ContextualMenu_chevron {
+    padding: 12px;
+    position: absolute;
+    right: -21px;
+    top: 0px;
+}
+
+.mx_ContextualMenu_field {
+    padding: 6px;
+}
+
+
+.mx_Dialog_background {
     position: fixed;
     top: 0;
     left: 0;
@@ -56,7 +86,7 @@ html {
     z-index: 2000;
 }
 
-.mx_Dialog_Wrapper {
+.mx_Dialog_wrapper {
     position: fixed;
     top: 0;
     left: 0;
diff --git a/skins/base/css/molecules/MemberInfo.css b/skins/base/css/molecules/MemberInfo.css
index 144212d7..52c48a79 100644
--- a/skins/base/css/molecules/MemberInfo.css
+++ b/skins/base/css/molecules/MemberInfo.css
@@ -14,60 +14,3 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-.mx_MemberInfo {
-    text-align: center;
-    border: 1px solid #a9dbf4;
-    border-radius: 8px;
-    background-color: #fff;
-    position: absolute;
-    width: 200px;
-    margin-left: -295px;
-    margin-top: 0px;
-    z-index: 1000;
-    padding: 6px;
-}
-
-.mx_MemberInfo_chevron {
-    padding: 12px;
-    position: absolute;
-    right: -21px;
-    top: 0px;
-}
-
-/*
- * a hacky shim to extend the hitmask of the overlay to overlap
- * better with the main menu itself
- */
-.mx_MemberInfo_shim {
-    position: absolute;
-    left: 212px;
-    width: 40px;
-    height: 100%;
-}
-
-.mx_MemberInfo_avatar {
-    padding: 6px;
-}
-
-.mx_MemberInfo_avatarImg {
-    border-radius: 128px;
-}
-
-.mx_MemberInfo_field {
-    padding: 6px;
-    overflow: hidden;
-    text-overflow: ellipsis;
-}
-
-.mx_MemberInfo_button {
-    vertical-align: middle;
-    max-width: 100px;
-    height: 36px;
-    background-color: #50e3c2;
-    line-height: 36px;
-    border-radius: 36px;
-    color: #fff;
-    margin: auto;
-    margin-top: 6px;
-    margin-bottom: 6px;
-}
diff --git a/skins/base/views/molecules/MemberInfo.js b/skins/base/views/molecules/MemberInfo.js
index e79b99a2..512ed9f4 100644
--- a/skins/base/views/molecules/MemberInfo.js
+++ b/skins/base/views/molecules/MemberInfo.js
@@ -27,84 +27,34 @@ module.exports = React.createClass({
     displayName: 'MemberInfo',
     mixins: [MemberInfoController],
 
-    componentDidMount: function() {
-        var self = this;
-
-        var memberInfo = this.getDOMNode();
-        var memberListScroll = document.getElementsByClassName("mx_MemberList_border")[0];
-        if (memberListScroll) {
-            memberInfo.style.top = (memberInfo.parentElement.offsetTop - memberListScroll.scrollTop) + "px";
-        }
-    },
-
-    getDuration: function(time) {
-        if (!time) return;
-        var t = parseInt(time / 1000);
-        var s = t % 60;
-        var m = parseInt(t / 60) % 60;
-        var h = parseInt(t / (60 * 60)) % 24;
-        var d = parseInt(t / (60 * 60 * 24));
-        if (t < 60) {
-            if (t < 0) {
-                return "0s";
-            }
-            return s + "s";
-        }
-        if (t < 60 * 60) {
-            return m + "m";
-        }
-        if (t < 24 * 60 * 60) {
-            return h + "h";
-        }
-        return d + "d ";
-    },
-
     render: function() {
-        var activeAgo = "unknown";
-        if (this.state.active >= 0) {
-            activeAgo = this.getDuration(this.state.active);
-        }
         var kickButton, banButton, muteButton, giveModButton;
         if (this.state.can.kick) {
-            kickButton = <div className="mx_MemberInfo_button" onClick={this.onKick}>
+            kickButton = <div className="mx_ContextualMenu_field" onClick={this.onKick}>
                 Kick
             </div>;
         }
         if (this.state.can.ban) {
-            banButton = <div className="mx_MemberInfo_button" onClick={this.onBan}>
+            banButton = <div className="mx_ContextualMenu_field" onClick={this.onBan}>
                 Ban
             </div>;
         }
         if (this.state.can.mute) {
             var muteLabel = this.state.muted ? "Unmute" : "Mute";
-            muteButton = <div className="mx_MemberInfo_button" onClick={this.onMuteToggle}>
+            muteButton = <div className="mx_ContextualMenu_field" onClick={this.onMuteToggle}>
                 {muteLabel}
             </div>;
         }
         if (this.state.can.modifyLevel) {
             var giveOpLabel = this.state.isTargetMod ? "Revoke Mod" : "Make Mod";
-            giveModButton = <div className="mx_MemberInfo_button" onClick={this.onModToggle}>
+            giveModButton = <div className="mx_ContextualMenu_field" onClick={this.onModToggle}>
                 {giveOpLabel}
             </div>
         }
 
-        var opLabel;
-        if (this.state.isTargetMod) {
-            var level = this.props.member.powerLevelNorm + "%";
-            opLabel = <div className="mx_MemberInfo_field">Moderator ({level})</div>
-        }
         return (
-            <div className="mx_MemberInfo">
-                <img className="mx_MemberInfo_chevron" src="img/chevron-right.png" width="9" height="16" />
-                <div className="mx_MemberInfo_shim"></div>
-                <div className="mx_MemberInfo_avatar">
-                    <MemberAvatar member={this.props.member} width={128} height={128} />
-                </div>
-                <div className="mx_MemberInfo_field">{this.props.member.userId}</div>
-                {opLabel}
-                <div className="mx_MemberInfo_field">Presence: {this.state.presence}</div>
-                <div className="mx_MemberInfo_field">Last active: {activeAgo}</div>
-                <div className="mx_MemberInfo_button" onClick={this.onChatClick}>Start chat</div>
+            <div>
+                <div className="mx_ContextualMenu_field" onClick={this.onChatClick}>Start chat</div>
                 {muteButton}
                 {kickButton}
                 {banButton}
diff --git a/skins/base/views/molecules/MemberTile.js b/skins/base/views/molecules/MemberTile.js
index a60fbb06..2cfceb4d 100644
--- a/skins/base/views/molecules/MemberTile.js
+++ b/skins/base/views/molecules/MemberTile.js
@@ -21,6 +21,7 @@ var React = require('react');
 var MatrixClientPeg = require("../../../../src/MatrixClientPeg");
 var ComponentBroker = require('../../../../src/ComponentBroker');
 var Modal = require("../../../../src/Modal");
+var ContextualMenu = require("../../../../src/ContextualMenu");
 var MemberTileController = require("../../../../src/controllers/molecules/MemberTile");
 var MemberInfo = ComponentBroker.get('molecules/MemberInfo');
 var ErrorDialog = ComponentBroker.get("organisms/ErrorDialog");
@@ -43,8 +44,11 @@ module.exports = React.createClass({
     },
 
     onClick: function(e) {
-        this.setState({ 'menu': true });
-        this.setState(this._calculateOpsPermissions());        
+        ContextualMenu.createMenu(MemberInfo, {
+            member: this.props.member,
+            right: window.innerWidth - e.pageX,
+            top: e.pageY,
+        });
     },
 
     getDuration: function(time) {
@@ -118,41 +122,6 @@ module.exports = React.createClass({
             nameClass += " mx_MemberTile_zalgo";
         }
 
-        var menu;
-        if (this.state.menu) {
-            var kickButton, banButton, muteButton, giveModButton;
-            if (this.state.can.kick) {
-                kickButton = <div className="mx_MemberTile_menuItem" onClick={this.onKick}>
-                    Kick
-                </div>;
-            }
-            if (this.state.can.ban) {
-                banButton = <div className="mx_MemberTile_menuItem" onClick={this.onBan}>
-                    Ban
-                </div>;
-            }
-            if (this.state.can.mute) {
-                var muteLabel = this.state.muted ? "Unmute" : "Mute";
-                muteButton = <div className="mx_MemberTile_menuItem" onClick={this.onMuteToggle}>
-                    {muteLabel}
-                </div>;
-            }
-            if (this.state.can.modifyLevel) {
-                var giveOpLabel = this.state.isTargetMod ? "Revoke Mod" : "Make Mod";
-                giveModButton = <div className="mx_MemberTile_menuItem" onClick={this.onModToggle}>
-                    {giveOpLabel}
-                </div>
-            }
-            menu = <div className="mx_MemberTile_menu">
-                        <img className="mx_MemberTile_chevron" src="img/chevron-right.png" width="9" height="16" />
-                        <div className="mx_MemberTile_menuItem" onClick={this.onChatClick}>Chat</div>
-                        {muteButton}
-                        {kickButton}
-                        {banButton}
-                        {giveModButton}
-                   </div>;
-        }
-
         var nameEl;
         if (this.state.hover) {
             var presence;
@@ -181,7 +150,6 @@ module.exports = React.createClass({
 
         return (
             <div className={mainClassName} title={ this.getPowerLabel() } onClick={ this.onClick } onMouseEnter={ this.mouseEnter } onMouseLeave={ this.mouseLeave }>
-                { menu }
                 <div className="mx_MemberTile_avatar">
                     <MemberAvatar member={this.props.member} />
                      { power }
diff --git a/src/Modal.js b/src/Modal.js
index d18b39e6..ba7660bf 100644
--- a/src/Modal.js
+++ b/src/Modal.js
@@ -47,11 +47,11 @@ module.exports = {
         // FIXME: If a dialog uses getDefaultProps it clobbers the onFinished
         // property set here so you can't close the dialog from a button click!
         var dialog = (
-            <div className="mx_Dialog_Wrapper">
+            <div className="mx_Dialog_wrapper">
                 <div className="mx_Dialog">
                     <Element {...props} onFinished={closeDialog}/>
                 </div>
-                <div className="mx_Dialog_Background" onClick={closeDialog}></div>
+                <div className="mx_Dialog_background" onClick={closeDialog}></div>
             </div>
         );
 
diff --git a/src/controllers/molecules/MemberInfo.js b/src/controllers/molecules/MemberInfo.js
index 5404c337..5c63d970 100644
--- a/src/controllers/molecules/MemberInfo.js
+++ b/src/controllers/molecules/MemberInfo.js
@@ -16,8 +16,6 @@ limitations under the License.
 
 /*
  * State vars:
- * 'presence' : string (online|offline|unavailable etc)
- * 'active' : number (ms ago; can be -1)
  * 'can': {
  *   kick: boolean,
  *   ban: boolean,
@@ -38,54 +36,15 @@ var ErrorDialog = ComponentBroker.get("organisms/ErrorDialog");
 module.exports = {
     componentDidMount: function() {
         var self = this;
-        // listen for presence changes
-        function updateUserState(event, user) {
-            if (!self.props.member) { return; }
-
-            if (user.userId === self.props.member.userId) {
-                self.setState({
-                    presence: user.presence,
-                    active: user.lastActiveAgo
-                });
-            }
-        }
-        MatrixClientPeg.get().on("User.presence", updateUserState);
-        this.userPresenceFn = updateUserState;
-
-        // listen for power level changes
-        function updatePowerLevel(event, member) {
-            if (!self.props.member) { return; }
-
-            if (member.roomId !== self.props.member.roomId) {
-                return;
-            }
-            // only interested in changes to us or them
-            var myUserId = MatrixClientPeg.get().credentials.userId;
-            if ([myUserId, self.props.member.userId].indexOf(member.userId) === -1) {
-                return;
-            }
-            self.setState(self._calculateOpsPermissions());
-        }
-        MatrixClientPeg.get().on("RoomMember.powerLevel", updatePowerLevel);
-        this.updatePowerLevelFn = updatePowerLevel;
 
         // work out the current state
         if (this.props.member) {
             var usr = MatrixClientPeg.get().getUser(this.props.member.userId) || {};
             var memberState = this._calculateOpsPermissions();
-            memberState.presence = usr.presence || "offline";
-            memberState.active = usr.lastActiveAgo || -1;
             this.setState(memberState);
         }
     },
 
-    componentWillUnmount: function() {
-        MatrixClientPeg.get().removeListener("User.presence", this.userPresenceFn);
-        MatrixClientPeg.get().removeListener(
-            "RoomMember.powerLevel", this.updatePowerLevelFn
-        );
-    },
-
     onKick: function() {
         var roomId = this.props.member.roomId;
         var target = this.props.member.userId;
@@ -244,8 +203,6 @@ module.exports = {
 
     getInitialState: function() {
         return {
-            presence: "offline",
-            active: -1,
             can: {
                 kick: false,
                 ban: false,
diff --git a/src/controllers/molecules/MemberTile.js b/src/controllers/molecules/MemberTile.js
index 19c08731..38bf43d6 100644
--- a/src/controllers/molecules/MemberTile.js
+++ b/src/controllers/molecules/MemberTile.js
@@ -32,264 +32,9 @@ module.exports = {
     //     });
     // },
 
-    onKick: function() {
-        var roomId = this.props.member.roomId;
-        var target = this.props.member.userId;
-        var self = this;
-        MatrixClientPeg.get().kick(roomId, target).done(function() {
-            // NO-OP; rely on the m.room.member event coming down else we could
-            // get out of sync if we force setState here!
-            console.log("Kick success");
-        }, function(err) {
-            Modal.createDialog(ErrorDialog, {
-                title: "Kick error",
-                description: err.message
-            });
-        });
-    },
-
-    onBan: function() {
-        var roomId = this.props.member.roomId;
-        var target = this.props.member.userId;
-        var self = this;
-        MatrixClientPeg.get().ban(roomId, target).done(function() {
-            // NO-OP; rely on the m.room.member event coming down else we could
-            // get out of sync if we force setState here!
-            console.log("Ban success");
-        }, function(err) {
-            Modal.createDialog(ErrorDialog, {
-                title: "Ban error",
-                description: err.message
-            });
-        });
-    },
-
-    onMuteToggle: function() {
-        var roomId = this.props.member.roomId;
-        var target = this.props.member.userId;
-        var self = this;
-        var room = MatrixClientPeg.get().getRoom(roomId);
-        if (!room) {
-            return;
-        }
-        var powerLevelEvent = room.currentState.getStateEvents(
-            "m.room.power_levels", ""
-        );
-        if (!powerLevelEvent) {
-            return;
-        }
-        var isMuted = this.state.muted;
-        var powerLevels = powerLevelEvent.getContent();
-        var levelToSend = (
-            (powerLevels.events ? powerLevels.events["m.room.message"] : null) ||
-            powerLevels.events_default
-        );
-        var level;
-        if (isMuted) { // unmute
-            level = levelToSend;
-        }
-        else { // mute
-            level = levelToSend - 1;
-        }
-
-        MatrixClientPeg.get().setPowerLevel(roomId, target, level, powerLevelEvent).done(
-        function() {
-            // NO-OP; rely on the m.room.member event coming down else we could
-            // get out of sync if we force setState here!
-            console.log("Mute toggle success");
-        }, function(err) {
-            Modal.createDialog(ErrorDialog, {
-                title: "Mute error",
-                description: err.message
-            });
-        });
-    },
-
-    onModToggle: function() {
-        var roomId = this.props.member.roomId;
-        var target = this.props.member.userId;
-        var room = MatrixClientPeg.get().getRoom(roomId);
-        if (!room) {
-            return;
-        }
-        var powerLevelEvent = room.currentState.getStateEvents(
-            "m.room.power_levels", ""
-        );
-        if (!powerLevelEvent) {
-            return;
-        }
-        var me = room.getMember(MatrixClientPeg.get().credentials.userId);
-        if (!me) {
-            return;
-        }
-        var defaultLevel = powerLevelEvent.getContent().users_default;
-        var modLevel = me.powerLevel - 1;
-        // toggle the level
-        var newLevel = this.state.isTargetMod ? defaultLevel : modLevel;
-        MatrixClientPeg.get().setPowerLevel(roomId, target, newLevel, powerLevelEvent).done(
-        function() {
-            // NO-OP; rely on the m.room.member event coming down else we could
-            // get out of sync if we force setState here!
-            console.log("Mod toggle success");
-        }, function(err) {
-            Modal.createDialog(ErrorDialog, {
-                title: "Mod error",
-                description: err.message
-            });
-        });
-    },
-
-    onChatClick: function() {
-        // check if there are any existing rooms with just us and them (1:1)
-        // If so, just view that room. If not, create a private room with them.
-        var rooms = MatrixClientPeg.get().getRooms();
-        var userIds = [
-            this.props.member.userId,
-            MatrixClientPeg.get().credentials.userId
-        ];
-        var existingRoomId = null;
-        for (var i = 0; i < rooms.length; i++) {
-            var members = rooms[i].getJoinedMembers();
-            if (members.length === 2) {
-                var hasTargetUsers = true;
-                for (var j = 0; j < members.length; j++) {
-                    if (userIds.indexOf(members[j].userId) === -1) {
-                        hasTargetUsers = false;
-                        break;
-                    }
-                }
-                if (hasTargetUsers) {
-                    existingRoomId = rooms[i].roomId;
-                    break;
-                }
-            }
-        }
-
-        if (existingRoomId) {
-            dis.dispatch({
-                action: 'view_room',
-                room_id: existingRoomId
-            });
-        }
-        else {
-            MatrixClientPeg.get().createRoom({
-                invite: [this.props.member.userId],
-                preset: "private_chat"
-            }).done(function(res) {
-                dis.dispatch({
-                    action: 'view_room',
-                    room_id: res.room_id
-                });
-            }, function(err) {
-                console.error(
-                    "Failed to create room: %s", JSON.stringify(err)
-                );
-            });
-        }
-    },
-
-    onLeaveClick: function() {
-        var roomId = this.props.member.roomId;
-        Modal.createDialog(QuestionDialog, {
-            title: "Leave room",
-            description: "Are you sure you want to leave the room?",
-            onFinished: function(should_leave) {
-                if (should_leave) {
-                    var d = MatrixClientPeg.get().leave(roomId);
-
-                    var modal = Modal.createDialog(Loader);
-
-                    d.then(function() {
-                        modal.close();
-                        dis.dispatch({action: 'view_next_room'});
-                    }, function(err) {
-                        modal.close();
-                        Modal.createDialog(ErrorDialog, {
-                            title: "Failed to leave room",
-                            description: err.toString()
-                        });
-                    });
-                }
-            }
-        });
-    },
-
     getInitialState: function() {
         return {
             hover: false,
-            menu: false,
-
-            // presence: "offline",
-            // active: -1,
-            can: {
-                kick: false,
-                ban: false,
-                mute: false,
-                modifyLevel: false
-            },
-            muted: false,
-            isTargetMod: false,
         }
     },
-
-    _calculateOpsPermissions: function() {
-        var defaultPerms = {
-            can: {},
-            muted: false,
-            modifyLevel: false
-        };
-        var room = MatrixClientPeg.get().getRoom(this.props.member.roomId);
-        if (!room) {
-            return defaultPerms;
-        }
-        var powerLevels = room.currentState.getStateEvents(
-            "m.room.power_levels", ""
-        );
-        if (!powerLevels) {
-            return defaultPerms;
-        }
-        var me = room.getMember(MatrixClientPeg.get().credentials.userId);
-        var them = this.props.member;
-        return {
-            can: this._calculateCanPermissions(
-                me, them, powerLevels.getContent()
-            ),
-            muted: this._isMuted(them, powerLevels.getContent()),
-            isTargetMod: them.powerLevel > powerLevels.getContent().users_default
-        };
-    },
-
-    _calculateCanPermissions: function(me, them, powerLevels) {
-        var can = {
-            kick: false,
-            ban: false,
-            mute: false,
-            modifyLevel: false
-        };
-        var canAffectUser = them.powerLevel < me.powerLevel;
-        if (!canAffectUser) {
-            //console.log("Cannot affect user: %s >= %s", them.powerLevel, me.powerLevel);
-            return can;
-        }
-        var editPowerLevel = (
-            (powerLevels.events ? powerLevels.events["m.room.power_levels"] : null) ||
-            powerLevels.state_default
-        );
-        can.kick = me.powerLevel >= powerLevels.kick;
-        can.ban = me.powerLevel >= powerLevels.ban;
-        can.mute = me.powerLevel >= editPowerLevel;
-        can.modifyLevel = me.powerLevel > them.powerLevel;
-        return can;
-    },
-
-    _isMuted: function(member, powerLevelContent) {
-        if (!powerLevelContent || !member) {
-            return false;
-        }
-        var levelToSend = (
-            (powerLevelContent.events ? powerLevelContent.events["m.room.message"] : null) ||
-            powerLevelContent.events_default
-        );
-        return member.powerLevel < levelToSend;
-    },
 };

From 260e22186b1e33eea5a08dd2df388d619e7d9c3a Mon Sep 17 00:00:00 2001
From: Matthew Hodgson <matthew@matrix.org>
Date: Mon, 21 Sep 2015 19:23:04 +0200
Subject: [PATCH 6/8] WIP at turning MemberInfo into a ContextualMenu

---
 skins/base/views/molecules/ContextualMenu.js | 35 ++++++++++
 src/ContextualMenu.js                        | 72 ++++++++++++++++++++
 2 files changed, 107 insertions(+)
 create mode 100644 skins/base/views/molecules/ContextualMenu.js
 create mode 100644 src/ContextualMenu.js

diff --git a/skins/base/views/molecules/ContextualMenu.js b/skins/base/views/molecules/ContextualMenu.js
new file mode 100644
index 00000000..58c542ee
--- /dev/null
+++ b/skins/base/views/molecules/ContextualMenu.js
@@ -0,0 +1,35 @@
+/*
+Copyright 2015 OpenMarket Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+'use strict';
+
+var React = require('react');
+var classNames = require('classnames');
+
+var dis = require("../../../../src/dispatcher");
+
+var MatrixClientPeg = require("../../../../src/MatrixClientPeg");
+
+module.exports = React.createClass({
+    displayName: 'ContextualMenu',
+
+    render: function() {
+        return (
+            <div className="mx_ContextualMenu">
+            </div>
+        );
+    }
+});
diff --git a/src/ContextualMenu.js b/src/ContextualMenu.js
new file mode 100644
index 00000000..cabab0c3
--- /dev/null
+++ b/src/ContextualMenu.js
@@ -0,0 +1,72 @@
+/*
+Copyright 2015 OpenMarket Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+
+'use strict';
+
+var React = require('react');
+var q = require('q');
+
+// Shamelessly ripped off Modal.js.  There's probably a better way
+// of doing reusable widgets like dialog boxes & menus where we go and
+// pass in a custom control as the actual body.
+
+module.exports = {
+    ContextualMenuContainerId: "mx_ContextualMenu_Container",
+
+    getOrCreateContainer: function() {
+        var container = document.getElementById(this.ContextualMenuContainerId);
+
+        if (!container) {
+            container = document.createElement("div");
+            container.id = this.ContextualMenuContainerId;
+            document.body.appendChild(container);
+        }
+
+        return container;
+    },
+
+    createMenu: function (Element, props) {
+        var self = this;
+
+        var closeMenu = function() {
+            React.unmountComponentAtNode(self.getOrCreateContainer());
+
+            if (props && props.onFinished) props.onFinished.apply(null, arguments);
+        };
+
+        var position = {
+            top: props.top - 20,
+            right: props.right + 8,
+        };
+
+        // FIXME: If a menu uses getDefaultProps it clobbers the onFinished
+        // property set here so you can't close the menu from a button click!
+        var menu = (
+            <div className="mx_ContextualMenu_wrapper">
+                <div className="mx_ContextualMenu" style={position}>
+                    <img className="mx_ContextualMenu_chevron" src="img/chevron-right.png" width="9" height="16" />
+                    <Element {...props} onFinished={closeMenu}/>
+                </div>
+                <div className="mx_ContextualMenu_background" onClick={closeMenu}></div>
+            </div>
+        );
+
+        React.render(menu, this.getOrCreateContainer());
+
+        return {close: closeMenu};
+    },
+};

From 61c94d63e7d93db8c029b53ee4a9c5f0bcf76313 Mon Sep 17 00:00:00 2001
From: Matthew Hodgson <matthew@matrix.org>
Date: Tue, 22 Sep 2015 01:16:45 +0200
Subject: [PATCH 7/8] make the new userlist UI actually work

---
 skins/base/css/common.css                |  7 ++--
 skins/base/views/molecules/MemberInfo.js | 11 +++++--
 skins/base/views/molecules/MemberTile.js | 11 +++++--
 src/controllers/molecules/MemberInfo.js  | 42 ++++++++++++++++++++++++
 src/controllers/molecules/MemberTile.js  | 29 ++++++++++++++++
 5 files changed, 92 insertions(+), 8 deletions(-)

diff --git a/skins/base/css/common.css b/skins/base/css/common.css
index 9527b253..d6c5e091 100644
--- a/skins/base/css/common.css
+++ b/skins/base/css/common.css
@@ -54,12 +54,12 @@ html {
 }
 
 .mx_ContextualMenu {
-    text-align: center;
     border: 1px solid #a9dbf4;
     border-radius: 8px;
     background-color: #fff;
+    color: #747474;
     position: fixed;
-    z-index: 1000;
+    z-index: 2001;
     padding: 6px;
 }
 
@@ -71,7 +71,8 @@ html {
 }
 
 .mx_ContextualMenu_field {
-    padding: 6px;
+    padding: 3px 6px 3px 6px;
+    cursor: pointer;
 }
 
 
diff --git a/skins/base/views/molecules/MemberInfo.js b/skins/base/views/molecules/MemberInfo.js
index 512ed9f4..b57a5b6f 100644
--- a/skins/base/views/molecules/MemberInfo.js
+++ b/skins/base/views/molecules/MemberInfo.js
@@ -28,7 +28,14 @@ module.exports = React.createClass({
     mixins: [MemberInfoController],
 
     render: function() {
-        var kickButton, banButton, muteButton, giveModButton;
+        var interactButton, kickButton, banButton, muteButton, giveModButton;
+        if (this.props.member.userId === MatrixClientPeg.get().credentials.userId) {
+            interactButton = <div className="mx_ContextualMenu_field" onClick={this.onLeaveClick}>Leave room</div>;
+        }
+        else {
+            interactButton = <div className="mx_ContextualMenu_field" onClick={this.onChatClick}>Start chat</div>;
+        }
+
         if (this.state.can.kick) {
             kickButton = <div className="mx_ContextualMenu_field" onClick={this.onKick}>
                 Kick
@@ -54,7 +61,7 @@ module.exports = React.createClass({
 
         return (
             <div>
-                <div className="mx_ContextualMenu_field" onClick={this.onChatClick}>Start chat</div>
+                {interactButton}
                 {muteButton}
                 {kickButton}
                 {banButton}
diff --git a/skins/base/views/molecules/MemberTile.js b/skins/base/views/molecules/MemberTile.js
index 2cfceb4d..1414b562 100644
--- a/skins/base/views/molecules/MemberTile.js
+++ b/skins/base/views/molecules/MemberTile.js
@@ -44,10 +44,15 @@ module.exports = React.createClass({
     },
 
     onClick: function(e) {
+        var self = this;
+        self.setState({ 'menu': true });
         ContextualMenu.createMenu(MemberInfo, {
-            member: this.props.member,
+            member: self.props.member,
             right: window.innerWidth - e.pageX,
             top: e.pageY,
+            onFinished: function() {
+                self.setState({ 'menu': false });
+            }
         });
     },
 
@@ -109,7 +114,7 @@ module.exports = React.createClass({
             }
         }
         mainClassName += presenceClass;
-        if (this.state.hover) {
+        if (this.state.hover || this.state.menu) {
             mainClassName += " mx_MemberTile_hover";
         }
 
@@ -123,7 +128,7 @@ module.exports = React.createClass({
         }
 
         var nameEl;
-        if (this.state.hover) {
+        if (this.state.hover || this.state.menu) {
             var presence;
             // FIXME: make presence data update whenever User.presence changes...
             var active = this.props.member.user ? (this.props.member.user.lastActiveAgo || -1) : -1;
diff --git a/src/controllers/molecules/MemberInfo.js b/src/controllers/molecules/MemberInfo.js
index 5c63d970..2e7c3f71 100644
--- a/src/controllers/molecules/MemberInfo.js
+++ b/src/controllers/molecules/MemberInfo.js
@@ -32,6 +32,8 @@ var dis = require("../../dispatcher");
 var Modal = require("../../Modal");
 var ComponentBroker = require('../../ComponentBroker');
 var ErrorDialog = ComponentBroker.get("organisms/ErrorDialog");
+var QuestionDialog = ComponentBroker.get("organisms/QuestionDialog");
+var Loader = require("react-loader");
 
 module.exports = {
     componentDidMount: function() {
@@ -59,6 +61,7 @@ module.exports = {
                 description: err.message
             });
         });
+        this.props.onFinished();
     },
 
     onBan: function() {
@@ -75,6 +78,7 @@ module.exports = {
                 description: err.message
             });
         });
+        this.props.onFinished();
     },
 
     onMuteToggle: function() {
@@ -83,12 +87,14 @@ module.exports = {
         var self = this;
         var room = MatrixClientPeg.get().getRoom(roomId);
         if (!room) {
+            this.props.onFinished();
             return;
         }
         var powerLevelEvent = room.currentState.getStateEvents(
             "m.room.power_levels", ""
         );
         if (!powerLevelEvent) {
+            this.props.onFinished();
             return;
         }
         var isMuted = this.state.muted;
@@ -116,6 +122,7 @@ module.exports = {
                 description: err.message
             });
         });
+        this.props.onFinished();        
     },
 
     onModToggle: function() {
@@ -123,16 +130,19 @@ module.exports = {
         var target = this.props.member.userId;
         var room = MatrixClientPeg.get().getRoom(roomId);
         if (!room) {
+            this.props.onFinished();
             return;
         }
         var powerLevelEvent = room.currentState.getStateEvents(
             "m.room.power_levels", ""
         );
         if (!powerLevelEvent) {
+            this.props.onFinished();
             return;
         }
         var me = room.getMember(MatrixClientPeg.get().credentials.userId);
         if (!me) {
+            this.props.onFinished();
             return;
         }
         var defaultLevel = powerLevelEvent.getContent().users_default;
@@ -150,6 +160,7 @@ module.exports = {
                 description: err.message
             });
         });
+        this.props.onFinished();        
     },
 
     onChatClick: function() {
@@ -199,6 +210,37 @@ module.exports = {
                 );
             });
         }
+        this.props.onFinished();                
+    },
+
+    // FIXME: this is horribly duplicated with MemberTile's onLeaveClick.
+    // Not sure what the right solution to this is.
+    onLeaveClick: function() {
+        console.log("leaving room");
+        var roomId = this.props.member.roomId;
+        Modal.createDialog(QuestionDialog, {
+            title: "Leave room",
+            description: "Are you sure you want to leave the room?",
+            onFinished: function(should_leave) {
+                if (should_leave) {
+                    var d = MatrixClientPeg.get().leave(roomId);
+
+                    var modal = Modal.createDialog(Loader);
+
+                    d.then(function() {
+                        modal.close();
+                        dis.dispatch({action: 'view_next_room'});
+                    }, function(err) {
+                        modal.close();
+                        Modal.createDialog(ErrorDialog, {
+                            title: "Failed to leave room",
+                            description: err.toString()
+                        });
+                    });
+                }
+            }
+        });
+        this.props.onFinished();        
     },
 
     getInitialState: function() {
diff --git a/src/controllers/molecules/MemberTile.js b/src/controllers/molecules/MemberTile.js
index 38bf43d6..43db7d1d 100644
--- a/src/controllers/molecules/MemberTile.js
+++ b/src/controllers/molecules/MemberTile.js
@@ -35,6 +35,35 @@ module.exports = {
     getInitialState: function() {
         return {
             hover: false,
+            menu: false,
         }
     },
+
+    onLeaveClick: function(ev) {
+        ev.stopPropagation();
+        ev.preventDefault();
+        var roomId = this.props.member.roomId;
+        Modal.createDialog(QuestionDialog, {
+            title: "Leave room",
+            description: "Are you sure you want to leave the room?",
+            onFinished: function(should_leave) {
+                if (should_leave) {
+                    var d = MatrixClientPeg.get().leave(roomId);
+
+                    var modal = Modal.createDialog(Loader);
+
+                    d.then(function() {
+                        modal.close();
+                        dis.dispatch({action: 'view_next_room'});
+                    }, function(err) {
+                        modal.close();
+                        Modal.createDialog(ErrorDialog, {
+                            title: "Failed to leave room",
+                            description: err.toString()
+                        });
+                    });
+                }
+            }
+        });
+    }    
 };

From ef3603cd1a7b6778e0ff9bb45bfa56bd26060e94 Mon Sep 17 00:00:00 2001
From: Matthew Hodgson <matthew@matrix.org>
Date: Tue, 22 Sep 2015 01:25:58 +0200
Subject: [PATCH 8/8] oops, rogue debugging stmt

---
 src/controllers/molecules/MemberInfo.js | 1 -
 1 file changed, 1 deletion(-)

diff --git a/src/controllers/molecules/MemberInfo.js b/src/controllers/molecules/MemberInfo.js
index 2e7c3f71..21cbe7a5 100644
--- a/src/controllers/molecules/MemberInfo.js
+++ b/src/controllers/molecules/MemberInfo.js
@@ -216,7 +216,6 @@ module.exports = {
     // FIXME: this is horribly duplicated with MemberTile's onLeaveClick.
     // Not sure what the right solution to this is.
     onLeaveClick: function() {
-        console.log("leaving room");
         var roomId = this.props.member.roomId;
         Modal.createDialog(QuestionDialog, {
             title: "Leave room",