From 2e2cecdd4fc72b56614d7548293cd8dda810b3ff Mon Sep 17 00:00:00 2001
From: Kegan Dougal <kegan@matrix.org>
Date: Tue, 21 Jul 2015 11:26:02 +0100
Subject: [PATCH 1/4] Implement kick/ban/[un]mute buttons on member list
 dialogs.

---
 skins/base/views/molecules/MemberInfo.js |  20 +++
 src/controllers/molecules/MemberInfo.js  | 182 ++++++++++++++++++++++-
 2 files changed, 196 insertions(+), 6 deletions(-)

diff --git a/skins/base/views/molecules/MemberInfo.js b/skins/base/views/molecules/MemberInfo.js
index 3a81185b..036a3fd6 100644
--- a/skins/base/views/molecules/MemberInfo.js
+++ b/skins/base/views/molecules/MemberInfo.js
@@ -67,6 +67,23 @@ module.exports = React.createClass({
         if (this.state.active >= 0) {
             activeAgo = this.getDuration(this.state.active);
         }
+        var kickButton, banButton, muteButton;
+        if (this.state.can.kick) {
+            kickButton = <div className="mx_MemberInfo_button" onClick={this.onKick}>
+                Kick
+            </div>;
+        }
+        if (this.state.can.ban) {
+            banButton = <div className="mx_MemberInfo_button" 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}>
+                {muteLabel}
+            </div>;
+        }
 
         return (
             <div className="mx_MemberInfo">
@@ -81,6 +98,9 @@ module.exports = React.createClass({
                 <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>
+                {muteButton}
+                {kickButton}
+                {banButton}
             </div>
         );
     }
diff --git a/src/controllers/molecules/MemberInfo.js b/src/controllers/molecules/MemberInfo.js
index 96f8bb8c..fd04df4d 100644
--- a/src/controllers/molecules/MemberInfo.js
+++ b/src/controllers/molecules/MemberInfo.js
@@ -18,15 +18,25 @@ limitations under the License.
  * State vars:
  * 'presence' : string (online|offline|unavailable etc)
  * 'active' : number (ms ago; can be -1)
+ * 'can': {
+ *   kick: boolean,
+ *   ban: boolean,
+ *   mute: boolean
+ * },
+ * 'muted': boolean
  */
 
 'use strict';
 var MatrixClientPeg = require("../../MatrixClientPeg");
 var dis = require("../../dispatcher");
+var Modal = require("../../Modal");
+var ComponentBroker = require('../../ComponentBroker');
+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; }
 
@@ -40,20 +50,116 @@ module.exports = {
         MatrixClientPeg.get().on("User.presence", updateUserState);
         this.userPresenceFn = updateUserState;
 
-        if (this.props.member) {
-            var usr = MatrixClientPeg.get().getUser(this.props.member.userId);
-            if (!usr) {
+        // 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({
+                can: self._calculateOpsPermissions(),
+                muted: self._isMuted(self.props.member)
+            });
+        }
+        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) || {};
             this.setState({
-                presence: usr.presence,
-                active: usr.lastActiveAgo
+                presence: usr.presence || "offline",
+                active: usr.lastActiveAgo || -1,
+                can: this._calculateOpsPermissions(),
+                muted: this._isMuted(this.props.member)
             });
         }
     },
 
     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;
+        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
+            });
+        });
     },
 
     onChatClick: function() {
@@ -108,8 +214,72 @@ module.exports = {
     getInitialState: function() {
         return {
             presence: "offline",
-            active: -1
+            active: -1,
+            can: {
+                kick: false,
+                ban: false,
+                mute: false
+            },
+            muted: false
         }
+    },
+
+    _isMuted: function(member) {
+        var room = MatrixClientPeg.get().getRoom(member.roomId);
+        if (!room) {
+            return false;
+        }
+        var powerLevels = room.currentState.getStateEvents(
+            "m.room.power_levels", ""
+        );
+        if (!powerLevels) {
+            return false;
+        }
+        powerLevels = powerLevels.getContent();
+        var levelToSend = (
+            (powerLevels.events ? powerLevels.events["m.room.message"] : null) ||
+            powerLevels.events_default
+        );
+        return member.powerLevel < levelToSend;
+    },
+
+    _calculateOpsPermissions: function() {
+        var can = {
+            kick: false,
+            ban: false,
+            mute: false
+        };
+        var them = this.props.member;
+        var room = MatrixClientPeg.get().getRoom(this.props.member.roomId);
+        if (!room) {
+            console.error("No room found");
+            return can;
+        }
+        var myUserId = MatrixClientPeg.get().credentials.userId;
+        var me = room.getMember(myUserId);
+        var powerLevels = room.currentState.getStateEvents(
+            "m.room.power_levels", ""
+        );
+        if (powerLevels) {
+            powerLevels = powerLevels.getContent();
+        }
+        else {
+            console.log("No power level event found in %s", room.roomId);
+            return can; // no power level event, don't allow anything.
+        }
+        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;
+        return can;
     }
 };
 

From 13f04f77dc542dd3522022d6e889f31a4276d727 Mon Sep 17 00:00:00 2001
From: Kegan Dougal <kegan@matrix.org>
Date: Tue, 21 Jul 2015 11:43:18 +0100
Subject: [PATCH 2/4] Tidy up power level logic. Add 'Moderator' label to
 members.

---
 skins/base/views/molecules/MemberInfo.js |  6 +-
 src/controllers/molecules/MemberInfo.js  | 79 ++++++++++++------------
 2 files changed, 43 insertions(+), 42 deletions(-)

diff --git a/skins/base/views/molecules/MemberInfo.js b/skins/base/views/molecules/MemberInfo.js
index 036a3fd6..45db0c30 100644
--- a/skins/base/views/molecules/MemberInfo.js
+++ b/skins/base/views/molecules/MemberInfo.js
@@ -84,7 +84,10 @@ module.exports = React.createClass({
                 {muteLabel}
             </div>;
         }
-
+        var opLabel;
+        if (this.state.isTargetOp) {
+            opLabel = <div className="mx_MemberInfo_field">Moderator</div>
+        }
         return (
             <div className="mx_MemberInfo">
                 <img className="mx_MemberInfo_chevron" src="img/chevron-right.png" width="9" height="16" />
@@ -95,6 +98,7 @@ module.exports = React.createClass({
                          width="128" height="128" alt=""/>
                 </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>
diff --git a/src/controllers/molecules/MemberInfo.js b/src/controllers/molecules/MemberInfo.js
index fd04df4d..d335eec5 100644
--- a/src/controllers/molecules/MemberInfo.js
+++ b/src/controllers/molecules/MemberInfo.js
@@ -23,7 +23,8 @@ limitations under the License.
  *   ban: boolean,
  *   mute: boolean
  * },
- * 'muted': boolean
+ * 'muted': boolean,
+ * 'isTargetOp': boolean
  */
 
 'use strict';
@@ -62,10 +63,7 @@ module.exports = {
             if ([myUserId, self.props.member.userId].indexOf(member.userId) === -1) {
                 return;
             }
-            self.setState({
-                can: self._calculateOpsPermissions(),
-                muted: self._isMuted(self.props.member)
-            });
+            self.setState(self._calculateOpsPermissions());
         }
         MatrixClientPeg.get().on("RoomMember.powerLevel", updatePowerLevel);
         this.updatePowerLevelFn = updatePowerLevel;
@@ -73,12 +71,10 @@ module.exports = {
         // work out the current state
         if (this.props.member) {
             var usr = MatrixClientPeg.get().getUser(this.props.member.userId) || {};
-            this.setState({
-                presence: usr.presence || "offline",
-                active: usr.lastActiveAgo || -1,
-                can: this._calculateOpsPermissions(),
-                muted: this._isMuted(this.props.member)
-            });
+            var memberState = this._calculateOpsPermissions();
+            memberState.presence = usr.presence || "offline";
+            memberState.active = usr.lastActiveAgo || -1;
+            this.setState(memberState);
         }
     },
 
@@ -220,53 +216,43 @@ module.exports = {
                 ban: false,
                 mute: false
             },
-            muted: false
+            muted: false,
+            isTargetOp: false
         }
     },
 
-    _isMuted: function(member) {
-        var room = MatrixClientPeg.get().getRoom(member.roomId);
+    _calculateOpsPermissions: function() {
+        var defaultPerms = {
+            can: {},
+            muted: false
+        };
+        var room = MatrixClientPeg.get().getRoom(this.props.member.roomId);
         if (!room) {
-            return false;
+            return defaultPerms;
         }
         var powerLevels = room.currentState.getStateEvents(
             "m.room.power_levels", ""
         );
         if (!powerLevels) {
-            return false;
+            return defaultPerms;
         }
-        powerLevels = powerLevels.getContent();
-        var levelToSend = (
-            (powerLevels.events ? powerLevels.events["m.room.message"] : null) ||
-            powerLevels.events_default
-        );
-        return member.powerLevel < levelToSend;
+        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()),
+            isTargetOp: them.powerLevel >= me.powerLevel && them.powerLevel > 0
+        };
     },
 
-    _calculateOpsPermissions: function() {
+    _calculateCanPermissions: function(me, them, powerLevels) {
         var can = {
             kick: false,
             ban: false,
             mute: false
         };
-        var them = this.props.member;
-        var room = MatrixClientPeg.get().getRoom(this.props.member.roomId);
-        if (!room) {
-            console.error("No room found");
-            return can;
-        }
-        var myUserId = MatrixClientPeg.get().credentials.userId;
-        var me = room.getMember(myUserId);
-        var powerLevels = room.currentState.getStateEvents(
-            "m.room.power_levels", ""
-        );
-        if (powerLevels) {
-            powerLevels = powerLevels.getContent();
-        }
-        else {
-            console.log("No power level event found in %s", room.roomId);
-            return can; // no power level event, don't allow anything.
-        }
         var canAffectUser = them.powerLevel < me.powerLevel;
         if (!canAffectUser) {
             console.log("Cannot affect user: %s >= %s", them.powerLevel, me.powerLevel);
@@ -280,6 +266,17 @@ module.exports = {
         can.ban = me.powerLevel >= powerLevels.ban;
         can.mute = me.powerLevel >= editPowerLevel;
         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 2454a71b38857861e43b88aa0eaab40871a41397 Mon Sep 17 00:00:00 2001
From: Kegan Dougal <kegan@matrix.org>
Date: Tue, 21 Jul 2015 13:24:59 +0100
Subject: [PATCH 3/4] Implement create/remove mod button.

---
 skins/base/views/molecules/MemberInfo.js | 15 +++++--
 src/controllers/molecules/MemberInfo.js  | 53 ++++++++++++++++++++----
 2 files changed, 58 insertions(+), 10 deletions(-)

diff --git a/skins/base/views/molecules/MemberInfo.js b/skins/base/views/molecules/MemberInfo.js
index 45db0c30..8e1e383f 100644
--- a/skins/base/views/molecules/MemberInfo.js
+++ b/skins/base/views/molecules/MemberInfo.js
@@ -67,7 +67,7 @@ module.exports = React.createClass({
         if (this.state.active >= 0) {
             activeAgo = this.getDuration(this.state.active);
         }
-        var kickButton, banButton, muteButton;
+        var kickButton, banButton, muteButton, giveModButton;
         if (this.state.can.kick) {
             kickButton = <div className="mx_MemberInfo_button" onClick={this.onKick}>
                 Kick
@@ -84,9 +84,17 @@ module.exports = React.createClass({
                 {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}>
+                {giveOpLabel}
+            </div>
+        }
+
         var opLabel;
-        if (this.state.isTargetOp) {
-            opLabel = <div className="mx_MemberInfo_field">Moderator</div>
+        if (this.state.isTargetMod) {
+            var level = this.props.member.powerLevelNorm + "%";
+            opLabel = <div className="mx_MemberInfo_field">Moderator ({level})</div>
         }
         return (
             <div className="mx_MemberInfo">
@@ -105,6 +113,7 @@ module.exports = React.createClass({
                 {muteButton}
                 {kickButton}
                 {banButton}
+                {giveModButton}
             </div>
         );
     }
diff --git a/src/controllers/molecules/MemberInfo.js b/src/controllers/molecules/MemberInfo.js
index d335eec5..4f222c5d 100644
--- a/src/controllers/molecules/MemberInfo.js
+++ b/src/controllers/molecules/MemberInfo.js
@@ -21,10 +21,11 @@ limitations under the License.
  * 'can': {
  *   kick: boolean,
  *   ban: boolean,
- *   mute: boolean
+ *   mute: boolean,
+ *   modifyLevel: boolean
  * },
  * 'muted': boolean,
- * 'isTargetOp': boolean
+ * 'isTargetMod': boolean
  */
 
 'use strict';
@@ -158,6 +159,40 @@ module.exports = {
         });
     },
 
+    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.
@@ -214,17 +249,19 @@ module.exports = {
             can: {
                 kick: false,
                 ban: false,
-                mute: false
+                mute: false,
+                modifyLevel: false
             },
             muted: false,
-            isTargetOp: false
+            isTargetMod: false
         }
     },
 
     _calculateOpsPermissions: function() {
         var defaultPerms = {
             can: {},
-            muted: false
+            muted: false,
+            modifyLevel: false
         };
         var room = MatrixClientPeg.get().getRoom(this.props.member.roomId);
         if (!room) {
@@ -243,7 +280,7 @@ module.exports = {
                 me, them, powerLevels.getContent()
             ),
             muted: this._isMuted(them, powerLevels.getContent()),
-            isTargetOp: them.powerLevel >= me.powerLevel && them.powerLevel > 0
+            isTargetMod: them.powerLevel > powerLevels.getContent().users_default
         };
     },
 
@@ -251,7 +288,8 @@ module.exports = {
         var can = {
             kick: false,
             ban: false,
-            mute: false
+            mute: false,
+            modifyLevel: false
         };
         var canAffectUser = them.powerLevel < me.powerLevel;
         if (!canAffectUser) {
@@ -265,6 +303,7 @@ module.exports = {
         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;
     },
 

From b8e1927e82949bd2ddca3519bea66603aaf98244 Mon Sep 17 00:00:00 2001
From: Erik Johnston <erikj@matrix.org>
Date: Tue, 21 Jul 2015 14:13:59 +0100
Subject: [PATCH 4/4] Use getDefaultProps instead of setting porps

---
 skins/base/views/organisms/ErrorDialog.js | 29 +++--------------
 src/controllers/organisms/ErrorDialog.js  | 39 +++++++++++++++++++++++
 2 files changed, 43 insertions(+), 25 deletions(-)
 create mode 100644 src/controllers/organisms/ErrorDialog.js

diff --git a/skins/base/views/organisms/ErrorDialog.js b/skins/base/views/organisms/ErrorDialog.js
index 847e796f..fd34a734 100644
--- a/skins/base/views/organisms/ErrorDialog.js
+++ b/skins/base/views/organisms/ErrorDialog.js
@@ -17,7 +17,7 @@ limitations under the License.
 'use strict';
 
 /*
- * Usage: 
+ * Usage:
  * Modal.createDialog(ErrorDialog, {
  *   title: "some text", (default: "Error")
  *   description: "some more text",
@@ -28,31 +28,11 @@ limitations under the License.
  */
 
 var React = require('react');
+var ErrorDialogController = require("../../../../src/controllers/organisms/ErrorDialog");
 
 module.exports = React.createClass({
     displayName: 'ErrorDialog',
-
-    // can't use getDefaultProps, see Modal.js
-    componentWillMount: function() {
-        if (!this.props.title) {
-            this.props.title = "Error";
-        }
-        if (!this.props.description) {
-            this.props.description = "An error has occurred.";
-        }
-        if (!this.props.button) {
-            this.props.button = "OK";
-        }
-        if (this.props.focus === undefined) {
-            this.props.focus = true;
-        }
-        if (!this.props.onClose) {
-            var self = this;
-            this.props.onClose = function() {
-                self.props.onFinished();
-            };
-        }
-    },
+    mixins: [ErrorDialogController],
 
     render: function() {
         return (
@@ -61,11 +41,10 @@ module.exports = React.createClass({
                     {this.props.title}
                 </div>
                 {this.props.description}<br />
-                <button onClick={this.props.onClose} autoFocus={this.props.focus}>
+                <button onClick={this.props.onFinished} autoFocus={this.props.focus}>
                     {this.props.button}
                 </button>
             </div>
         );
     }
 });
-
diff --git a/src/controllers/organisms/ErrorDialog.js b/src/controllers/organisms/ErrorDialog.js
new file mode 100644
index 00000000..73f66c87
--- /dev/null
+++ b/src/controllers/organisms/ErrorDialog.js
@@ -0,0 +1,39 @@
+/*
+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");
+
+module.exports = {
+    propTypes: {
+        title: React.PropTypes.string,
+        description: React.PropTypes.string,
+        button: React.PropTypes.string,
+        focus: React.PropTypes.bool,
+        onFinished: React.PropTypes.func.isRequired,
+    },
+
+    getDefaultProps: function() {
+        var self = this;
+        return {
+            title: "Error",
+            description: "An error has occurred.",
+            button: "OK",
+            focus: true,
+        };
+    },
+};