diff --git a/package.json b/package.json index cb3cdfa63f..5c96a74f5b 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "react": "^15.4.0", "react-addons-css-transition-group": "15.3.2", "react-dom": "^15.4.0", - "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef", + "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#39d858c", "sanitize-html": "^1.11.1", "text-encoding-utf-8": "^1.0.1", "velocity-vector": "vector-im/velocity#059e3b2", diff --git a/src/ConstantTimeDispatcher.js b/src/ConstantTimeDispatcher.js new file mode 100644 index 0000000000..265ee11fd4 --- /dev/null +++ b/src/ConstantTimeDispatcher.js @@ -0,0 +1,62 @@ +/* +Copyright 2017 Vector Creations 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. +*/ + +// singleton which dispatches invocations of a given type & argument +// rather than just a type (as per EventEmitter and Flux's dispatcher etc) +// +// This means you can have a single point which listens for an EventEmitter event +// and then dispatches out to one of thousands of RoomTiles (for instance) rather than +// having each RoomTile register for the EventEmitter event and having to +// iterate over all of them. +class ConstantTimeDispatcher { + constructor() { + // type -> arg -> [ listener(arg, params) ] + this.listeners = {}; + } + + register(type, arg, listener) { + if (!this.listeners[type]) this.listeners[type] = {}; + if (!this.listeners[type][arg]) this.listeners[type][arg] = []; + this.listeners[type][arg].push(listener); + } + + unregister(type, arg, listener) { + if (this.listeners[type] && this.listeners[type][arg]) { + var i = this.listeners[type][arg].indexOf(listener); + if (i > -1) { + this.listeners[type][arg].splice(i, 1); + } + } + else { + console.warn("Unregistering unrecognised listener (type=" + type + ", arg=" + arg + ")"); + } + } + + dispatch(type, arg, params) { + if (!this.listeners[type] || !this.listeners[type][arg]) { + console.warn("No registered listeners for dispatch (type=" + type + ", arg=" + arg + ")"); + return; + } + this.listeners[type][arg].forEach(listener=>{ + listener.call(arg, params); + }); + } +} + +if (!global.constantTimeDispatcher) { + global.constantTimeDispatcher = new ConstantTimeDispatcher(); +} +module.exports = global.constantTimeDispatcher; diff --git a/src/KeyCode.js b/src/KeyCode.js index c9cac01239..f164dbc15c 100644 --- a/src/KeyCode.js +++ b/src/KeyCode.js @@ -32,4 +32,5 @@ module.exports = { DELETE: 46, KEY_D: 68, KEY_E: 69, + KEY_K: 75, }; diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 8cd820c284..3aec582f89 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -523,6 +523,7 @@ var TimelinePanel = React.createClass({ this.props.timelineSet.room.setUnreadNotificationCount('highlight', 0); dis.dispatch({ action: 'on_room_read', + room: this.props.timelineSet.room, }); } } diff --git a/src/components/views/dialogs/QuestionDialog.js b/src/components/views/dialogs/QuestionDialog.js index 6012541b94..8e20b0d2bc 100644 --- a/src/components/views/dialogs/QuestionDialog.js +++ b/src/components/views/dialogs/QuestionDialog.js @@ -47,6 +47,12 @@ export default React.createClass({ this.props.onFinished(false); }, + componentDidMount: function() { + if (this.props.focus) { + this.refs.button.focus(); + } + }, + render: function() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const cancelButton = this.props.hasCancelButton ? ( @@ -63,7 +69,7 @@ export default React.createClass({ {this.props.description}
- {this.props.extraButtons} diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index 59346d5f4d..979b14eaaf 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -21,13 +21,13 @@ var GeminiScrollbar = require('react-gemini-scrollbar'); var MatrixClientPeg = require("../../../MatrixClientPeg"); var CallHandler = require('../../../CallHandler'); var RoomListSorter = require("../../../RoomListSorter"); -var Unread = require('../../../Unread'); var dis = require("../../../dispatcher"); var sdk = require('../../../index'); var rate_limited_func = require('../../../ratelimitedfunc'); var Rooms = require('../../../Rooms'); import DMRoomMap from '../../../utils/DMRoomMap'; var Receipt = require('../../../utils/Receipt'); +var constantTimeDispatcher = require('../../../ConstantTimeDispatcher'); var HIDE_CONFERENCE_CHANS = true; @@ -37,10 +37,16 @@ module.exports = React.createClass({ propTypes: { ConferenceHandler: React.PropTypes.any, collapsed: React.PropTypes.bool.isRequired, - currentRoom: React.PropTypes.string, + selectedRoom: React.PropTypes.string, searchFilter: React.PropTypes.string, }, + shouldComponentUpdate: function(nextProps, nextState) { + if (nextProps.collapsed !== this.props.collapsed) return true; + if (nextProps.searchFilter !== this.props.searchFilter) return true; + return false; + }, + getInitialState: function() { return { isLoadingLeftRooms: false, @@ -57,12 +63,18 @@ module.exports = React.createClass({ cli.on("Room.name", this.onRoomName); cli.on("Room.tags", this.onRoomTags); cli.on("Room.receipt", this.onRoomReceipt); - cli.on("RoomState.events", this.onRoomStateEvents); + cli.on("RoomState.members", this.onRoomStateMember); cli.on("RoomMember.name", this.onRoomMemberName); cli.on("accountData", this.onAccountData); + // lookup for which lists a given roomId is currently in. + this.listsForRoomId = {}; + var s = this.getRoomLists(); this.setState(s); + + // order of the sublists + this.listOrder = []; }, componentDidMount: function() { @@ -71,7 +83,22 @@ module.exports = React.createClass({ this._updateStickyHeaders(true); }, - componentDidUpdate: function() { + componentWillReceiveProps: function(nextProps) { + // short-circuit react when the room changes + // to avoid rerendering all the sublists everywhere + if (nextProps.selectedRoom !== this.props.selectedRoom) { + if (this.props.selectedRoom) { + constantTimeDispatcher.dispatch( + "RoomTile.select", this.props.selectedRoom, {} + ); + } + constantTimeDispatcher.dispatch( + "RoomTile.select", nextProps.selectedRoom, { selected: true } + ); + } + }, + + componentDidUpdate: function(prevProps, prevState) { // Reinitialise the stickyHeaders when the component is updated this._updateStickyHeaders(true); this._repositionIncomingCallBox(undefined, false); @@ -97,10 +124,24 @@ module.exports = React.createClass({ } break; case 'on_room_read': - // Force an update because the notif count state is too deep to cause - // an update. This forces the local echo of reading notifs to be - // reflected by the RoomTiles. - this.forceUpdate(); + // poke the right RoomTile to refresh, using the constantTimeDispatcher + // to avoid each and every RoomTile registering to the 'on_room_read' event + // XXX: if we like the constantTimeDispatcher we might want to dispatch + // directly from TimelinePanel rather than needlessly bouncing via here. + constantTimeDispatcher.dispatch( + "RoomTile.refresh", payload.room.roomId, {} + ); + + // also have to poke the right list(s) + var lists = this.listsForRoomId[payload.room.roomId]; + if (lists) { + lists.forEach(list=>{ + constantTimeDispatcher.dispatch( + "RoomSubList.refreshHeader", list, { room: payload.room } + ); + }); + } + break; } }, @@ -114,7 +155,7 @@ module.exports = React.createClass({ MatrixClientPeg.get().removeListener("Room.name", this.onRoomName); MatrixClientPeg.get().removeListener("Room.tags", this.onRoomTags); MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt); - MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents); + MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember); MatrixClientPeg.get().removeListener("RoomMember.name", this.onRoomMemberName); MatrixClientPeg.get().removeListener("accountData", this.onAccountData); } @@ -123,10 +164,14 @@ module.exports = React.createClass({ }, onRoom: function(room) { + // XXX: this happens rarely; ideally we should only update the correct + // sublists when it does (e.g. via a constantTimeDispatch to the right sublist) this._delayedRefreshRoomList(); }, onDeleteRoom: function(roomId) { + // XXX: this happens rarely; ideally we should only update the correct + // sublists when it does (e.g. via a constantTimeDispatch to the right sublist) this._delayedRefreshRoomList(); }, @@ -149,6 +194,10 @@ module.exports = React.createClass({ } }, + _onMouseOver: function(ev) { + this._lastMouseOverTs = Date.now(); + }, + onSubListHeaderClick: function(isHidden, scrollToPosition) { // The scroll area has expanded or contracted, so re-calculate sticky headers positions this._updateStickyHeaders(true, scrollToPosition); @@ -158,41 +207,89 @@ module.exports = React.createClass({ if (toStartOfTimeline) return; if (!room) return; if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return; - this._delayedRefreshRoomList(); + + // rather than regenerate our full roomlists, which is very heavy, we poke the + // correct sublists to just re-sort themselves. This isn't enormously reacty, + // but is much faster than the default react reconciler, or having to do voodoo + // with shouldComponentUpdate and a pleaseRefresh property or similar. + var lists = this.listsForRoomId[room.roomId]; + if (lists) { + lists.forEach(list=>{ + constantTimeDispatcher.dispatch("RoomSubList.sort", list, { room: room }); + }); + } + + // we have to explicitly hit the roomtile which just changed + constantTimeDispatcher.dispatch( + "RoomTile.refresh", room.roomId, {} + ); }, 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._delayedRefreshRoomList(); + var lists = this.listsForRoomId[room.roomId]; + if (lists) { + lists.forEach(list=>{ + constantTimeDispatcher.dispatch( + "RoomSubList.refreshHeader", list, { room: room } + ); + }); + } + + // we have to explicitly hit the roomtile which just changed + constantTimeDispatcher.dispatch( + "RoomTile.refresh", room.roomId, {} + ); } }, onRoomName: function(room) { - this._delayedRefreshRoomList(); + constantTimeDispatcher.dispatch( + "RoomTile.refresh", room.roomId, {} + ); }, onRoomTags: function(event, room) { + // XXX: this happens rarely; ideally we should only update the correct + // sublists when it does (e.g. via a constantTimeDispatch to the right sublist) this._delayedRefreshRoomList(); }, - onRoomStateEvents: function(ev, state) { - this._delayedRefreshRoomList(); + onRoomStateMember: function(ev, state, member) { + constantTimeDispatcher.dispatch( + "RoomTile.refresh", member.roomId, {} + ); }, onRoomMemberName: function(ev, member) { - this._delayedRefreshRoomList(); + constantTimeDispatcher.dispatch( + "RoomTile.refresh", member.roomId, {} + ); }, onAccountData: function(ev) { if (ev.getType() == 'm.direct') { + // XXX: this happens rarely; ideally we should only update the correct + // sublists when it does (e.g. via a constantTimeDispatch to the right sublist) this._delayedRefreshRoomList(); } + else if (ev.getType() == 'm.push_rules') { + this._delayedRefreshRoomList(); + } }, _delayedRefreshRoomList: new rate_limited_func(function() { - this.refreshRoomList(); + // if the mouse has been moving over the RoomList in the last 500ms + // then delay the refresh further to avoid bouncing around under the + // cursor + if (Date.now() - this._lastMouseOverTs > 500) { + this.refreshRoomList(); + } + else { + this._delayedRefreshRoomList(); + } }, 500), refreshRoomList: function() { @@ -200,14 +297,13 @@ module.exports = React.createClass({ // (!this._lastRefreshRoomListTs ? "-" : (Date.now() - this._lastRefreshRoomListTs)) // ); - // TODO: rather than bluntly regenerating and re-sorting everything - // every time we see any kind of room change from the JS SDK - // we could do incremental updates on our copy of the state - // based on the room which has actually changed. This would stop - // us re-rendering all the sublists every time anything changes anywhere - // in the state of the client. + // TODO: ideally we'd calculate this once at start, and then maintain + // any changes to it incrementally, updating the appropriate sublists + // as needed. + // Alternatively we'd do something magical with Immutable.js or similar. this.setState(this.getRoomLists()); - this._lastRefreshRoomListTs = Date.now(); + + // this._lastRefreshRoomListTs = Date.now(); }, getRoomLists: function() { @@ -221,18 +317,26 @@ module.exports = React.createClass({ s.lists["m.lowpriority"] = []; s.lists["im.vector.fake.archived"] = []; + this.listsForRoomId = {}; + var otherTagNames = {}; + const dmRoomMap = new DMRoomMap(MatrixClientPeg.get()); MatrixClientPeg.get().getRooms().forEach(function(room) { const me = room.getMember(MatrixClientPeg.get().credentials.userId); if (!me) return; - + // console.log("room = " + room.name + ", me.membership = " + me.membership + // ", sender = " + me.events.member.getSender() + // ", target = " + me.events.member.getStateKey() + // ", prevMembership = " + me.events.member.getPrevContent().membership); + if (!self.listsForRoomId[room.roomId]) { + self.listsForRoomId[room.roomId] = []; + } + if (me.membership == "invite") { + self.listsForRoomId[room.roomId].push("im.vector.fake.invite"); s.lists["im.vector.fake.invite"].push(room); } else if (HIDE_CONFERENCE_CHANS && Rooms.isConfCallRoom(room, me, self.props.ConferenceHandler)) { @@ -243,23 +347,27 @@ module.exports = React.createClass({ { // Used to split rooms via tags var tagNames = Object.keys(room.tags); - if (tagNames.length) { for (var i = 0; i < tagNames.length; i++) { var tagName = tagNames[i]; s.lists[tagName] = s.lists[tagName] || []; - s.lists[tagNames[i]].push(room); + s.lists[tagName].push(room); + self.listsForRoomId[room.roomId].push(tagName); + otherTagNames[tagName] = 1; } } else if (dmRoomMap.getUserIdForRoomId(room.roomId)) { // "Direct Message" rooms (that we're still in and that aren't otherwise tagged) + self.listsForRoomId[room.roomId].push("im.vector.fake.direct"); s.lists["im.vector.fake.direct"].push(room); } else { + self.listsForRoomId[room.roomId].push("im.vector.fake.recent"); s.lists["im.vector.fake.recent"].push(room); } } else if (me.membership === "leave") { + self.listsForRoomId[room.roomId].push("im.vector.fake.archived"); s.lists["im.vector.fake.archived"].push(room); } else { @@ -280,8 +388,10 @@ module.exports = React.createClass({ const me = room.getMember(MatrixClientPeg.get().credentials.userId); if (me && Rooms.looksLikeDirectMessageRoom(room, me)) { + self.listsForRoomId[room.roomId].push("im.vector.fake.direct"); s.lists["im.vector.fake.direct"].push(room); } else { + self.listsForRoomId[room.roomId].push("im.vector.fake.recent"); s.lists["im.vector.fake.recent"].push(room); } } @@ -298,6 +408,8 @@ module.exports = React.createClass({ newMDirectEvent[otherPerson.userId] = roomList; } + console.warn("Resetting room DM state to be " + JSON.stringify(newMDirectEvent)); + // if this fails, fine, we'll just do the same thing next time we get the room lists MatrixClientPeg.get().setAccountData('m.direct', newMDirectEvent).done(); } @@ -306,6 +418,21 @@ module.exports = React.createClass({ // we actually apply the sorting to this when receiving the prop in RoomSubLists. + // we'll need this when we get to iterating through lists programatically - e.g. ctrl-shift-up/down +/* + this.listOrder = [ + "im.vector.fake.invite", + "m.favourite", + "im.vector.fake.recent", + "im.vector.fake.direct", + Object.keys(otherTagNames).filter(tagName=>{ + return (!tagName.match(/^m\.(favourite|lowpriority)$/)); + }).sort(), + "m.lowpriority", + "im.vector.fake.archived" + ]; +*/ + return s; }, @@ -464,14 +591,14 @@ module.exports = React.createClass({ return ( -
+
@@ -482,9 +609,9 @@ module.exports = React.createClass({ verb="favourite" editable={ true } order="manual" - selectedRoom={ self.props.selectedRoom } incomingCall={ self.state.incomingCall } collapsed={ self.props.collapsed } + selectedRoom={ self.props.selectedRoom } searchFilter={ self.props.searchFilter } onHeaderClick={ self.onSubListHeaderClick } onShowMoreRooms={ self.onShowMoreRooms } /> @@ -495,9 +622,9 @@ module.exports = React.createClass({ verb="tag direct chat" editable={ true } order="recent" - selectedRoom={ self.props.selectedRoom } incomingCall={ self.state.incomingCall } collapsed={ self.props.collapsed } + selectedRoom={ self.props.selectedRoom } alwaysShowHeader={ true } searchFilter={ self.props.searchFilter } onHeaderClick={ self.onSubListHeaderClick } @@ -508,14 +635,14 @@ module.exports = React.createClass({ editable={ true } verb="restore" order="recent" - selectedRoom={ self.props.selectedRoom } incomingCall={ self.state.incomingCall } collapsed={ self.props.collapsed } + selectedRoom={ self.props.selectedRoom } searchFilter={ self.props.searchFilter } onHeaderClick={ self.onSubListHeaderClick } onShowMoreRooms={ self.onShowMoreRooms } /> - { Object.keys(self.state.lists).map(function(tagName) { + { Object.keys(self.state.lists).sort().map(function(tagName) { if (!tagName.match(/^(m\.(favourite|lowpriority)|im\.vector\.fake\.(invite|recent|direct|archived))$/)) { return ; @@ -540,9 +667,9 @@ module.exports = React.createClass({ verb="demote" editable={ true } order="recent" - selectedRoom={ self.props.selectedRoom } incomingCall={ self.state.incomingCall } collapsed={ self.props.collapsed } + selectedRoom={ self.props.selectedRoom } searchFilter={ self.props.searchFilter } onHeaderClick={ self.onSubListHeaderClick } onShowMoreRooms={ self.onShowMoreRooms } /> @@ -551,8 +678,8 @@ module.exports = React.createClass({ label="Historical" editable={ false } order="recent" - selectedRoom={ self.props.selectedRoom } collapsed={ self.props.collapsed } + selectedRoom={ self.props.selectedRoom } alwaysShowHeader={ true } startAsHidden={ true } showSpinner={ self.state.isLoadingLeftRooms } diff --git a/src/components/views/rooms/RoomTile.js b/src/components/views/rooms/RoomTile.js index 06b05e9299..5d896e8beb 100644 --- a/src/components/views/rooms/RoomTile.js +++ b/src/components/views/rooms/RoomTile.js @@ -27,6 +27,8 @@ var RoomNotifs = require('../../../RoomNotifs'); var FormattingUtils = require('../../../utils/FormattingUtils'); import AccessibleButton from '../elements/AccessibleButton'; var UserSettingsStore = require('../../../UserSettingsStore'); +var constantTimeDispatcher = require('../../../ConstantTimeDispatcher'); +var Unread = require('../../../Unread'); module.exports = React.createClass({ displayName: 'RoomTile', @@ -36,12 +38,10 @@ module.exports = React.createClass({ connectDropTarget: React.PropTypes.func, onClick: React.PropTypes.func, isDragging: React.PropTypes.bool, + selectedRoom: React.PropTypes.string, room: React.PropTypes.object.isRequired, collapsed: React.PropTypes.bool.isRequired, - selected: React.PropTypes.bool.isRequired, - unread: React.PropTypes.bool.isRequired, - highlight: React.PropTypes.bool.isRequired, isInvite: React.PropTypes.bool.isRequired, incomingCall: React.PropTypes.object, }, @@ -54,10 +54,11 @@ module.exports = React.createClass({ getInitialState: function() { return({ - hover : false, - badgeHover : false, + hover: false, + badgeHover: false, menuDisplayed: false, notifState: RoomNotifs.getRoomNotifsState(this.props.room.roomId), + selected: this.props.room ? (this.props.selectedRoom === this.props.room.roomId) : false, }); }, @@ -79,23 +80,32 @@ module.exports = React.createClass({ } }, - onAccountData: function(accountDataEvent) { - if (accountDataEvent.getType() == 'm.push_rules') { - this.setState({ - notifState: RoomNotifs.getRoomNotifsState(this.props.room.roomId), - }); - } - }, - componentWillMount: function() { - MatrixClientPeg.get().on("accountData", this.onAccountData); + constantTimeDispatcher.register("RoomTile.refresh", this.props.room.roomId, this.onRefresh); + constantTimeDispatcher.register("RoomTile.select", this.props.room.roomId, this.onSelect); + this.onRefresh(); }, componentWillUnmount: function() { - var cli = MatrixClientPeg.get(); - if (cli) { - MatrixClientPeg.get().removeListener("accountData", this.onAccountData); - } + constantTimeDispatcher.unregister("RoomTile.refresh", this.props.room.roomId, this.onRefresh); + constantTimeDispatcher.unregister("RoomTile.select", this.props.room.roomId, this.onSelect); + }, + + componentWillReceiveProps: function(nextProps) { + this.onRefresh(); + }, + + onRefresh: function(params) { + this.setState({ + unread: Unread.doesRoomHaveUnreadMessages(this.props.room), + highlight: this.props.room.getUnreadNotificationCount('highlight') > 0 || this.props.isInvite, + }); + }, + + onSelect: function(params) { + this.setState({ + selected: params.selected, + }); }, onClick: function() { @@ -169,13 +179,13 @@ module.exports = React.createClass({ // var highlightCount = this.props.room.getUnreadNotificationCount("highlight"); const notifBadges = notificationCount > 0 && this._shouldShowNotifBadge(); - const mentionBadges = this.props.highlight && this._shouldShowMentionBadge(); + const mentionBadges = this.state.highlight && this._shouldShowMentionBadge(); const badges = notifBadges || mentionBadges; var classes = classNames({ 'mx_RoomTile': true, - 'mx_RoomTile_selected': this.props.selected, - 'mx_RoomTile_unread': this.props.unread, + 'mx_RoomTile_selected': this.state.selected, + 'mx_RoomTile_unread': this.state.unread, 'mx_RoomTile_unreadNotify': notifBadges, 'mx_RoomTile_highlight': mentionBadges, 'mx_RoomTile_invited': (me && me.membership == 'invite'), @@ -221,7 +231,7 @@ module.exports = React.createClass({ 'mx_RoomTile_badgeShown': badges || this.state.badgeHover || this.state.menuDisplayed, }); - if (this.props.selected) { + if (this.state.selected) { let nameSelected = {name}; label =
{ nameSelected }
; @@ -255,7 +265,8 @@ module.exports = React.createClass({ let ret = (
{ /* Only native elements can be wrapped in a DnD object. */} - +