diff --git a/skins/base/css/common.css b/skins/base/css/common.css index d90ce11a..d6c5e091 100644 --- a/skins/base/css/common.css +++ b/skins/base/css/common.css @@ -43,9 +43,40 @@ 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 { + border: 1px solid #a9dbf4; + border-radius: 8px; + background-color: #fff; + color: #747474; + position: fixed; + z-index: 2001; + padding: 6px; +} + +.mx_ContextualMenu_chevron { + padding: 12px; + position: absolute; + right: -21px; + top: 0px; +} + +.mx_ContextualMenu_field { + padding: 3px 6px 3px 6px; + cursor: pointer; +} + + +.mx_Dialog_background { position: fixed; top: 0; left: 0; @@ -56,7 +87,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/css/molecules/MemberTile.css b/skins/base/css/molecules/MemberTile.css index f296668f..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,33 @@ 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; + color: #747474; +} + +.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; +} + +/* .mx_MemberTile_nameWrapper { display: table-cell; vertical-align: middle; @@ -77,25 +109,22 @@ 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 { 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/img/delete.png b/skins/base/img/delete.png new file mode 100644 index 00000000..8ff20a11 Binary files /dev/null and b/skins/base/img/delete.png differ diff --git a/skins/base/img/edit.png b/skins/base/img/edit.png new file mode 100644 index 00000000..2686885f Binary files /dev/null and b/skins/base/img/edit.png differ 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 ( +
+
+ ); + } +}); diff --git a/skins/base/views/molecules/MemberInfo.js b/skins/base/views/molecules/MemberInfo.js index e79b99a2..b57a5b6f 100644 --- a/skins/base/views/molecules/MemberInfo.js +++ b/skins/base/views/molecules/MemberInfo.js @@ -27,84 +27,41 @@ 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 interactButton, kickButton, banButton, muteButton, giveModButton; + if (this.props.member.userId === MatrixClientPeg.get().credentials.userId) { + interactButton =
Leave room
; } - var kickButton, banButton, muteButton, giveModButton; + else { + interactButton =
Start chat
; + } + if (this.state.can.kick) { - kickButton =
+ kickButton =
Kick
; } if (this.state.can.ban) { - banButton =
+ banButton =
Ban
; } if (this.state.can.mute) { var muteLabel = this.state.muted ? "Unmute" : "Mute"; - muteButton =
+ muteButton =
{muteLabel}
; } if (this.state.can.modifyLevel) { var giveOpLabel = this.state.isTargetMod ? "Revoke Mod" : "Make Mod"; - giveModButton =
+ giveModButton =
{giveOpLabel}
} - var opLabel; - if (this.state.isTargetMod) { - var level = this.props.member.powerLevelNorm + "%"; - opLabel =
Moderator ({level})
- } return ( -
- -
-
- -
-
{this.props.member.userId}
- {opLabel} -
Presence: {this.state.presence}
-
Last active: {activeAgo}
-
Start chat
+
+ {interactButton} {muteButton} {kickButton} {banButton} diff --git a/skins/base/views/molecules/MemberTile.js b/skins/base/views/molecules/MemberTile.js index bd184051..1414b562 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"); @@ -34,11 +35,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 +43,58 @@ module.exports = React.createClass({ this.setState({ 'hover': false }); }, + onClick: function(e) { + var self = this; + self.setState({ 'menu': true }); + ContextualMenu.createMenu(MemberInfo, { + member: self.props.member, + right: window.innerWidth - e.pageX, + top: e.pageY, + onFinished: function() { + self.setState({ 'menu': 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) { + if (!user) return "Unknown"; + var presence = user.presence; + if (presence === "online") return "Online"; + if (presence === "unavailable") return "Idle"; // XXX: is this actually right? + if (presence === "offline") return "Offline"; + 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; @@ -66,35 +114,47 @@ module.exports = React.createClass({ } } mainClassName += presenceClass; + if (this.state.hover || this.state.menu) { + mainClassName += " mx_MemberTile_hover"; + } var name = this.props.member.name; - if (isMyUser) name += " (me)"; - var leave = isMyUser ? X : null; + // if (isMyUser) name += " (me)"; // this does nothing other than introduce line wrapping and pain + var leave = isMyUser ? : 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) { + 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; + if (active >= 0) { + presence =
{ this.getPrettyPresence(this.props.member.user) } for { this.getDuration(active) }
; + } + else { + presence =
{ this.getPrettyPresence(this.props.member.user) }
; + } + nameEl = -
- - {name} - {leave} +
+ { leave } +
{ this.props.member.userId }
+ { presence }
} else { nameEl =
- {name} - {leave} + { name }
} return ( -
+
{ power } 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 ( - //
- // ); - // } - var classes = classNames({ mx_MemberTile: true, + mx_MemberTile_inviteTile: true, mx_MemberTile_inviteEditing: this.state.editing, }); diff --git a/skins/base/views/organisms/RoomView.js b/skins/base/views/organisms/RoomView.js index b6747ae7..520c1d6b 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/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 = ( +
+
+ + +
+
+
+ ); + + React.render(menu, this.getOrCreateContainer()); + + return {close: closeMenu}; + }, +}; 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 = ( -
+
-
+
); diff --git a/src/controllers/molecules/MemberInfo.js b/src/controllers/molecules/MemberInfo.js index 5404c337..21cbe7a5 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, @@ -34,58 +32,21 @@ 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() { 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; @@ -100,6 +61,7 @@ module.exports = { description: err.message }); }); + this.props.onFinished(); }, onBan: function() { @@ -116,6 +78,7 @@ module.exports = { description: err.message }); }); + this.props.onFinished(); }, onMuteToggle: function() { @@ -124,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; @@ -157,6 +122,7 @@ module.exports = { description: err.message }); }); + this.props.onFinished(); }, onModToggle: function() { @@ -164,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; @@ -191,6 +160,7 @@ module.exports = { description: err.message }); }); + this.props.onFinished(); }, onChatClick: function() { @@ -240,12 +210,40 @@ 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() { + 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() { 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 ae468284..43db7d1d 100644 --- a/src/controllers/molecules/MemberTile.js +++ b/src/controllers/molecules/MemberTile.js @@ -25,14 +25,23 @@ 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 + // }); + // }, + + getInitialState: function() { + return { + hover: false, + menu: false, + } }, - onLeaveClick: function() { + onLeaveClick: function(ev) { + ev.stopPropagation(); + ev.preventDefault(); var roomId = this.props.member.roomId; Modal.createDialog(QuestionDialog, { title: "Leave room", @@ -56,5 +65,5 @@ module.exports = { } } }); - } + } }; 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);