From b724b0c6b68dcbdc5cff14181e30643e8a93b078 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Fri, 9 Sep 2016 11:41:56 +0100 Subject: [PATCH 01/11] Update MemberDeviceInfo display Show the displayname when we have it Show the deviceid in a tooltip Show the Ed25519 public key to help verify --- src/components/views/rooms/MemberDeviceInfo.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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}
From fec1e4d4c192d79466668754424a4fef8d32e883 Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Fri, 9 Sep 2016 18:07:42 +0530 Subject: [PATCH 02/11] Add some tests for the rich text editor --- .../views/rooms/MessageComposerInput.js | 16 +- .../views/rooms/MessageComposerInput-test.js | 144 ++++++++++++++++++ test/test-utils.js | 33 ++-- 3 files changed, 179 insertions(+), 14 deletions(-) create mode 100644 test/components/views/rooms/MessageComposerInput-test.js diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 1f5b303fe0..c326a7c4f5 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -31,7 +31,7 @@ import {Editor, EditorState, RichUtils, CompositeDecorator, convertFromRaw, convertToRaw, Modifier, EditorChangeType, getDefaultKeyBinding, KeyBindingUtil, ContentState, ContentBlock, SelectionState} from 'draft-js'; -import {stateToMarkdown} from 'draft-js-export-markdown'; +import {stateToMarkdown as __stateToMarkdown} from 'draft-js-export-markdown'; import classNames from 'classnames'; import escape from 'lodash/escape'; @@ -51,6 +51,16 @@ const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000; const KEY_M = 77; +const ZWS_CODE = 8203; +const ZWS = String.fromCharCode(ZWS_CODE); // zero width space +function stateToMarkdown(state) { + return __stateToMarkdown(state) + .replace( + ZWS, // draft-js-export-markdown adds these + ''); // this is *not* a zero width space, trust me :) +} + + // FIXME Breaks markdown with multiple paragraphs, since it only strips first and last

function mdownToHtml(mdown: string): string { let html = marked(mdown) || ""; @@ -480,7 +490,7 @@ export default class MessageComposerInput extends React.Component { }); } if (cmd.promise) { - cmd.promise.done(function() { + cmd.promise.then(function() { console.log("Command success."); }, function(err) { console.error("Command failure: %s", err); @@ -520,7 +530,7 @@ export default class MessageComposerInput extends React.Component { this.sentHistory.push(contentHTML); let sendMessagePromise = sendFn.call(this.client, this.props.room.roomId, contentText, contentHTML); - sendMessagePromise.done(() => { + sendMessagePromise.then(() => { dis.dispatch({ action: 'message_sent' }); diff --git a/test/components/views/rooms/MessageComposerInput-test.js b/test/components/views/rooms/MessageComposerInput-test.js new file mode 100644 index 0000000000..89f838ba87 --- /dev/null +++ b/test/components/views/rooms/MessageComposerInput-test.js @@ -0,0 +1,144 @@ +import React from 'react'; +import ReactTestUtils from 'react-addons-test-utils'; +import ReactDOM from 'react-dom'; +import expect, {createSpy} from 'expect'; +import sinon from 'sinon'; +import Q from 'q'; +import * as testUtils from '../../../test-utils'; +import sdk from 'matrix-react-sdk'; +import UserSettingsStore from '../../../../src/UserSettingsStore'; +const MessageComposerInput = sdk.getComponent('views.rooms.MessageComposerInput'); +import MatrixClientPeg from 'MatrixClientPeg'; + +function addTextToDraft(text) { + const components = document.getElementsByClassName('public-DraftEditor-content'); + if (components && components.length) { + const textarea = components[0]; + const textEvent = document.createEvent('TextEvent'); + textEvent.initTextEvent('textInput', true, true, null, text); + textarea.dispatchEvent(textEvent); + } +} + +describe('MessageComposerInput', () => { + let parentDiv = null, + sandbox = null, + client = null, + mci = null, + room = testUtils.mkStubRoom('!DdJkzRliezrwpNebLk:matrix.org'); + + // TODO Remove when RTE is out of labs. + + beforeEach(() => { + sandbox = testUtils.stubClient(sandbox); + client = MatrixClientPeg.get(); + UserSettingsStore.isFeatureEnabled = sinon.stub() + .withArgs('rich_text_editor').returns(true); + + parentDiv = document.createElement('div'); + document.body.appendChild(parentDiv); + mci = ReactDOM.render( + , + parentDiv); + }); + + afterEach(() => { + if (parentDiv) { + ReactDOM.unmountComponentAtNode(parentDiv); + parentDiv.remove(); + parentDiv = null; + } + sandbox.restore(); + }); + + it('should change mode if indicator is clicked', () => { + mci.enableRichtext(true); + + setTimeout(() => { + const indicator = ReactTestUtils.findRenderedDOMComponentWithClass( + mci, + 'mx_MessageComposer_input_markdownIndicator'); + ReactTestUtils.Simulate.click(indicator); + + expect(mci.state.isRichtextEnabled).toEqual(false, 'should have changed mode'); + }); + }); + + it('should not send messages when composer is empty', () => { + const spy = sinon.spy(client, 'sendHtmlMessage'); + mci.enableRichtext(true); + mci.handleReturn(sinon.stub()); + + expect(spy.calledOnce).toEqual(false, 'should not send message'); + }); + + it('should not change content unnecessarily on RTE -> Markdown conversion', () => { + const spy = sinon.spy(client, 'sendHtmlMessage'); + mci.enableRichtext(true); + addTextToDraft('a'); + mci.handleKeyCommand('toggle-mode'); + mci.handleReturn(sinon.stub()); + + expect(spy.calledOnce).toEqual(true); + expect(spy.args[0][1]).toEqual('a'); + }); + + it('should not change content unnecessarily on Markdown -> RTE conversion', () => { + const spy = sinon.spy(client, 'sendHtmlMessage'); + mci.enableRichtext(false); + addTextToDraft('a'); + mci.handleKeyCommand('toggle-mode'); + mci.handleReturn(sinon.stub()); + expect(spy.calledOnce).toEqual(true); + expect(spy.args[0][1]).toEqual('a'); + }); + + it('should send emoji messages in rich text', () => { + const spy = sinon.spy(client, 'sendHtmlMessage'); + mci.enableRichtext(true); + addTextToDraft('☹'); + mci.handleReturn(sinon.stub()); + + expect(spy.calledOnce).toEqual(true, 'should send message'); + }); + + it('should send emoji messages in Markdown', () => { + const spy = sinon.spy(client, 'sendHtmlMessage'); + mci.enableRichtext(false); + addTextToDraft('☹'); + mci.handleReturn(sinon.stub()); + + expect(spy.calledOnce).toEqual(true, 'should send message'); + }); + + it('should convert basic Markdown to rich text correctly', () => { + const spy = sinon.spy(client, 'sendHtmlMessage'); + mci.enableRichtext(false); + addTextToDraft('*abc*'); + mci.handleKeyCommand('toggle-mode'); + mci.handleReturn(sinon.stub()); + expect(spy.args[0][2]).toContain('abc'); + }); + + it('should convert basic rich text to Markdown correctly', () => { + const spy = sinon.spy(client, 'sendHtmlMessage'); + mci.enableRichtext(true); + mci.handleKeyCommand('italic'); + addTextToDraft('abc'); + mci.handleKeyCommand('toggle-mode'); + mci.handleReturn(sinon.stub()); + expect(['_abc_', '*abc*']).toContain(spy.args[0][1]); + }); + + it('should insert formatting characters in Markdown mode', () => { + const spy = sinon.spy(client, 'sendHtmlMessage'); + mci.enableRichtext(false); + mci.handleKeyCommand('italic'); + mci.handleReturn(sinon.stub()); + expect(['__', '**']).toContain(spy.args[0][1]); + }); + +}); diff --git a/test/test-utils.js b/test/test-utils.js index 799f04ce54..78349b7824 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -12,7 +12,7 @@ var MatrixEvent = jssdk.MatrixEvent; * name to stdout. * @param {Mocha.Context} context The test context */ -module.exports.beforeEach = function(context) { +export function beforeEach(context) { var desc = context.currentTest.fullTitle(); console.log(); console.log(desc); @@ -26,7 +26,7 @@ module.exports.beforeEach = function(context) { * * @returns {sinon.Sandbox}; remember to call sandbox.restore afterwards. */ -module.exports.stubClient = function() { +export function stubClient() { var sandbox = sinon.sandbox.create(); var client = { @@ -44,6 +44,16 @@ module.exports.stubClient = function() { sendReadReceipt: sinon.stub().returns(q()), getRoomIdForAlias: sinon.stub().returns(q()), getProfileInfo: sinon.stub().returns(q({})), + getAccountData: (type) => { + return mkEvent({ + type, + event: true, + content: {}, + }); + }, + setAccountData: sinon.stub(), + sendTyping: sinon.stub().returns(q({})), + sendHtmlMessage: () => q({}), }; // stub out the methods in MatrixClientPeg @@ -73,7 +83,7 @@ module.exports.stubClient = function() { * @param {boolean} opts.event True to make a MatrixEvent. * @return {Object} a JSON object representing this event. */ -module.exports.mkEvent = function(opts) { +export function mkEvent(opts) { if (!opts.type || !opts.content) { throw new Error("Missing .type or .content =>" + JSON.stringify(opts)); } @@ -101,7 +111,7 @@ module.exports.mkEvent = function(opts) { * @param {Object} opts Values for the presence. * @return {Object|MatrixEvent} The event */ -module.exports.mkPresence = function(opts) { +export function mkPresence(opts) { if (!opts.user) { throw new Error("Missing user"); } @@ -132,7 +142,7 @@ module.exports.mkPresence = function(opts) { * @param {boolean} opts.event True to make a MatrixEvent. * @return {Object|MatrixEvent} The event */ -module.exports.mkMembership = function(opts) { +export function mkMembership(opts) { opts.type = "m.room.member"; if (!opts.skey) { opts.skey = opts.user; @@ -145,7 +155,7 @@ module.exports.mkMembership = function(opts) { }; if (opts.name) { opts.content.displayname = opts.name; } if (opts.url) { opts.content.avatar_url = opts.url; } - return module.exports.mkEvent(opts); + return mkEvent(opts); }; /** @@ -157,7 +167,7 @@ module.exports.mkMembership = function(opts) { * @param {boolean} opts.event True to make a MatrixEvent. * @return {Object|MatrixEvent} The event */ -module.exports.mkMessage = function(opts) { +export function mkMessage(opts) { opts.type = "m.room.message"; if (!opts.msg) { opts.msg = "Random->" + Math.random(); @@ -169,11 +179,12 @@ module.exports.mkMessage = function(opts) { msgtype: "m.text", body: opts.msg }; - return module.exports.mkEvent(opts); -}; + return mkEvent(opts); +} -module.exports.mkStubRoom = function() { +export function mkStubRoom(roomId = null) { return { + roomId, getReceiptsForEvent: sinon.stub().returns([]), getMember: sinon.stub().returns({}), getJoinedMembers: sinon.stub().returns([]), @@ -182,4 +193,4 @@ module.exports.mkStubRoom = function() { members: [], }, }; -}; +} From 17b75a589faec42c44451e12351add1b79953ab0 Mon Sep 17 00:00:00 2001 From: wmwragg Date: Fri, 9 Sep 2016 14:36:51 +0100 Subject: [PATCH 03/11] Added the little green men for direct message rooms --- src/components/views/rooms/RoomTile.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/components/views/rooms/RoomTile.js b/src/components/views/rooms/RoomTile.js index 79698e475d..54772b8a2f 100644 --- a/src/components/views/rooms/RoomTile.js +++ b/src/components/views/rooms/RoomTile.js @@ -21,6 +21,7 @@ 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'); @@ -64,6 +65,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({ @@ -259,6 +270,11 @@ module.exports = React.createClass({ var RoomAvatar = sdk.getComponent('avatars.RoomAvatar'); + var directMessageIndicator; + if (this._isDirectMessageRoom(this.props.room)) { + directMessageIndicator = dm; + } + // These props are injected by React DnD, // as defined by your `collect` function above: var isDragging = this.props.isDragging; @@ -271,6 +287,7 @@ module.exports = React.createClass({

+ {directMessageIndicator}
From a6b0a7d5dc6abab7f20b4c0daab3762c20996de6 Mon Sep 17 00:00:00 2001 From: wmwragg Date: Fri, 9 Sep 2016 15:01:40 +0100 Subject: [PATCH 04/11] Should supply the roomId --- src/components/views/rooms/RoomTile.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/rooms/RoomTile.js b/src/components/views/rooms/RoomTile.js index 54772b8a2f..ac79da9851 100644 --- a/src/components/views/rooms/RoomTile.js +++ b/src/components/views/rooms/RoomTile.js @@ -271,7 +271,7 @@ module.exports = React.createClass({ var RoomAvatar = sdk.getComponent('avatars.RoomAvatar'); var directMessageIndicator; - if (this._isDirectMessageRoom(this.props.room)) { + if (this._isDirectMessageRoom(this.props.room.roomId)) { directMessageIndicator = dm; } From a306a5e694a31278a89526820d1d03331c427a64 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Fri, 9 Sep 2016 16:06:19 +0100 Subject: [PATCH 05/11] Fix bug whereby refreshing Vector would not allow querying of membership state This was caused by Vector only sending a room alias with the `view_room` action. We now resolve this to a room ID if we don't have a room ID. --- src/ScalarMessaging.js | 85 +++++++++++++++++++++++++----------------- 1 file changed, 50 insertions(+), 35 deletions(-) diff --git a/src/ScalarMessaging.js b/src/ScalarMessaging.js index 952648010c..c3b5b59e25 100644 --- a/src/ScalarMessaging.js +++ b/src/ScalarMessaging.js @@ -256,6 +256,7 @@ function returnStateEvent(event, roomId, eventType, stateKey) { } var currentRoomId = null; +var currentRoomAlias = null; // Listen for when a room is viewed dis.register(onAction); @@ -264,6 +265,7 @@ function onAction(payload) { return; } currentRoomId = payload.room_id; + currentRoomAlias = payload.room_alias; } const onMessage = function(event) { @@ -287,45 +289,58 @@ const onMessage = function(event) { sendError(event, "Missing room_id in request"); return; } + let promise = Promise.resolve(currentRoomId); if (!currentRoomId) { - sendError(event, "Must be viewing a room"); - return; - } - if (roomId !== currentRoomId) { - sendError(event, "Room " + roomId + " not visible"); - return; + if (!currentRoomAlias) { + sendError(event, "Must be viewing a room"); + } + // 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; + }); } - // Getting join rules does not require userId - if (event.data.action === "join_rules_state") { - getJoinRules(event, roomId); - return; - } + promise.then((viewingRoomId) => { + if (roomId !== viewingRoomId) { + sendError(event, "Room " + roomId + " not visible"); + 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; - } + // 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 = { From 9c290c4b8d7a85f3c9d7a74816f3afd708de0cb4 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Fri, 9 Sep 2016 16:14:41 +0100 Subject: [PATCH 06/11] Return after sending an error --- src/ScalarMessaging.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ScalarMessaging.js b/src/ScalarMessaging.js index c3b5b59e25..bb6b4d02cb 100644 --- a/src/ScalarMessaging.js +++ b/src/ScalarMessaging.js @@ -293,6 +293,7 @@ const onMessage = function(event) { 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); From aa0f15c46eac985b773f612f29197718fd094eab Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 9 Sep 2016 16:15:01 +0100 Subject: [PATCH 07/11] List common rooms in MemberInfo --- src/components/views/rooms/MemberInfo.js | 150 ++++++++--------------- src/components/views/rooms/RoomTile.js | 24 ++-- src/utils/DMRoomMap.js | 27 ++-- 3 files changed, 88 insertions(+), 113 deletions(-) diff --git a/src/components/views/rooms/MemberInfo.js b/src/components/views/rooms/MemberInfo.js index be6663be67..4310c56363 100644 --- a/src/components/views/rooms/MemberInfo.js +++ b/src/components/views/rooms/MemberInfo.js @@ -26,12 +26,15 @@ limitations under the License. * 'isTargetMod': boolean */ var React = require('react'); +var classNames = require('classnames'); var MatrixClientPeg = require("../../../MatrixClientPeg"); var dis = require("../../../dispatcher"); var Modal = require("../../../Modal"); var sdk = require('../../../index'); var UserSettingsStore = require('../../../UserSettingsStore'); var createRoom = require('../../../createRoom'); +var DMRoomMap = require('../../../utils/DMRoomMap'); +var Unread = require('../../../Unread'); module.exports = React.createClass({ displayName: 'MemberInfo', @@ -60,7 +63,6 @@ module.exports = React.createClass({ updating: 0, devicesLoading: true, devices: null, - existingOneToOneRoomId: null, } }, @@ -71,10 +73,6 @@ module.exports = React.createClass({ // feature is enabled in the user settings this._enableDevices = MatrixClientPeg.get().isCryptoEnabled() && UserSettingsStore.isFeatureEnabled("e2e_encryption"); - - this.setState({ - existingOneToOneRoomId: this.getExistingOneToOneRoomId() - }); }, componentDidMount: function() { @@ -98,59 +96,6 @@ module.exports = React.createClass({ } }, - getExistingOneToOneRoomId: function() { - const rooms = MatrixClientPeg.get().getRooms(); - const userIds = [ - this.props.member.userId, - MatrixClientPeg.get().credentials.userId - ]; - let existingRoomId = null; - let invitedRoomId = null; - - // roomId can be null here because of a hack in MatrixChat.onUserClick where we - // abuse this to view users rather than room members. - let currentMembers; - if (this.props.member.roomId) { - const currentRoom = MatrixClientPeg.get().getRoom(this.props.member.roomId); - currentMembers = currentRoom.getJoinedMembers(); - } - - // reuse the first private 1:1 we find - existingRoomId = null; - - for (let i = 0; i < rooms.length; i++) { - // don't try to reuse public 1:1 rooms - const join_rules = rooms[i].currentState.getStateEvents("m.room.join_rules", ''); - if (join_rules && join_rules.getContent().join_rule === 'public') continue; - - const members = rooms[i].getJoinedMembers(); - if (members.length === 2 && - userIds.indexOf(members[0].userId) !== -1 && - userIds.indexOf(members[1].userId) !== -1) - { - existingRoomId = rooms[i].roomId; - break; - } - - const invited = rooms[i].getMembersWithMembership('invite'); - if (members.length === 1 && - invited.length === 1 && - userIds.indexOf(members[0].userId) !== -1 && - userIds.indexOf(invited[0].userId) !== -1 && - invitedRoomId === null) - { - invitedRoomId = rooms[i].roomId; - // keep looking: we'll use this one if there's nothing better - } - } - - if (existingRoomId === null) { - existingRoomId = invitedRoomId; - } - - return existingRoomId; - }, - onDeviceVerificationChanged: function(userId, device) { if (!this._enableDevices) { return; @@ -416,33 +361,16 @@ module.exports = React.createClass({ } }, - onChatClick: function() { - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - - // TODO: keep existingOneToOneRoomId updated if we see any room member changes anywhere - - const useExistingOneToOneRoom = this.state.existingOneToOneRoomId && (this.state.existingOneToOneRoomId !== this.props.member.roomId); - - // 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. - if (useExistingOneToOneRoom) { - dis.dispatch({ - action: 'view_room', - room_id: this.state.existingOneToOneRoomId, - }); + onNewDMClick: function() { + this.setState({ updating: this.state.updating + 1 }); + createRoom({ + createOpts: { + invite: [this.props.member.userId], + }, + }).finally(() => { this.props.onFinished(); - } - else { - this.setState({ updating: this.state.updating + 1 }); - createRoom({ - createOpts: { - invite: [this.props.member.userId], - }, - }).finally(() => { - this.props.onFinished(); - this.setState({ updating: this.state.updating - 1 }); - }).done(); - } + this.setState({ updating: this.state.updating - 1 }); + }).done(); }, onLeaveClick: function() { @@ -583,24 +511,50 @@ module.exports = React.createClass({ render: function() { var startChat, kickButton, banButton, muteButton, giveModButton, spinner; if (this.props.member.userId !== MatrixClientPeg.get().credentials.userId) { - // FIXME: we're referring to a vector component from react-sdk - var BottomLeftMenuTile = sdk.getComponent('rooms.BottomLeftMenuTile'); + const dmRoomMap = new DMRoomMap(MatrixClientPeg.get()); + const dmRooms = dmRoomMap.getDMRoomsForUserId(this.props.member.userId); - var label; - if (this.state.existingOneToOneRoomId) { - if (this.state.existingOneToOneRoomId == this.props.member.roomId) { - label = "Start new direct chat"; - } - else { - label = "Go to direct chat"; + const RoomTile = sdk.getComponent("rooms.RoomTile"); + + const tiles = []; + for (const roomId of dmRooms) { + const room = MatrixClientPeg.get().getRoom(roomId); + if (room) { + const me = room.getMember(MatrixClientPeg.get().credentials.userId); + const highlight = ( + room.getUnreadNotificationCount('highlight') > 0 || + me.membership == "invite" + ); + tiles.push( + + ); } } - else { - label = "Start direct chat"; - } - startChat = + const labelClasses = classNames({ + mx_MemberInfo_createRoom_label: true, + mx_RoomTile_name: true, + }); + const startNewChat =
+
+ +
+
Start new direct chat
+
+ + startChat =
+ {tiles} + {startNewChat} +
; } if (this.state.updating) { diff --git a/src/components/views/rooms/RoomTile.js b/src/components/views/rooms/RoomTile.js index b646b12e76..182b62fcb2 100644 --- a/src/components/views/rooms/RoomTile.js +++ b/src/components/views/rooms/RoomTile.js @@ -28,10 +28,9 @@ module.exports = React.createClass({ displayName: 'RoomTile', propTypes: { - // TODO: We should *optionally* support DND stuff and ideally be impl agnostic about it - connectDragSource: React.PropTypes.func.isRequired, - connectDropTarget: React.PropTypes.func.isRequired, - isDragging: React.PropTypes.bool.isRequired, + connectDragSource: React.PropTypes.func, + connectDropTarget: React.PropTypes.func, + isDragging: React.PropTypes.bool, room: React.PropTypes.object.isRequired, collapsed: React.PropTypes.bool.isRequired, @@ -39,11 +38,15 @@ module.exports = React.createClass({ unread: React.PropTypes.bool.isRequired, highlight: React.PropTypes.bool.isRequired, isInvite: React.PropTypes.bool.isRequired, - roomSubList: React.PropTypes.object.isRequired, - refreshSubList: React.PropTypes.func.isRequired, incomingCall: React.PropTypes.object, }, + getDefaultProps: function() { + return { + isDragging: false, + }; + }, + getInitialState: function() { return({ hover : false, @@ -265,7 +268,7 @@ module.exports = React.createClass({ var connectDragSource = this.props.connectDragSource; var connectDropTarget = this.props.connectDropTarget; - return connectDragSource(connectDropTarget( + let ret = (
@@ -281,6 +284,11 @@ module.exports = React.createClass({ { incomingCallBox } { tooltip }
- )); + ); + + if (connectDropTarget) ret = connectDropTarget(ret); + if (connectDragSource) ret = connectDragSource(ret); + + return ret; } }); diff --git a/src/utils/DMRoomMap.js b/src/utils/DMRoomMap.js index d92ae87e64..0089ebbe5b 100644 --- a/src/utils/DMRoomMap.js +++ b/src/utils/DMRoomMap.js @@ -21,18 +21,13 @@ limitations under the License. */ export default class DMRoomMap { constructor(matrixClient) { + this.roomToUser = null; + const mDirectEvent = matrixClient.getAccountData('m.direct'); if (!mDirectEvent) { this.userToRooms = {}; - this.roomToUser = {}; } else { this.userToRooms = mDirectEvent.getContent(); - this.roomToUser = {}; - for (const user of Object.keys(this.userToRooms)) { - for (const roomId of this.userToRooms[user]) { - this.roomToUser[roomId] = user; - } - } } } @@ -41,6 +36,24 @@ export default class DMRoomMap { } getUserIdForRoomId(roomId) { + if (this.roomToUser == null) { + // we lazily populate roomToUser so you can use + // this class just to call getDMRoomsForUserId + // which doesn't do very much, but is a fairly + // convenient wrapper and there's no point + // iterating through the map if getUserIdForRoomId() + // is never called. + this._populateRoomToUser(); + } return this.roomToUser[roomId]; } + + _populateRoomToUser() { + this.roomToUser = {}; + for (const user of Object.keys(this.userToRooms)) { + for (const roomId of this.userToRooms[user]) { + this.roomToUser[roomId] = user; + } + } + } } From 6f2e0a4cdf7aa4d6e409e69e9704e6f7ec3f4e24 Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 9 Sep 2016 16:59:59 +0100 Subject: [PATCH 08/11] Make rooms in MemberInfo update when necessary Factor out the chunk of code that looks through a read receipt event to see if it contain a read receipt from a given user, now we use it in 2 places. --- src/components/views/rooms/MemberInfo.js | 60 +++++++++++++++++++++++- src/components/views/rooms/RoomList.js | 10 ++-- src/utils/Receipt.js | 32 +++++++++++++ 3 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 src/utils/Receipt.js diff --git a/src/components/views/rooms/MemberInfo.js b/src/components/views/rooms/MemberInfo.js index 4310c56363..b6944a3d4e 100644 --- a/src/components/views/rooms/MemberInfo.js +++ b/src/components/views/rooms/MemberInfo.js @@ -35,6 +35,7 @@ var UserSettingsStore = require('../../../UserSettingsStore'); var createRoom = require('../../../createRoom'); var DMRoomMap = require('../../../utils/DMRoomMap'); var Unread = require('../../../Unread'); +var Receipt = require('../../../utils/Receipt'); module.exports = React.createClass({ displayName: 'MemberInfo', @@ -73,11 +74,21 @@ module.exports = React.createClass({ // feature is enabled in the user settings this._enableDevices = MatrixClientPeg.get().isCryptoEnabled() && UserSettingsStore.isFeatureEnabled("e2e_encryption"); + + const cli = MatrixClientPeg.get(); + cli.on("deviceVerificationChanged", this.onDeviceVerificationChanged); + cli.on("Room", this.onRoom); + cli.on("deleteRoom", this.onDeleteRoom); + cli.on("Room.timeline", this.onRoomTimeline); + cli.on("Room.name", this.onRoomName); + cli.on("Room.receipt", this.onRoomReceipt); + cli.on("RoomState.events", this.onRoomStateEvents); + cli.on("RoomMember.name", this.onRoomMemberName); + cli.on("accountData", this.onAccountData); }, componentDidMount: function() { this._updateStateForNewMember(this.props.member); - MatrixClientPeg.get().on("deviceVerificationChanged", this.onDeviceVerificationChanged); }, componentWillReceiveProps: function(newProps) { @@ -90,6 +101,14 @@ module.exports = React.createClass({ var client = MatrixClientPeg.get(); if (client) { client.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged); + client.removeListener("Room", this.onRoom); + client.removeListener("deleteRoom", this.onDeleteRoom); + client.removeListener("Room.timeline", this.onRoomTimeline); + client.removeListener("Room.name", this.onRoomName); + client.removeListener("Room.receipt", this.onRoomReceipt); + client.removeListener("RoomState.events", this.onRoomStateEvents); + client.removeListener("RoomMember.name", this.onRoomMemberName); + client.removeListener("accountData", this.onAccountData); } if (this._cancelDeviceList) { this._cancelDeviceList(); @@ -109,6 +128,45 @@ module.exports = React.createClass({ } }, + onRoom: function(room) { + this.forceUpdate(); + }, + + onDeleteRoom: function(roomId) { + this.forceUpdate(); + }, + + onRoomTimeline: function(ev, room, toStartOfTimeline) { + if (toStartOfTimeline) return; + this.forceUpdate(); + }, + + onRoomName: function(room) { + this.forceUpdate(); + }, + + onRoomReceipt: function(receiptEvent, room) { + // because if we read a notification, it will affect notification count + // only bother updating if there's a receipt from us + if (Receipt.findReadReceiptFromUserId(receiptEvent, MatrixClientPeg.get().credentials.userId)) { + this.forceUpdate(); + } + }, + + onRoomStateEvents: function(ev, state) { + this.forceUpdate(); + }, + + onRoomMemberName: function(ev, member) { + this.forceUpdate(); + }, + + onAccountData: function(ev) { + if (ev.getType() == 'm.direct') { + this.forceUpdate(); + } + }, + _updateStateForNewMember: function(member) { var newState = this._calculateOpsPermissions(member); newState.devicesLoading = true; diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index 1265fc1af0..98ec789a7c 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -27,6 +27,7 @@ var sdk = require('../../../index'); var rate_limited_func = require('../../../ratelimitedfunc'); var Rooms = require('../../../Rooms'); var DMRoomMap = require('../../../utils/DMRoomMap'); +var Receipt = require('../../../utils/Receipt'); var HIDE_CONFERENCE_CHANS = true; @@ -154,13 +155,8 @@ module.exports = React.createClass({ onRoomReceipt: function(receiptEvent, room) { // because if we read a notification, it will affect notification count // only bother updating if there's a receipt from us - var receiptKeys = Object.keys(receiptEvent.getContent()); - for (var i = 0; i < receiptKeys.length; ++i) { - var rcpt = receiptEvent.getContent()[receiptKeys[i]]; - if (rcpt['m.read'] && rcpt['m.read'][MatrixClientPeg.get().credentials.userId]) { - this._delayedRefreshRoomList(); - break; - } + if (Receipt.findReadReceiptFromUserId(receiptEvent, MatrixClientPeg.get().credentials.userId)) { + this._delayedRefreshRoomList(); } }, diff --git a/src/utils/Receipt.js b/src/utils/Receipt.js new file mode 100644 index 0000000000..549a0fd8b3 --- /dev/null +++ b/src/utils/Receipt.js @@ -0,0 +1,32 @@ +/* +Copyright 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. +*/ + +/** + * Given MatrixEvent containing receipts, return the first + * read receipt from the given user ID, or null if no such + * receipt exists. + */ +export function findReadReceiptFromUserId(receiptEvent, userId) { + var receiptKeys = Object.keys(receiptEvent.getContent()); + for (var i = 0; i < receiptKeys.length; ++i) { + var rcpt = receiptEvent.getContent()[receiptKeys[i]]; + if (rcpt['m.read'] && rcpt['m.read'][userId]) { + return rcpt; + } + } + + return null; +} From 4c5db7cc9f54c5571cdf6b63cda5094a7814e69e Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 9 Sep 2016 17:23:15 +0100 Subject: [PATCH 09/11] Don't always show DM rooms in Direct Messages Favourites belong in favourites & parted ones belong in Historical, etc. --- src/components/views/rooms/RoomList.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index 1265fc1af0..134b2286aa 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -233,10 +233,6 @@ module.exports = React.createClass({ else if (HIDE_CONFERENCE_CHANS && Rooms.isConfCallRoom(room, me, self.props.ConferenceHandler)) { // skip past this room & don't put it in any lists } - else if (dmRoomMap.getUserIdForRoomId(room.roomId)) { - // "Direct Message" rooms - s.lists["im.vector.fake.direct"].push(room); - } else if (me.membership == "join" || me.membership === "ban" || (me.membership === "leave" && me.events.member.getSender() !== me.events.member.getStateKey())) { @@ -250,6 +246,10 @@ module.exports = React.createClass({ s.lists[tagNames[i]].push(room); } } + else if (dmRoomMap.getUserIdForRoomId(room.roomId)) { + // "Direct Message" rooms (that we're still in and that aren't otherwise tagged) + s.lists["im.vector.fake.direct"].push(room); + } else { s.lists["im.vector.fake.recent"].push(room); } From f1ed750246c6eaf6c3d9a11777f0321b455df163 Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 9 Sep 2016 17:35:35 +0100 Subject: [PATCH 10/11] Don't crash if no DM rooms with someone ...when opening MemberInfo. getDMRoomsForUserId should always return a valid list, since it's a list of what DM rooms you have with somebody. --- src/utils/DMRoomMap.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/utils/DMRoomMap.js b/src/utils/DMRoomMap.js index d92ae87e64..95b4edb546 100644 --- a/src/utils/DMRoomMap.js +++ b/src/utils/DMRoomMap.js @@ -37,10 +37,14 @@ export default class DMRoomMap { } getDMRoomsForUserId(userId) { - return this.userToRooms[userId]; + // Here, we return the empty list if there are no rooms, + // since the number of conversations you have with this user is zero. + return this.userToRooms[userId] || []; } getUserIdForRoomId(roomId) { + // Here, we return undefined if the room is not in the map: + // the room ID you gave is not a DM room for any user. return this.roomToUser[roomId]; } } From 1380bf705a1a44f9bb0c06f0f36c6dab46ba1e12 Mon Sep 17 00:00:00 2001 From: Shell Turner Date: Fri, 9 Sep 2016 18:21:31 +0100 Subject: [PATCH 11/11] Fix CAS support by using a temporary Matrix client Signed-off-by: Shell Turner --- src/Signup.js | 10 ++++++++++ src/components/structures/login/Login.js | 6 +++++- src/components/views/login/CasLogin.js | 13 +++---------- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/Signup.js b/src/Signup.js index 1ac92f3218..eaf1906059 100644 --- a/src/Signup.js +++ b/src/Signup.js @@ -6,6 +6,7 @@ var MatrixClientPeg = require("./MatrixClientPeg"); var SignupStages = require("./SignupStages"); var dis = require("./dispatcher"); var q = require("q"); +var url = require("url"); const EMAIL_STAGE_TYPE = "m.login.email.identity"; @@ -413,6 +414,15 @@ class Login extends Signup { throw error; }); } + + redirectToCas() { + var client = this._createTemporaryClient(); + var parsedUrl = url.parse(window.location.href, true); + parsedUrl.query["homeserver"] = client.getHomeserverUrl(); + parsedUrl.query["identityServer"] = client.getIdentityServerUrl(); + var casUrl = client.getCasLoginUrl(url.format(parsedUrl)); + window.location.href = casUrl; + } } module.exports.Register = Register; diff --git a/src/components/structures/login/Login.js b/src/components/structures/login/Login.js index 8025504857..3139d020a6 100644 --- a/src/components/structures/login/Login.js +++ b/src/components/structures/login/Login.js @@ -90,6 +90,10 @@ module.exports = React.createClass({ }).done(); }, + onCasLogin: function() { + this._loginLogic.redirectToCas(); + }, + _onLoginAsGuestClick: function() { var self = this; self.setState({ @@ -225,7 +229,7 @@ module.exports = React.createClass({ ); case 'm.login.cas': return ( - + ); default: if (!step) { diff --git a/src/components/views/login/CasLogin.js b/src/components/views/login/CasLogin.js index 5c89fd3706..c818586d52 100644 --- a/src/components/views/login/CasLogin.js +++ b/src/components/views/login/CasLogin.js @@ -16,26 +16,19 @@ limitations under the License. 'use strict'; -var MatrixClientPeg = require("../../../MatrixClientPeg"); var React = require('react'); -var url = require("url"); module.exports = React.createClass({ displayName: 'CasLogin', - onCasClicked: function(ev) { - var cli = MatrixClientPeg.get(); - var parsedUrl = url.parse(window.location.href, true); - parsedUrl.query["homeserver"] = cli.getHomeserverUrl(); - parsedUrl.query["identityServer"] = cli.getIdentityServerUrl(); - var casUrl = MatrixClientPeg.get().getCasLoginUrl(url.format(parsedUrl)); - window.location.href = casUrl; + propTypes: { + onSubmit: React.PropTypes.func, // fn() }, render: function() { return (
- +
); }