diff --git a/src/ScalarMessaging.js b/src/ScalarMessaging.js index cc552ba898..bb6b4d02cb 100644 --- a/src/ScalarMessaging.js +++ b/src/ScalarMessaging.js @@ -123,6 +123,8 @@ Example: const SdkConfig = require('./SdkConfig'); const MatrixClientPeg = require("./MatrixClientPeg"); +const MatrixEvent = require("matrix-js-sdk").MatrixEvent; +const dis = require("./dispatcher"); function sendResponse(event, res) { const data = JSON.parse(JSON.stringify(event.data)); @@ -188,17 +190,52 @@ function setBotOptions(event, roomId, userId) { }); } +function setBotPower(event, roomId, userId, level) { + if (!(Number.isInteger(level) && level >= 0)) { + sendError(event, "Power level must be positive integer."); + return; + } + + console.log(`Received request to set power level to ${level} for bot ${userId} in room ${roomId}.`); + const client = MatrixClientPeg.get(); + if (!client) { + sendError(event, "You need to be logged in."); + return; + } + + client.getStateEvent(roomId, "m.room.power_levels", "").then((powerLevels) => { + let powerEvent = new MatrixEvent( + { + type: "m.room.power_levels", + content: powerLevels, + } + ); + + client.setPowerLevel(roomId, userId, level, powerEvent).done(() => { + sendResponse(event, { + success: true, + }); + }, (err) => { + sendError(event, err.message ? err.message : "Failed to send request.", err); + }); + }); +} + function getMembershipState(event, roomId, userId) { console.log(`membership_state of ${userId} in room ${roomId} requested.`); returnStateEvent(event, roomId, "m.room.member", userId); } +function getJoinRules(event, roomId) { + console.log(`join_rules of ${roomId} requested.`); + returnStateEvent(event, roomId, "m.room.join_rules", ""); +} + function botOptions(event, roomId, userId) { console.log(`bot_options of ${userId} in room ${roomId} requested.`); returnStateEvent(event, roomId, "m.room.bot.options", "_" + userId); } - function returnStateEvent(event, roomId, eventType, stateKey) { const client = MatrixClientPeg.get(); if (!client) { @@ -218,6 +255,19 @@ function returnStateEvent(event, roomId, eventType, stateKey) { sendResponse(event, stateEvent.getContent()); } +var currentRoomId = null; +var currentRoomAlias = null; + +// Listen for when a room is viewed +dis.register(onAction); +function onAction(payload) { + if (payload.action !== "view_room") { + return; + } + currentRoomId = payload.room_id; + currentRoomAlias = payload.room_alias; +} + const onMessage = function(event) { if (!event.origin) { // stupid chrome event.origin = event.originalEvent.origin; @@ -235,31 +285,63 @@ const onMessage = function(event) { const roomId = event.data.room_id; const userId = event.data.user_id; - if (!userId) { - sendError(event, "Missing user_id in request"); - return; - } if (!roomId) { sendError(event, "Missing room_id in request"); return; } - switch (event.data.action) { - case "membership_state": - getMembershipState(event, roomId, userId); - break; - case "invite": - inviteUser(event, roomId, userId); - break; - case "bot_options": - botOptions(event, roomId, userId); - break; - case "set_bot_options": - setBotOptions(event, roomId, userId); - break; - default: - console.warn("Unhandled postMessage event with action '" + event.data.action +"'"); - break; + let promise = Promise.resolve(currentRoomId); + if (!currentRoomId) { + if (!currentRoomAlias) { + sendError(event, "Must be viewing a room"); + return; + } + // no room ID but there is an alias, look it up. + console.log("Looking up alias " + currentRoomAlias); + promise = MatrixClientPeg.get().getRoomIdForAlias(currentRoomAlias).then((res) => { + return res.room_id; + }); } + + promise.then((viewingRoomId) => { + if (roomId !== viewingRoomId) { + sendError(event, "Room " + roomId + " not visible"); + return; + } + + // Getting join rules does not require userId + if (event.data.action === "join_rules_state") { + getJoinRules(event, roomId); + return; + } + + if (!userId) { + sendError(event, "Missing user_id in request"); + return; + } + switch (event.data.action) { + case "membership_state": + getMembershipState(event, roomId, userId); + break; + case "invite": + inviteUser(event, roomId, userId); + break; + case "bot_options": + botOptions(event, roomId, userId); + break; + case "set_bot_options": + setBotOptions(event, roomId, userId); + break; + case "set_bot_power": + setBotPower(event, roomId, userId, event.data.level); + break; + default: + console.warn("Unhandled postMessage event with action '" + event.data.action +"'"); + break; + } + }, (err) => { + console.error(err); + sendError(event, "Failed to lookup current room."); + }) }; module.exports = { diff --git a/src/component-index.js b/src/component-index.js index 3871f60e15..4cf2ba4016 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -45,6 +45,7 @@ module.exports.components['views.avatars.RoomAvatar'] = require('./components/vi module.exports.components['views.create_room.CreateRoomButton'] = require('./components/views/create_room/CreateRoomButton'); module.exports.components['views.create_room.Presets'] = require('./components/views/create_room/Presets'); module.exports.components['views.create_room.RoomAlias'] = require('./components/views/create_room/RoomAlias'); +module.exports.components['views.dialogs.ChatInviteDialog'] = require('./components/views/dialogs/ChatInviteDialog'); module.exports.components['views.dialogs.DeactivateAccountDialog'] = require('./components/views/dialogs/DeactivateAccountDialog'); module.exports.components['views.dialogs.ErrorDialog'] = require('./components/views/dialogs/ErrorDialog'); module.exports.components['views.dialogs.LogoutPrompt'] = require('./components/views/dialogs/LogoutPrompt'); @@ -53,6 +54,7 @@ module.exports.components['views.dialogs.NeedToRegisterDialog'] = require('./com module.exports.components['views.dialogs.QuestionDialog'] = require('./components/views/dialogs/QuestionDialog'); module.exports.components['views.dialogs.SetDisplayNameDialog'] = require('./components/views/dialogs/SetDisplayNameDialog'); module.exports.components['views.dialogs.TextInputDialog'] = require('./components/views/dialogs/TextInputDialog'); +module.exports.components['views.elements.AddressTile'] = require('./components/views/elements/AddressTile'); module.exports.components['views.elements.EditableText'] = require('./components/views/elements/EditableText'); module.exports.components['views.elements.EditableTextContainer'] = require('./components/views/elements/EditableTextContainer'); module.exports.components['views.elements.EmojiText'] = require('./components/views/elements/EmojiText'); diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 251f3f1dc8..c83da2b8f0 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -370,6 +370,9 @@ module.exports = React.createClass({ this._setPage(this.PageTypes.RoomDirectory); this.notifyNewScreen('directory'); break; + case 'view_create_chat': + this._createChat(); + break; case 'notifier_enabled': this.forceUpdate(); break; @@ -506,6 +509,13 @@ module.exports = React.createClass({ } }, + _createChat: function() { + var ChatInviteDialog = sdk.getComponent("dialogs.ChatInviteDialog"); + Modal.createDialog(ChatInviteDialog, { + title: "Start a one to one chat", + }); + }, + // update scrollStateMap according to the current scroll state of the // room view. _updateScrollMap: function() { diff --git a/src/components/views/dialogs/ChatInviteDialog.js b/src/components/views/dialogs/ChatInviteDialog.js new file mode 100644 index 0000000000..e322127135 --- /dev/null +++ b/src/components/views/dialogs/ChatInviteDialog.js @@ -0,0 +1,371 @@ +/* +Copyright 2015, 2016 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. +*/ + +var React = require("react"); +var classNames = require('classnames'); +var sdk = require("../../../index"); +var Invite = require("../../../Invite"); +var createRoom = require("../../../createRoom"); +var MatrixClientPeg = require("../../../MatrixClientPeg"); +var DMRoomMap = require('../../../utils/DMRoomMap'); +var rate_limited_func = require("../../../ratelimitedfunc"); +var dis = require("../../../dispatcher"); +var Modal = require('../../../Modal'); + +const TRUNCATE_QUERY_LIST = 40; + +module.exports = React.createClass({ + displayName: "ChatInviteDialog", + propTypes: { + title: React.PropTypes.string, + description: React.PropTypes.oneOfType([ + React.PropTypes.element, + React.PropTypes.string, + ]), + value: React.PropTypes.string, + placeholder: React.PropTypes.string, + button: React.PropTypes.string, + focus: React.PropTypes.bool, + onFinished: React.PropTypes.func.isRequired + }, + + getDefaultProps: function() { + return { + title: "Start a chat", + description: "Who would you like to communicate with?", + value: "", + placeholder: "User ID, Name or email", + button: "Start Chat", + focus: true + }; + }, + + getInitialState: function() { + return { + user: null, + queryList: [], + addressSelected: false, + selected: 0, + hover: false, + }; + }, + + componentDidMount: function() { + if (this.props.focus) { + // Set the cursor at the end of the text input + this.refs.textinput.value = this.props.value; + } + this._updateUserList(); + }, + + componentDidUpdate: function() { + // As the user scrolls with the arrow keys keep the selected item + // at the top of the window. + if (this.scrollElement && !this.state.hover) { + var elementHeight = this.queryListElement.getBoundingClientRect().height; + this.scrollElement.scrollTop = (this.state.selected * elementHeight) - elementHeight; + } + }, + + onStartChat: function() { + var addr; + + // Either an address tile was created, or text input is being used + if (this.state.user) { + addr = this.state.user.userId; + } else { + addr = this.refs.textinput.value; + } + + // Check if the addr is a valid type + if (Invite.getAddressType(addr) === "mx") { + var room = this._getDirectMessageRoom(addr); + if (room) { + // A Direct Message room already exists for this user and you + // so go straight to that room + dis.dispatch({ + action: 'view_room', + room_id: room.roomId, + }); + this.props.onFinished(true, addr); + } else { + this._startChat(addr); + } + } else if (Invite.getAddressType(addr) === "email") { + this._startChat(addr); + } else { + // Nothing to do, so focus back on the textinput + this.refs.textinput.focus(); + } + }, + + onCancel: function() { + this.props.onFinished(false); + }, + + onKeyDown: function(e) { + if (e.keyCode === 27) { // escape + e.stopPropagation(); + e.preventDefault(); + this.props.onFinished(false); + } else if (e.keyCode === 38) { // up arrow + e.stopPropagation(); + e.preventDefault(); + if (this.state.selected > 0) { + this.setState({ + selected: this.state.selected - 1, + hover : false, + }); + } + } else if (e.keyCode === 40) { // down arrow + e.stopPropagation(); + e.preventDefault(); + if (this.state.selected < this._maxSelected(this.state.queryList)) { + this.setState({ + selected: this.state.selected + 1, + hover : false, + }); + } + } else if (e.keyCode === 13) { // enter + e.stopPropagation(); + e.preventDefault(); + if (this.state.queryList.length > 0) { + this.setState({ + user: this.state.queryList[this.state.selected], + addressSelected: true, + queryList: [], + hover : false, + }); + } + } + }, + + onQueryChanged: function(ev) { + var query = ev.target.value; + var queryList = []; + + // Only do search if there is something to search + if (query.length > 0) { + queryList = this._userList.filter((user) => { + return this._matches(query, user); + }); + } + + // Make sure the selected item isn't outside the list bounds + var selected = this.state.selected; + var maxSelected = this._maxSelected(queryList); + if (selected > maxSelected) { + selected = maxSelected; + } + + this.setState({ + queryList: queryList, + selected: selected, + }); + }, + + onDismissed: function() { + this.setState({ + user: null, + addressSelected: false, + selected: 0, + queryList: [], + }); + }, + + onClick: function(index) { + var self = this; + return function() { + self.setState({ + user: self.state.queryList[index], + addressSelected: true, + queryList: [], + hover: false, + }); + }; + }, + + onMouseEnter: function(index) { + var self = this; + return function() { + self.setState({ + selected: index, + hover: true, + }); + }; + }, + + onMouseLeave: function() { + this.setState({ hover : false }); + }, + + createQueryListTiles: function() { + var self = this; + var TintableSvg = sdk.getComponent("elements.TintableSvg"); + var AddressTile = sdk.getComponent("elements.AddressTile"); + var maxSelected = this._maxSelected(this.state.queryList); + var queryList = []; + + // Only create the query elements if there are queries + if (this.state.queryList.length > 0) { + for (var i = 0; i <= maxSelected; i++) { + var classes = classNames({ + "mx_ChatInviteDialog_queryListElement": true, + "mx_ChatInviteDialog_selected": this.state.selected === i, + }); + + // NOTE: Defaulting to "vector" as the network, until the network backend stuff is done. + // Saving the queryListElement so we can use it to work out, in the componentDidUpdate + // method, how far to scroll when using the arrow keys + queryList.push( +
{ this.queryListElement = ref; }} > + +
+ ); + } + } + return queryList; + }, + + _getDirectMessageRoom: function(addr) { + const dmRoomMap = new DMRoomMap(MatrixClientPeg.get()); + var dmRooms = dmRoomMap.getDMRoomsForUserId(addr); + if (dmRooms.length > 0) { + // Cycle through all the DM rooms and find the first non forgotten or parted room + for (let i = 0; i < dmRooms.length; i++) { + let room = MatrixClientPeg.get().getRoom(dmRooms[i]); + if (room) { + return room; + } + } + } + return null; + }, + + _startChat: function(addr) { + // Start the chat + createRoom().then(function(roomId) { + return Invite.inviteToRoom(roomId, addr); + }) + .catch(function(err) { + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Failure to invite user", + description: err.toString() + }); + return null; + }) + .done(); + + // Close - this will happen before the above, as that is async + this.props.onFinished(true, addr); + }, + + _updateUserList: new rate_limited_func(function() { + // Get all the users + this._userList = MatrixClientPeg.get().getUsers(); + }, 500), + + _maxSelected: function(list) { + var listSize = list.length === 0 ? 0 : list.length - 1; + var maxSelected = listSize > (TRUNCATE_QUERY_LIST - 1) ? (TRUNCATE_QUERY_LIST - 1) : listSize + return maxSelected; + }, + + // This is the search algorithm for matching users + _matches: function(query, user) { + var name = user.displayName.toLowerCase(); + var uid = user.userId.toLowerCase(); + query = query.toLowerCase(); + + // direct prefix matches + if (name.indexOf(query) === 0 || uid.indexOf(query) === 0) { + return true; + } + + // strip @ on uid and try matching again + if (uid.length > 1 && uid[0] === "@" && uid.substring(1).indexOf(query) === 0) { + return true; + } + + // split spaces in name and try matching constituent parts + var parts = name.split(" "); + for (var i = 0; i < parts.length; i++) { + if (parts[i].indexOf(query) === 0) { + return true; + } + } + return false; + }, + + render: function() { + var TintableSvg = sdk.getComponent("elements.TintableSvg"); + this.scrollElement = null; + + var query; + if (this.state.addressSelected) { + var AddressTile = sdk.getComponent("elements.AddressTile"); + query = ( + + ); + } else { + query = ( + + ); + } + + var queryList; + var queryListElements = this.createQueryListTiles(); + if (queryListElements.length > 0) { + queryList = ( +
{this.scrollElement = ref}}> + { queryListElements } +
+ ); + } + + return ( +
+
+ {this.props.title} +
+
+ +
+
+ +
+
+
{ query }
+ { queryList } +
+
+ +
+
+ ); + } +}); diff --git a/src/components/views/elements/AddressTile.js b/src/components/views/elements/AddressTile.js new file mode 100644 index 0000000000..e0a5dbbc80 --- /dev/null +++ b/src/components/views/elements/AddressTile.js @@ -0,0 +1,93 @@ +/* +Copyright 2015, 2016 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 sdk = require("../../../index"); +var Avatar = require('../../../Avatar'); + +module.exports = React.createClass({ + displayName: 'AddressTile', + + propTypes: { + user: React.PropTypes.object.isRequired, + canDismiss: React.PropTypes.bool, + onDismissed: React.PropTypes.func, + justified: React.PropTypes.bool, + networkName: React.PropTypes.string, + networkUrl: React.PropTypes.string, + }, + + getDefaultProps: function() { + return { + canDismiss: false, + onDismissed: function() {}, // NOP + justified: false, + networkName: "", + networkUrl: "", + }; + }, + + render: function() { + var BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); + var TintableSvg = sdk.getComponent("elements.TintableSvg"); + var userId = this.props.user.userId; + var name = this.props.user.displayName || userId; + var imgUrl = Avatar.avatarUrlForUser(this.props.user, 25, 25, "crop"); + + var network; + if (this.props.networkUrl !== "") { + network = ( +
+ +
+ ); + } + + var dismiss; + if (this.props.canDismiss) { + dismiss = ( +
+ +
+ ); + } + + var nameClasses = classNames({ + "mx_AddressTile_name": true, + "mx_AddressTile_justified": this.props.justified, + }); + + var idClasses = classNames({ + "mx_AddressTile_id": true, + "mx_AddressTile_justified": this.props.justified, + }); + + return ( +
+ { network } +
+ +
+
{ name }
+
{ userId }
+ { dismiss } +
+ ); + } +}); diff --git a/src/components/views/rooms/MemberDeviceInfo.js b/src/components/views/rooms/MemberDeviceInfo.js index efc2cdf638..22bbdd2ce7 100644 --- a/src/components/views/rooms/MemberDeviceInfo.js +++ b/src/components/views/rooms/MemberDeviceInfo.js @@ -99,12 +99,16 @@ export default class MemberDeviceInfo extends React.Component { ); } - var deviceName = this.props.device.display_name || this.props.device.deviceId; + var deviceName = this.props.device.getDisplayName() || this.props.device.deviceId; + // add the deviceId as a titletext to help with debugging return ( -
+
{deviceName}
{indicator} +
+ {this.props.device.getFingerprint()} +
{verifyButton} {blockButton}
diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index 1b1f7f6bd2..1265fc1af0 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -79,8 +79,6 @@ module.exports = React.createClass({ switch (payload.action) { case 'view_tooltip': this.tooltip = payload.tooltip; - this._repositionTooltip(); - if (this.tooltip) this.tooltip.style.display = 'block'; break; case 'call_state': var call = CallHandler.getCall(payload.room_id); @@ -315,17 +313,15 @@ module.exports = React.createClass({ }, _whenScrolling: function(e) { - this._repositionTooltip(e); + this._hideTooltip(e); this._repositionIncomingCallBox(e, false); this._updateStickyHeaders(false); }, - _repositionTooltip: function(e) { - // We access the parent of the parent, as the tooltip is inside a container - // Needs refactoring into a better multipurpose tooltip - if (this.tooltip && this.tooltip.parentElement && this.tooltip.parentElement.parentElement) { - var scroll = ReactDOM.findDOMNode(this); - this.tooltip.style.top = (3 + scroll.parentElement.offsetTop + this.tooltip.parentElement.parentElement.offsetTop - this._getScrollNode().scrollTop) + "px"; + _hideTooltip: function(e) { + // Hide tooltip when scrolling, as we'll no longer be over the one we were on + if (this.tooltip && this.tooltip.style.display !== "none") { + this.tooltip.style.display = "none"; } }, @@ -374,7 +370,7 @@ module.exports = React.createClass({ var scrollArea = this._getScrollNode(); // Use the offset of the top of the scroll area from the window // as this is used to calculate the CSS fixed top position for the stickies - var scrollAreaOffset = scrollArea.getBoundingClientRect().top; + var scrollAreaOffset = scrollArea.getBoundingClientRect().top + window.pageYOffset; // Use the offset of the top of the componet from the window // as this is used to calculate the CSS fixed top position for the stickies var scrollAreaHeight = ReactDOM.findDOMNode(this).getBoundingClientRect().height; diff --git a/src/components/views/rooms/RoomTile.js b/src/components/views/rooms/RoomTile.js index 182b62fcb2..bac8f5aa07 100644 --- a/src/components/views/rooms/RoomTile.js +++ b/src/components/views/rooms/RoomTile.js @@ -17,9 +17,11 @@ limitations under the License. 'use strict'; var React = require('react'); +var ReactDOM = require("react-dom"); var classNames = require('classnames'); var dis = require("../../../dispatcher"); var MatrixClientPeg = require('../../../MatrixClientPeg'); +var DMRoomMap = require('../../../utils/DMRoomMap'); var sdk = require('../../../index'); var ContextualMenu = require('../../structures/ContextualMenu'); var RoomNotifs = require('../../../RoomNotifs'); @@ -66,6 +68,16 @@ module.exports = React.createClass({ return this.state.notifState != RoomNotifs.MUTE; }, + _isDirectMessageRoom: function(roomId) { + const dmRoomMap = new DMRoomMap(MatrixClientPeg.get()); + var dmRooms = dmRoomMap.getUserIdForRoomId(roomId); + if (dmRooms) { + return true; + } else { + return false; + } + }, + onAccountData: function(accountDataEvent) { if (accountDataEvent.getType() == 'm.push_rules') { this.setState({ @@ -248,10 +260,9 @@ module.exports = React.createClass({ } else { label = {name}; } - } - else if (this.state.hover) { + } else if (this.state.hover) { var RoomTooltip = sdk.getComponent("rooms.RoomTooltip"); - label = ; + tooltip = ; } var incomingCallBox; @@ -262,6 +273,11 @@ module.exports = React.createClass({ var RoomAvatar = sdk.getComponent('avatars.RoomAvatar'); + var directMessageIndicator; + if (this._isDirectMessageRoom(this.props.room.roomId)) { + directMessageIndicator = dm; + } + // These props are injected by React DnD, // as defined by your `collect` function above: var isDragging = this.props.isDragging; @@ -274,6 +290,7 @@ module.exports = React.createClass({
+ {directMessageIndicator}