From f7aa8be1c1792fdd265afd1acf1d5721af0cf567 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Wed, 16 Dec 2015 16:06:29 +0000 Subject: [PATCH 01/17] Add a forget button. Add left rooms to the "historical" tab. Call /forget when the forget button is clicked. Number of shortcomings: - We need to lazy load the historical list (atm we never get the list of left rooms; things only go into that list if you leave the room whilst running) - Once a room is forgotten we need to physically nuke it from the JS SDK. - Need icon for forget room. --- src/components/structures/RoomView.js | 23 ++++++++++++++++++++--- src/components/views/rooms/RoomHeader.js | 13 ++++++++++++- src/components/views/rooms/RoomList.js | 3 +++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 6db2659986..072742cf10 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -824,7 +824,14 @@ module.exports = React.createClass({ action: 'leave_room', room_id: this.props.roomId, }); - this.props.onFinished(); + }, + + onForgetClick: function() { + MatrixClientPeg.get().forget(this.props.roomId).done(function() { + dis.dispatch({ action: 'view_next_room' }); + }, function(err) { + console.error("Failed to forget room: %s", err); + }); }, onRejectButtonClicked: function(ev) { @@ -1249,8 +1256,18 @@ module.exports = React.createClass({ return (
- + { fileDropTarget }
diff --git a/src/components/views/rooms/RoomHeader.js b/src/components/views/rooms/RoomHeader.js index 068dff85d6..aaf570305c 100644 --- a/src/components/views/rooms/RoomHeader.js +++ b/src/components/views/rooms/RoomHeader.js @@ -129,7 +129,17 @@ module.exports = React.createClass({ if (this.props.onLeaveClick) { leave_button =
- Leave room + Leave room +
; + } + + var forget_button; + if (this.props.onForgetClick) { + forget_button = +
+ Forget room
; } @@ -147,6 +157,7 @@ module.exports = React.createClass({ {cancel_button} {save_button}
+ { forget_button } { leave_button }
Search diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index a89dd55f1a..c48ed5880f 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -168,6 +168,9 @@ module.exports = React.createClass({ if (me && me.membership == "invite") { s.lists["im.vector.fake.invite"].push(room); } + else if (me && (me.membership === "leave" || me.membership === "ban")) { + s.lists["im.vector.fake.archived"].push(room); + } else { var shouldShowRoom = ( me && (me.membership == "join") From e8f82527d1435e821ceaa284fd1bac1367bb1f18 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Wed, 16 Dec 2015 16:27:46 +0000 Subject: [PATCH 02/17] Listen for room deletions and refresh the room list when it happens --- src/components/views/rooms/RoomList.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index c48ed5880f..f7dd48d871 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -45,6 +45,7 @@ module.exports = React.createClass({ componentWillMount: function() { var cli = MatrixClientPeg.get(); 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.tags", this.onRoomTags); @@ -91,6 +92,10 @@ module.exports = React.createClass({ this.refreshRoomList(); }, + onDeleteRoom: function(roomId) { + this.refreshRoomList(); + }, + onRoomTimeline: function(ev, room, toStartOfTimeline) { if (toStartOfTimeline) return; From d979a028a5ebaba567711dd3c3bf982f33e65a20 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Thu, 17 Dec 2015 02:49:09 +0000 Subject: [PATCH 03/17] position the inbound call box correctly, and fix various issues with when the video preview and callview are shown --- src/CallHandler.js | 10 ++- src/components/structures/RoomView.js | 3 +- src/components/views/rooms/MessageComposer.js | 2 + src/components/views/rooms/RoomList.js | 67 +++++++++++++++++- src/components/views/rooms/RoomTile.js | 8 +++ src/components/views/voip/CallView.js | 12 +--- src/components/views/voip/IncomingCallBox.js | 70 ++----------------- 7 files changed, 94 insertions(+), 78 deletions(-) diff --git a/src/CallHandler.js b/src/CallHandler.js index 187449924f..189e99b307 100644 --- a/src/CallHandler.js +++ b/src/CallHandler.js @@ -138,9 +138,17 @@ function _setCallListeners(call) { function _setCallState(call, roomId, status) { console.log( - "Call state in %s changed to %s (%s)", roomId, status, (call ? call.state : "-") + "Call state in %s changed to %s (%s)", roomId, status, (call ? call.call_state : "-") ); calls[roomId] = call; + + if (status === "ringing") { + play("ringAudio") + } + else if (call && call.call_state === "ringing") { + pause("ringAudio") + } + if (call) { call.call_state = status; } diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 6db2659986..01b877e6cf 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -1207,8 +1207,9 @@ module.exports = React.createClass({ } var call = CallHandler.getCallForRoom(this.props.roomId); + //var call = CallHandler.getAnyActiveCall(); var inCall = false; - if (call && this.state.callState != 'ended') { + if (call && (this.state.callState !== 'ended' && this.state.callState !== 'ringing')) { inCall = true; var zoomButton, voiceMuteButton, videoMuteButton; diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index 73f553af6e..7c228b5c9d 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -529,6 +529,7 @@ module.exports = React.createClass({ onHangupClick: function() { var call = CallHandler.getCallForRoom(this.props.room.roomId); + //var call = CallHandler.getAnyActiveCall(); if (!call) { return; } @@ -563,6 +564,7 @@ module.exports = React.createClass({ var callButton, videoCallButton, hangupButton; var call = CallHandler.getCallForRoom(this.props.room.roomId); + //var call = CallHandler.getAnyActiveCall(); if (this.props.callState && this.props.callState !== 'ended') { hangupButton =
diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index a89dd55f1a..9ec2bf0d45 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -19,6 +19,7 @@ var React = require("react"); var ReactDOM = require("react-dom"); var GeminiScrollbar = require('react-gemini-scrollbar'); var MatrixClientPeg = require("../../../MatrixClientPeg"); +var CallHandler = require('../../../CallHandler'); var RoomListSorter = require("../../../RoomListSorter"); var UnreadStatus = require('../../../UnreadStatus'); var dis = require("../../../dispatcher"); @@ -39,6 +40,7 @@ module.exports = React.createClass({ return { activityMap: null, lists: {}, + incomingCall: null, } }, @@ -66,7 +68,21 @@ module.exports = React.createClass({ this.tooltip = payload.tooltip; this._repositionTooltip(); if (this.tooltip) this.tooltip.style.display = 'block'; - break + break; + case 'call_state': + var call = CallHandler.getCall(payload.room_id); + if (call && call.call_state === 'ringing') { + this.setState({ + incomingCall: call + }); + this._repositionIncomingCallBox(undefined, true); + } + else { + this.setState({ + incomingCall: null + }); + } + break; } }, @@ -212,10 +228,49 @@ module.exports = React.createClass({ return s; }, + _getScrollNode: function() { + var panel = ReactDOM.findDOMNode(this); + if (!panel) return null; + + if (panel.classList.contains('gm-prevented')) { + return panel; + } else { + return panel.children[2]; // XXX: Fragile! + } + }, + + _repositionTooltips: function(e) { + this._repositionTooltip(e); + this._repositionIncomingCallBox(e, false); + }, + _repositionTooltip: function(e) { if (this.tooltip && this.tooltip.parentElement) { var scroll = ReactDOM.findDOMNode(this); - this.tooltip.style.top = (scroll.parentElement.offsetTop + this.tooltip.parentElement.offsetTop - scroll.children[2].scrollTop) + "px"; + this.tooltip.style.top = (scroll.parentElement.offsetTop + this.tooltip.parentElement.offsetTop - this._getScrollNode().scrollTop) + "px"; + } + }, + + _repositionIncomingCallBox: function(e, firstTime) { + var incomingCallBox = document.getElementById("incomingCallBox"); + if (incomingCallBox && incomingCallBox.parentElement) { + var scroll = this._getScrollNode(); + var top = (scroll.offsetTop + incomingCallBox.parentElement.offsetTop - scroll.scrollTop); + + if (firstTime) { + // scroll to make sure the callbox is on the screen... + if (top < 10) { + scroll.scrollTop = incomingCallBox.parentElement.offsetTop - 10; + } + else if (top > scroll.clientHeight - incomingCallBox.offsetHeight) { + scroll.scrollTop = incomingCallBox.parentElement.offsetTop - scroll.offsetHeight + incomingCallBox.offsetHeight; + } + // recalculate top in case we clipped it. + top = (scroll.offsetTop + incomingCallBox.parentElement.offsetTop - scroll.scrollTop); + } + + incomingCallBox.style.top = top + "px"; + incomingCallBox.style.left = scroll.offsetLeft + scroll.offsetWidth + "px"; } }, @@ -234,7 +289,7 @@ module.exports = React.createClass({ var self = this; return ( - +
{ expandButton } @@ -244,6 +299,7 @@ module.exports = React.createClass({ order="recent" activityMap={ self.state.activityMap } selectedRoom={ self.props.selectedRoom } + incomingCall={ self.state.incomingCall } collapsed={ self.props.collapsed } /> { Object.keys(self.state.lists).map(function(tagName) { @@ -276,6 +334,7 @@ module.exports = React.createClass({ order="manual" activityMap={ self.state.activityMap } selectedRoom={ self.props.selectedRoom } + incomingCall={ self.state.incomingCall } collapsed={ self.props.collapsed } /> } @@ -290,6 +349,7 @@ module.exports = React.createClass({ bottommost={ self.state.lists['im.vector.fake.archived'].length === 0 } activityMap={ self.state.activityMap } selectedRoom={ self.props.selectedRoom } + incomingCall={ self.state.incomingCall } collapsed={ self.props.collapsed } />
diff --git a/src/components/views/rooms/RoomTile.js b/src/components/views/rooms/RoomTile.js index 0a03ebe89d..37a77f9561 100644 --- a/src/components/views/rooms/RoomTile.js +++ b/src/components/views/rooms/RoomTile.js @@ -38,6 +38,7 @@ module.exports = React.createClass({ highlight: React.PropTypes.bool.isRequired, isInvite: React.PropTypes.bool.isRequired, roomSubList: React.PropTypes.object.isRequired, + incomingCall: React.PropTypes.object, }, getInitialState: function() { @@ -105,6 +106,12 @@ module.exports = React.createClass({ label = ; } + var incomingCallBox; + if (this.props.incomingCall) { + var IncomingCallBox = sdk.getComponent("voip.IncomingCallBox"); + incomingCallBox = ; + } + var RoomAvatar = sdk.getComponent('avatars.RoomAvatar'); // These props are injected by React DnD, @@ -120,6 +127,7 @@ module.exports = React.createClass({ { badge }
{ label } + { incomingCallBox }
)); } diff --git a/src/components/views/voip/CallView.js b/src/components/views/voip/CallView.js index fbaed1dcd7..d67147dd1e 100644 --- a/src/components/views/voip/CallView.js +++ b/src/components/views/voip/CallView.js @@ -35,19 +35,13 @@ module.exports = React.createClass({ componentDidMount: function() { this.dispatcherRef = dis.register(this.onAction); - this._trackedRoom = null; if (this.props.room) { - this._trackedRoom = this.props.room; - this.showCall(this._trackedRoom.roomId); + this.showCall(this.props.room.roomId); } else { + // XXX: why would we ever not have a this.props.room? var call = CallHandler.getAnyActiveCall(); if (call) { - console.log( - "Global CallView is now tracking active call in room %s", - call.roomId - ); - this._trackedRoom = MatrixClientPeg.get().getRoom(call.roomId); this.showCall(call.roomId); } } @@ -81,7 +75,7 @@ module.exports = React.createClass({ // and for the voice stream of screen captures call.setRemoteAudioElement(this.getVideoView().getRemoteAudioElement()); } - if (call && call.type === "video" && call.state !== 'ended') { + if (call && call.type === "video" && call.call_state !== "ended" && call.call_state !== "ringing") { // if this call is a conf call, don't display local video as the // conference will have us in it this.getVideoView().getLocalVideoElement().style.display = ( diff --git a/src/components/views/voip/IncomingCallBox.js b/src/components/views/voip/IncomingCallBox.js index 263bbf543c..b110d45043 100644 --- a/src/components/views/voip/IncomingCallBox.js +++ b/src/components/views/voip/IncomingCallBox.js @@ -21,87 +21,29 @@ var CallHandler = require("../../../CallHandler"); module.exports = React.createClass({ displayName: 'IncomingCallBox', - componentDidMount: function() { - this.dispatcherRef = dis.register(this.onAction); - }, - - componentWillUnmount: function() { - dis.unregister(this.dispatcherRef); - }, - - getInitialState: function() { - return { - incomingCall: null - } - }, - - onAction: function(payload) { - if (payload.action !== 'call_state') { - return; - } - var call = CallHandler.getCall(payload.room_id); - if (!call || call.call_state !== 'ringing') { - this.setState({ - incomingCall: null, - }); - this.getRingAudio().pause(); - return; - } - if (call.call_state === "ringing") { - this.getRingAudio().load(); - this.getRingAudio().play(); - } - else { - this.getRingAudio().pause(); - } - - this.setState({ - incomingCall: call - }); - }, - onAnswerClick: function() { dis.dispatch({ action: 'answer', - room_id: this.state.incomingCall.roomId + room_id: this.props.incomingCall.roomId }); }, onRejectClick: function() { dis.dispatch({ action: 'hangup', - room_id: this.state.incomingCall.roomId + room_id: this.props.incomingCall.roomId }); }, - getRingAudio: function() { - return this.refs.ringAudio; - }, - render: function() { - // NB: This block MUST have a "key" so React doesn't clobber the elements - // between in-call / not-in-call. - var audioBlock = ( - - ); - if (!this.state.incomingCall || !this.state.incomingCall.roomId) { - return ( -
- {audioBlock} -
- ); - } - var caller = MatrixClientPeg.get().getRoom(this.state.incomingCall.roomId).name; + var room = MatrixClientPeg.get().getRoom(this.props.incomingCall.roomId); + var caller = room ? room.name : "unknown"; return ( -
- {audioBlock} +
- Incoming { this.state.incomingCall ? this.state.incomingCall.type : '' } call from { caller } + Incoming { this.props.incomingCall ? this.props.incomingCall.type : '' } call from { caller }
From bb184c12a1b0abb5096f64f959e88b30d7775793 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Thu, 17 Dec 2015 11:56:41 +0000 Subject: [PATCH 04/17] fix NPE --- src/components/views/voip/IncomingCallBox.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/voip/IncomingCallBox.js b/src/components/views/voip/IncomingCallBox.js index b110d45043..a9601931bb 100644 --- a/src/components/views/voip/IncomingCallBox.js +++ b/src/components/views/voip/IncomingCallBox.js @@ -37,7 +37,7 @@ module.exports = React.createClass({ render: function() { - var room = MatrixClientPeg.get().getRoom(this.props.incomingCall.roomId); + var room = this.props.incomingCall ? MatrixClientPeg.get().getRoom(this.props.incomingCall.roomId) : null; var caller = room ? room.name : "unknown"; return (
From 5c999fe1ab307083ef224728c45e1b8f8c3ed681 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Thu, 17 Dec 2015 14:56:09 +0000 Subject: [PATCH 05/17] stop the incoming call box from scrolling off the screen --- src/components/views/rooms/RoomList.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index 9ec2bf0d45..ad749295ae 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -259,15 +259,24 @@ module.exports = React.createClass({ if (firstTime) { // scroll to make sure the callbox is on the screen... - if (top < 10) { + if (top < 10) { // 10px of vertical margin at top of screen scroll.scrollTop = incomingCallBox.parentElement.offsetTop - 10; } - else if (top > scroll.clientHeight - incomingCallBox.offsetHeight) { - scroll.scrollTop = incomingCallBox.parentElement.offsetTop - scroll.offsetHeight + incomingCallBox.offsetHeight; + else if (top > scroll.clientHeight - incomingCallBox.offsetHeight + 50) { + scroll.scrollTop = incomingCallBox.parentElement.offsetTop - scroll.offsetHeight + incomingCallBox.offsetHeight - 50; } // recalculate top in case we clipped it. top = (scroll.offsetTop + incomingCallBox.parentElement.offsetTop - scroll.scrollTop); } + else { + // stop the box from scrolling off the screen + if (top < 10) { + top = 10; + } + else if (top > scroll.clientHeight - incomingCallBox.offsetHeight + 50) { + top = scroll.clientHeight - incomingCallBox.offsetHeight + 50; + } + } incomingCallBox.style.top = top + "px"; incomingCallBox.style.left = scroll.offsetLeft + scroll.offsetWidth + "px"; From e0d05d4f4b770c51ab7bd088d1ac977ea1fe6b71 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Thu, 17 Dec 2015 22:07:56 +0000 Subject: [PATCH 06/17] Initialise the messagepanel correctly after accepting an invite This should fix vector-im/vector-web#538. I'm sorry. --- src/components/structures/RoomView.js | 46 ++++++++++++++------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 2e8a39aae9..2425fab958 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -77,13 +77,6 @@ module.exports = React.createClass({ }, componentWillUnmount: function() { - if (this.refs.messagePanel) { - var messagePanel = ReactDOM.findDOMNode(this.refs.messagePanel); - messagePanel.removeEventListener('drop', this.onDrop); - messagePanel.removeEventListener('dragover', this.onDragOver); - messagePanel.removeEventListener('dragleave', this.onDragLeaveOrEnd); - messagePanel.removeEventListener('dragend', this.onDragLeaveOrEnd); - } dis.unregister(this.dispatcherRef); if (MatrixClientPeg.get()) { MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline); @@ -285,16 +278,7 @@ module.exports = React.createClass({ componentDidMount: function() { if (this.refs.messagePanel) { - var messagePanel = ReactDOM.findDOMNode(this.refs.messagePanel); - - messagePanel.addEventListener('drop', this.onDrop); - messagePanel.addEventListener('dragover', this.onDragOver); - messagePanel.addEventListener('dragleave', this.onDragLeaveOrEnd); - messagePanel.addEventListener('dragend', this.onDragLeaveOrEnd); - - this.scrollToBottom(); - this.sendReadReceipt(); - this.fillSpace(); + this._initialiseMessagePanel(); } var call = CallHandler.getCallForRoom(this.props.roomId); @@ -309,19 +293,37 @@ module.exports = React.createClass({ this.onResize(); }, + _initialiseMessagePanel: function() { + var messagePanel = ReactDOM.findDOMNode(this.refs.messagePanel); + this.refs.messagePanel.initialised = true; + + messagePanel.addEventListener('drop', this.onDrop); + messagePanel.addEventListener('dragover', this.onDragOver); + messagePanel.addEventListener('dragleave', this.onDragLeaveOrEnd); + messagePanel.addEventListener('dragend', this.onDragLeaveOrEnd); + + this.scrollToBottom(); + this.sendReadReceipt(); + this.fillSpace(); + }, + componentDidUpdate: function() { + // we need to initialise the messagepanel if we've just joined the + // room. TODO: we really really ought to factor out messagepanel to a + // separate component to avoid this ridiculous dance. + if (!this.refs.messagePanel) return; + + if (!this.refs.messagePanel.initialised) { + this._initialiseMessagePanel(); + } + // after adding event tiles, we may need to tweak the scroll (either to // keep at the bottom of the timeline, or to maintain the view after // adding events to the top). - if (!this.refs.messagePanel) return; - if (this.state.searchResults) return; this._restoreSavedScrollState(); - - // have to fill space in case we're accepting an invite - if (!this.state.paginating) this.fillSpace(); }, _paginateCompleted: function() { From d6c208a27507b2935f94b8d4b62a139a236f7002 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Fri, 18 Dec 2015 10:19:07 +0000 Subject: [PATCH 07/17] Reinstate the DnD event listener removals, with comments --- src/components/structures/RoomView.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 2425fab958..81ac1db639 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -77,6 +77,17 @@ module.exports = React.createClass({ }, componentWillUnmount: function() { + if (this.refs.messagePanel) { + // disconnect the D&D event listeners from the message panel. This + // is really just for hygiene - the messagePanel is going to be + // deleted anyway, so it doesn't matter if the event listeners + // don't get cleaned up. + var messagePanel = ReactDOM.findDOMNode(this.refs.messagePanel); + messagePanel.removeEventListener('drop', this.onDrop); + messagePanel.removeEventListener('dragover', this.onDragOver); + messagePanel.removeEventListener('dragleave', this.onDragLeaveOrEnd); + messagePanel.removeEventListener('dragend', this.onDragLeaveOrEnd); + } dis.unregister(this.dispatcherRef); if (MatrixClientPeg.get()) { MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline); From 4b271a332e5e817bde391a9517a9bbde8362b7b0 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Fri, 18 Dec 2015 11:11:41 +0000 Subject: [PATCH 08/17] Refactor the search stuff in RoomView * factor out the call to MatrixClient.search to a separate _getSearchBatch (so that we can reuse it for paginated results in a bit) * Don't group cross-room searches by room - just display them in timeline order. --- src/components/structures/RoomView.js | 207 ++++++++++++----------- src/components/views/rooms/RoomHeader.js | 4 +- 2 files changed, 109 insertions(+), 102 deletions(-) diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 81ac1db639..e9341b7389 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -490,75 +490,65 @@ module.exports = React.createClass({ }, onSearch: function(term, scope) { - var filter; - if (scope === "Room") { - filter = { - // XXX: it's unintuitive that the filter for searching doesn't have the same shape as the v2 filter API :( - rooms: [ - this.props.roomId - ] - }; - } - - var self = this; - self.setState({ - searchInProgress: true + this.setState({ + searchTerm: term, + searchScope: scope, + searchResults: [], + searchHighlights: [], + searchCount: null, }); - MatrixClientPeg.get().search({ - body: { - search_categories: { - room_events: { - search_term: term, - filter: filter, - order_by: "recent", - include_state: true, - groupings: { - group_by: [ - { - key: "room_id" - } - ] - }, - event_context: { - before_limit: 1, - after_limit: 1, - include_profile: true, - } - } - } - } - }).then(function(data) { + this._getSearchBatch(term, scope); + }, - if (!self.state.searching || term !== self.refs.search_bar.refs.search_term.value) { + // fire off a request for a batch of search results + _getSearchBatch: function(term, scope) { + this.setState({ + searchInProgress: true, + }); + + // make sure that we don't end up merging results from + // different searches by keeping a unique id. + // + // todo: should cancel any previous search requests. + var searchId = this.searchId = new Date().getTime(); + + var self = this; + + MatrixClientPeg.get().search({ body: this._getSearchCondition(term, scope) }) + .then(function(data) { + if (!self.state.searching || self.searchId != searchId) { console.error("Discarding stale search results"); return; } - // for debugging: - // data.search_categories.room_events.highlights = ["hello", "everybody"]; + var results = data.search_categories.room_events; - var highlights; - if (data.search_categories.room_events.highlights && - data.search_categories.room_events.highlights.length > 0) - { - // postgres on synapse returns us precise details of the - // strings which actually got matched for highlighting. - // for overlapping highlights, favour longer (more specific) terms first - highlights = data.search_categories.room_events.highlights - .sort(function(a, b) { b.length - a.length }); - } - else { - // sqlite doesn't, so just try to highlight the literal search term + // postgres on synapse returns us precise details of the + // strings which actually got matched for highlighting. + + // combine the highlight list with our existing list; build an object + // to avoid O(N^2) fail + var highlights = {}; + results.highlights.forEach(function(hl) { highlights[hl] = 1; }); + self.state.searchHighlights.forEach(function(hl) { highlights[hl] = 1; }); + + // turn it back into an ordered list. For overlapping highlights, + // favour longer (more specific) terms first + highlights = Object.keys(highlights).sort(function(a, b) { b.length - a.length }); + + // sqlite doesn't give us any highlights, so just try to highlight the literal search term + if (highlights.length == 0) { highlights = [ term ]; } + // append the new results to our existing results + var events = self.state.searchResults.concat(results.results); + self.setState({ - highlights: highlights, - searchTerm: term, - searchResults: data, - searchScope: scope, - searchCount: data.search_categories.room_events.count, + searchHighlights: highlights, + searchResults: events, + searchCount: results.count, }); }, function(error) { var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); @@ -570,7 +560,35 @@ module.exports = React.createClass({ self.setState({ searchInProgress: false }); - }); + }).done(); + }, + + _getSearchCondition: function(term, scope) { + var filter; + + if (scope === "Room") { + filter = { + // XXX: it's unintuitive that the filter for searching doesn't have the same shape as the v2 filter API :( + rooms: [ + this.props.roomId + ] + }; + } + + return { + search_categories: { + room_events: { + search_term: term, + filter: filter, + order_by: "recent", + event_context: { + before_limit: 1, + after_limit: 1, + include_profile: true, + } + } + } + } }, getEventTiles: function() { @@ -585,57 +603,44 @@ module.exports = React.createClass({ if (this.state.searchResults) { - if (!this.state.searchResults.search_categories.room_events.results || - !this.state.searchResults.search_categories.room_events.groups) - { - return ret; - } + // XXX: todo: merge overlapping results somehow? + // XXX: why doesn't searching on name work? - // XXX: this dance is foul, due to the results API not directly returning sorted results - var results = this.state.searchResults.search_categories.room_events.results; - var roomIdGroups = this.state.searchResults.search_categories.room_events.groups.room_id; + var lastRoomId; - if (Array.isArray(results)) { - // Old search API used to return results as a event_id -> result dict, but now - // returns a straightforward list. - results = results.reduce(function(prev, curr) { - prev[curr.result.event_id] = curr; - return prev; - }, {}); - } + for (var i = this.state.searchResults.length - 1; i >= 0; i--) { + var result = this.state.searchResults[i]; + var mxEv = new Matrix.MatrixEvent(result.result); - Object.keys(roomIdGroups) - .sort(function(a, b) { roomIdGroups[a].order - roomIdGroups[b].order }) // WHY NOT RETURN AN ORDERED ARRAY?!?!?! - .forEach(function(roomId) - { - // XXX: todo: merge overlapping results somehow? - // XXX: why doesn't searching on name work? if (self.state.searchScope === 'All') { - ret.push(
  • Room: { cli.getRoom(roomId).name }

  • ); + var roomId = result.result.room_id; + if(roomId != lastRoomId) { + ret.push(
  • Room: { cli.getRoom(roomId).name }

  • ); + lastRoomId = roomId; + } } - var resultList = roomIdGroups[roomId].results.map(function(eventId) { return results[eventId]; }); - for (var i = resultList.length - 1; i >= 0; i--) { - var ts1 = resultList[i].result.origin_server_ts; - ret.push(
  • ); // Rank: {resultList[i].rank} - var mxEv = new Matrix.MatrixEvent(resultList[i].result); - if (resultList[i].context.events_before[0]) { - var mxEv2 = new Matrix.MatrixEvent(resultList[i].context.events_before[0]); - if (EventTile.haveTileForEvent(mxEv2)) { - ret.push(
  • ); - } - } - if (EventTile.haveTileForEvent(mxEv)) { - ret.push(
  • ); - } - if (resultList[i].context.events_after[0]) { - var mxEv2 = new Matrix.MatrixEvent(resultList[i].context.events_after[0]); - if (EventTile.haveTileForEvent(mxEv2)) { - ret.push(
  • ); - } + var ts1 = result.result.origin_server_ts; + ret.push(
  • ); // Rank: {resultList[i].rank} + + if (result.context.events_before[0]) { + var mxEv2 = new Matrix.MatrixEvent(result.context.events_before[0]); + if (EventTile.haveTileForEvent(mxEv2)) { + ret.push(
  • ); } } - }); + + if (EventTile.haveTileForEvent(mxEv)) { + ret.push(
  • ); + } + + if (result.context.events_after[0]) { + var mxEv2 = new Matrix.MatrixEvent(result.context.events_after[0]); + if (EventTile.haveTileForEvent(mxEv2)) { + ret.push(
  • ); + } + } + } return ret; } diff --git a/src/components/views/rooms/RoomHeader.js b/src/components/views/rooms/RoomHeader.js index 068dff85d6..1e287ac7dc 100644 --- a/src/components/views/rooms/RoomHeader.js +++ b/src/components/views/rooms/RoomHeader.js @@ -103,7 +103,9 @@ module.exports = React.createClass({ // var searchStatus; - if (this.props.searchInfo && this.props.searchInfo.searchTerm) { + // don't display the search count until the search completes and + // gives us a non-null searchCount. + if (this.props.searchInfo && this.props.searchInfo.searchCount !== null) { searchStatus =
     ({ this.props.searchInfo.searchCount } results)
    ; } From ec32347f43b8d90780cb2b5591b8c059508b79bb Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Thu, 17 Dec 2015 23:04:32 +0000 Subject: [PATCH 09/17] Request more search results when scroll hits top of window --- src/components/structures/RoomView.js | 69 +++++++++++++++++---------- 1 file changed, 43 insertions(+), 26 deletions(-) diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index e9341b7389..c9d3bc732d 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -346,39 +346,49 @@ module.exports = React.createClass({ // we might not have got enough results from the pagination // request, so give fillSpace() a chance to set off another. - if (!this.fillSpace()) { - this.setState({paginating: false}); + this.setState({paginating: false}); + + if (!this.state.searchResults) { + this.fillSpace(); } }, // check the scroll position, and if we need to, set off a pagination // request. - // - // returns true if a pagination request was started (or is still in progress) fillSpace: function() { if (!this.refs.messagePanel) return; - if (this.state.searchResults) return; // TODO: paginate search results var messageWrapperScroll = this._getScrollNode(); - if (messageWrapperScroll.scrollTop < messageWrapperScroll.clientHeight && this.state.room.oldState.paginationToken) { - // there's less than a screenful of messages left. Either wind back - // the message cap (if there are enough events in the timeline to - // do so), or fire off a pagination request. - - this.oldScrollHeight = messageWrapperScroll.scrollHeight; - - if (this.state.messageCap < this.state.room.timeline.length) { - var cap = Math.min(this.state.messageCap + PAGINATE_SIZE, this.state.room.timeline.length); - if (DEBUG_SCROLL) console.log("winding back message cap to", cap); - this.setState({messageCap: cap}); - } else { - var cap = this.state.messageCap + PAGINATE_SIZE; - if (DEBUG_SCROLL) console.log("starting paginate to cap", cap); - this.setState({messageCap: cap, paginating: true}); - MatrixClientPeg.get().scrollback(this.state.room, PAGINATE_SIZE).finally(this._paginateCompleted).done(); - return true; - } + if (messageWrapperScroll.scrollTop > messageWrapperScroll.clientHeight) { + return; + } + + // there's less than a screenful of messages left - try to get some + // more messages. + + if (this.state.searchResults) { + if (this.nextSearchBatch) { + if (DEBUG_SCROLL) console.log("requesting more search results"); + this._getSearchBatch(this.state.searchTerm, + this.state.searchScope); + } else { + if (DEBUG_SCROLL) console.log("no more search results"); + } + return; + } + + // Either wind back the message cap (if there are enough events in the + // timeline to do so), or fire off a pagination request. + + if (this.state.messageCap < this.state.room.timeline.length) { + var cap = Math.min(this.state.messageCap + PAGINATE_SIZE, this.state.room.timeline.length); + if (DEBUG_SCROLL) console.log("winding back message cap to", cap); + this.setState({messageCap: cap}); + } else if(this.state.room.oldState.paginationToken) { + var cap = this.state.messageCap + PAGINATE_SIZE; + if (DEBUG_SCROLL) console.log("starting paginate to cap", cap); + this.setState({messageCap: cap, paginating: true}); + MatrixClientPeg.get().scrollback(this.state.room, PAGINATE_SIZE).finally(this._paginateCompleted).done(); } - return false; }, onResendAllClick: function() { @@ -438,7 +448,9 @@ module.exports = React.createClass({ this.setState({numUnreadMessages: 0}); } } - if (!this.state.paginating) this.fillSpace(); + if (!this.state.paginating && !this.state.searchInProgress) { + this.fillSpace(); + } }, onDragOver: function(ev) { @@ -498,6 +510,7 @@ module.exports = React.createClass({ searchCount: null, }); + this.nextSearchBatch = null; this._getSearchBatch(term, scope); }, @@ -515,8 +528,11 @@ module.exports = React.createClass({ var self = this; - MatrixClientPeg.get().search({ body: this._getSearchCondition(term, scope) }) + if (DEBUG_SCROLL) console.log("sending search request"); + MatrixClientPeg.get().search({ body: this._getSearchCondition(term, scope), + next_batch: this.nextSearchBatch }) .then(function(data) { + if (DEBUG_SCROLL) console.log("search complete"); if (!self.state.searching || self.searchId != searchId) { console.error("Discarding stale search results"); return; @@ -550,6 +566,7 @@ module.exports = React.createClass({ searchResults: events, searchCount: results.count, }); + self.nextSearchBatch = results.next_batch; }, function(error) { var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { From 22635f251d6b546f7d41d47406e56437209e2626 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Fri, 18 Dec 2015 11:55:43 +0000 Subject: [PATCH 10/17] Call through to syncLeftRooms when the archived header is clicked --- src/components/views/rooms/RoomList.js | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index dfdeb0145b..758d3500dd 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -96,6 +96,17 @@ module.exports = React.createClass({ this.refreshRoomList(); }, + onArchivedHeaderClick: function(isHidden) { + if (!isHidden) { + // we don't care about the response since it comes down via "Room" + // events. + MatrixClientPeg.get().syncLeftRooms().catch(function(err) { + console.error("Failed to sync left rooms: %s", err); + console.error(err); + }); + } + }, + onRoomTimeline: function(ev, room, toStartOfTimeline) { if (toStartOfTimeline) return; @@ -295,7 +306,7 @@ module.exports = React.createClass({ verb="demote" editable={ true } order="recent" - bottommost={ self.state.lists['im.vector.fake.archived'].length === 0 } + bottommost={ false } activityMap={ self.state.activityMap } selectedRoom={ self.props.selectedRoom } collapsed={ self.props.collapsed } /> @@ -307,7 +318,9 @@ module.exports = React.createClass({ bottommost={ true } activityMap={ self.state.activityMap } selectedRoom={ self.props.selectedRoom } - collapsed={ self.props.collapsed } /> + collapsed={ self.props.collapsed } + alwaysShowHeader={ true } + onHeaderClick= { self.onArchivedHeaderClick } />
    ); From 711fdd25afa4813829d7b46934843482554a7429 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Fri, 18 Dec 2015 15:13:59 +0000 Subject: [PATCH 11/17] Improve perf of refreshing room list. Show spinner when loading left rooms. When the JS SDK encounters a new room it will emit a flurry of events for things like state and room members. Refreshing the room list on each event is bad for performance. This is okay initially because the room list is only shown after the first sync, but when getting archived rooms it locks up for 15-30s as it thrashes. Add a 1s cap to refreshRoomList() which means that it will refresh *AT MOST* once every second. If it has been >1s since the last refresh it will immediately refresh. If it has been <1s it will wait the difference. --- src/components/views/rooms/RoomList.js | 55 +++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index 758d3500dd..af20b25421 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -38,6 +38,7 @@ module.exports = React.createClass({ getInitialState: function() { return { activityMap: null, + isLoadingLeftRooms: false, lists: {}, } }, @@ -89,20 +90,24 @@ module.exports = React.createClass({ }, onRoom: function(room) { - this.refreshRoomList(); + this._delayedRefreshRoomList(); }, onDeleteRoom: function(roomId) { - this.refreshRoomList(); + this._delayedRefreshRoomList(); }, onArchivedHeaderClick: function(isHidden) { if (!isHidden) { + var self = this; + this.setState({ isLoadingLeftRooms: true }); // we don't care about the response since it comes down via "Room" // events. MatrixClientPeg.get().syncLeftRooms().catch(function(err) { console.error("Failed to sync left rooms: %s", err); console.error(err); + }).finally(function() { + self.setState({ isLoadingLeftRooms: false }); }); } }, @@ -143,22 +148,57 @@ module.exports = React.createClass({ }, onRoomName: function(room) { - this.refreshRoomList(); + this._delayedRefreshRoomList(); }, onRoomTags: function(event, room) { - this.refreshRoomList(); + this._delayedRefreshRoomList(); }, onRoomStateEvents: function(ev, state) { - setTimeout(this.refreshRoomList, 0); + this._delayedRefreshRoomList(); }, onRoomMemberName: function(ev, member) { - setTimeout(this.refreshRoomList, 0); + this._delayedRefreshRoomList(); + }, + + _delayedRefreshRoomList: function() { + // There can be 1000s of JS SDK events when rooms are initially synced; + // we don't want to do lots of work rendering until things have settled. + // Therefore, keep a 1s refresh buffer which will refresh the room list + // at MOST once every 1s to prevent thrashing. + var MAX_REFRESH_INTERVAL_MS = 1000; + var self = this; + + if (!self._lastRefreshRoomListTs) { + self.refreshRoomList(); // first refresh evar + } + else { + var timeWaitedMs = Date.now() - self._lastRefreshRoomListTs; + if (timeWaitedMs > MAX_REFRESH_INTERVAL_MS) { + clearTimeout(self._refreshRoomListTimerId); + self._refreshRoomListTimerId = null; + self.refreshRoomList(); // refreshed more than MAX_REFRESH_INTERVAL_MS ago + } + else { + // refreshed less than MAX_REFRESH_INTERVAL_MS ago, wait the difference + // if we aren't already waiting. If we are waiting then NOP, it will + // fire soon, promise! + if (!self._refreshRoomListTimerId) { + self._refreshRoomListTimerId = setTimeout(function() { + self.refreshRoomList(); + }, 10 + MAX_REFRESH_INTERVAL_MS - timeWaitedMs); // 10 is a buffer amount + } + } + } }, refreshRoomList: function() { + // console.log("DEBUG: Refresh room list delta=%s ms", + // (!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 @@ -166,6 +206,7 @@ module.exports = React.createClass({ // us re-rendering all the sublists every time anything changes anywhere // in the state of the client. this.setState(this.getRoomLists()); + this._lastRefreshRoomListTs = Date.now(); }, getRoomLists: function() { @@ -320,6 +361,8 @@ module.exports = React.createClass({ selectedRoom={ self.props.selectedRoom } collapsed={ self.props.collapsed } alwaysShowHeader={ true } + startAsHidden={ true } + showSpinner={ self.state.isLoadingLeftRooms } onHeaderClick= { self.onArchivedHeaderClick } />
    From c3bd81b83aa187a89feda3eb106b429d04723f84 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Fri, 18 Dec 2015 15:56:27 +0000 Subject: [PATCH 12/17] Make rooms the user is banned in be treated as a joined room for position in room list This is so users can still find the room they've been expelled from, rather than have it drop to the Historical section. --- src/components/views/rooms/RoomList.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index af20b25421..887f6adb5e 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -225,12 +225,12 @@ module.exports = React.createClass({ if (me && me.membership == "invite") { s.lists["im.vector.fake.invite"].push(room); } - else if (me && (me.membership === "leave" || me.membership === "ban")) { + else if (me && me.membership === "leave") { s.lists["im.vector.fake.archived"].push(room); } else { var shouldShowRoom = ( - me && (me.membership == "join") + me && (me.membership == "join" || me.membership === "ban") ); // hiding conf rooms only ever toggles shouldShowRoom to false From 44c42394aecb4b31431c32c5d21411ad2e0f8e54 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Fri, 18 Dec 2015 16:42:46 +0000 Subject: [PATCH 13/17] Fix scroll for search results Make sure that we save scroll position when searching, and use it to preserve offset when backfilling. --- src/components/structures/RoomView.js | 221 ++++++++++++++++---------- 1 file changed, 138 insertions(+), 83 deletions(-) diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index c9d3bc732d..df8c850d0d 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -48,6 +48,17 @@ module.exports = React.createClass({ ConferenceHandler: React.PropTypes.any }, + /* properties in RoomView objects include: + * + * savedScrollState: the current scroll position in the backlog. Response + * from _calculateScrollState. Updated on scroll events. + * + * savedSearchScrollState: similar to savedScrollState, but specific to the + * search results (we need to preserve savedScrollState when search + * results are visible) + * + * eventNodes: a map from event id to DOM node representing that event + */ getInitialState: function() { var room = this.props.roomId ? MatrixClientPeg.get().getRoom(this.props.roomId) : null; return { @@ -207,7 +218,7 @@ module.exports = React.createClass({ if (!toStartOfTimeline && (ev.getSender() !== MatrixClientPeg.get().credentials.userId)) { // update unread count when scrolled up - if (this.savedScrollState.atBottom) { + if (!this.state.searchResults && this.savedScrollState.atBottom) { currentUnread = 0; } else { @@ -331,9 +342,6 @@ module.exports = React.createClass({ // after adding event tiles, we may need to tweak the scroll (either to // keep at the bottom of the timeline, or to maintain the view after // adding events to the top). - - if (this.state.searchResults) return; - this._restoreSavedScrollState(); }, @@ -441,11 +449,16 @@ module.exports = React.createClass({ this.recentEventScroll = undefined; } - if (this.refs.messagePanel && !this.state.searchResults) { - this.savedScrollState = this._calculateScrollState(); - if (DEBUG_SCROLL) console.log("Saved scroll state", this.savedScrollState); - if (this.savedScrollState.atBottom && this.state.numUnreadMessages != 0) { - this.setState({numUnreadMessages: 0}); + if (this.refs.messagePanel) { + if (this.state.searchResults) { + this.savedSearchScrollState = this._calculateScrollState(); + if (DEBUG_SCROLL) console.log("Saved search scroll state", this.savedSearchScrollState); + } else { + this.savedScrollState = this._calculateScrollState(); + if (DEBUG_SCROLL) console.log("Saved scroll state", this.savedScrollState); + if (this.savedScrollState.atBottom && this.state.numUnreadMessages != 0) { + this.setState({numUnreadMessages: 0}); + } } } if (!this.state.paginating && !this.state.searchInProgress) { @@ -510,6 +523,7 @@ module.exports = React.createClass({ searchCount: null, }); + this.savedSearchScrollState = {atBottom: true}; this.nextSearchBatch = null; this._getSearchBatch(term, scope); }, @@ -629,10 +643,18 @@ module.exports = React.createClass({ var result = this.state.searchResults[i]; var mxEv = new Matrix.MatrixEvent(result.result); + if (!EventTile.haveTileForEvent(mxEv)) { + // XXX: can this ever happen? It will make the result count + // not match the displayed count. + continue; + } + + var eventId = mxEv.getId(); + if (self.state.searchScope === 'All') { var roomId = result.result.room_id; if(roomId != lastRoomId) { - ret.push(
  • Room: { cli.getRoom(roomId).name }

  • ); + ret.push(
  • Room: { cli.getRoom(roomId).name }

  • ); lastRoomId = roomId; } } @@ -643,18 +665,16 @@ module.exports = React.createClass({ if (result.context.events_before[0]) { var mxEv2 = new Matrix.MatrixEvent(result.context.events_before[0]); if (EventTile.haveTileForEvent(mxEv2)) { - ret.push(
  • ); + ret.push(
  • ); } } - if (EventTile.haveTileForEvent(mxEv)) { - ret.push(
  • ); - } + ret.push(
  • ); if (result.context.events_after[0]) { var mxEv2 = new Matrix.MatrixEvent(result.context.events_after[0]); if (EventTile.haveTileForEvent(mxEv2)) { - ret.push(
  • ); + ret.push(
  • ); } } } @@ -706,15 +726,17 @@ module.exports = React.createClass({ continuation = false; } + var eventId = mxEv.getId(); ret.unshift( -
  • +
  • + +
  • ); if (dateSeparator) { ret.unshift(dateSeparator); } ++count; } - this.lastEventTileCount = count; return ret; }, @@ -884,7 +906,7 @@ module.exports = React.createClass({ }, onCancelClick: function() { - this.setState(this.getInitialState()); + this.setState({editingRoomSettings: false}); }, onLeaveClick: function() { @@ -918,6 +940,13 @@ module.exports = React.createClass({ this.setState({ searching: true }); }, + onCancelSearchClick: function () { + this.setState({ + searching: false, + searchResults: null, + }); + }, + onConferenceNotificationClick: function() { dis.dispatch({ action: 'place_call', @@ -945,12 +974,6 @@ module.exports = React.createClass({ // pixel_offset gives the number of pixels between the bottom of the event // and the bottom of the container. scrollToEvent: function(eventId, pixelOffset) { - var scrollNode = this._getScrollNode(); - if (!scrollNode) return; - - var messageWrapper = this.refs.messagePanel; - if (messageWrapper === undefined) return; - var idx = this._indexForEventId(eventId); if (idx === null) { // we don't seem to have this event in our timeline. Presumably @@ -960,7 +983,7 @@ module.exports = React.createClass({ // // for now, just scroll to the top of the buffer. console.log("Refusing to scroll to unknown event "+eventId); - scrollNode.scrollTop = 0; + this._getScrollNode().scrollTop = 0; return; } @@ -978,14 +1001,88 @@ module.exports = React.createClass({ this.setState({messageCap: minCap}); } - var node = this.eventNodes[eventId]; - if (node === null) { - // getEventTiles should have sorted this out when we set the - // messageCap, so this is weird. - console.error("No node for event, even after rolling back messageCap"); + // the scrollTokens on our DOM nodes are the event IDs, so we can pass + // eventId directly into _scrollToToken. + this._scrollToToken(eventId, pixelOffset); + }, + + _restoreSavedScrollState: function() { + var scrollState = this.state.searchResults ? this.savedSearchScrollState : this.savedScrollState; + if (!scrollState || scrollState.atBottom) { + this.scrollToBottom(); + } else if (scrollState.lastDisplayedScrollToken) { + this._scrollToToken(scrollState.lastDisplayedScrollToken, + scrollState.pixelOffset); + } + }, + + _calculateScrollState: function() { + // we don't save the absolute scroll offset, because that + // would be affected by window width, zoom level, amount of scrollback, + // etc. + // + // instead we save an identifier for the last fully-visible message, + // and the number of pixels the window was scrolled below it - which + // will hopefully be near enough. + // + // Our scroll implementation is agnostic of the precise contents of the + // message list (since it needs to work with both search results and + // timelines). 'refs.messageList' is expected to be a DOM node with a + // number of children, each of which may have a 'data-scroll-token' + // attribute. It is this token which is stored as the + // 'lastDisplayedScrollToken'. + + var messageWrapperScroll = this._getScrollNode(); + // + 1 here to avoid fractional pixel rounding errors + var atBottom = messageWrapperScroll.scrollHeight - messageWrapperScroll.scrollTop <= messageWrapperScroll.clientHeight + 1; + + var messageWrapper = this.refs.messagePanel; + var wrapperRect = ReactDOM.findDOMNode(messageWrapper).getBoundingClientRect(); + var messages = this.refs.messageList.children; + + for (var i = messages.length-1; i >= 0; --i) { + var node = messages[i]; + if (!node.dataset.scrollToken) continue; + + var boundingRect = node.getBoundingClientRect(); + if (boundingRect.bottom < wrapperRect.bottom) { + return { + atBottom: atBottom, + lastDisplayedScrollToken: node.dataset.scrollToken, + pixelOffset: wrapperRect.bottom - boundingRect.bottom, + } + } + } + + // apparently the entire timeline is below the viewport. Give up. + return { atBottom: true }; + }, + + // scroll the message list to the node with the given scrollToken. See + // notes in _calculateScrollState on how this works. + // + // pixel_offset gives the number of pixels between the bottom of the node + // and the bottom of the container. + _scrollToToken: function(scrollToken, pixelOffset) { + /* find the dom node with the right scrolltoken */ + var node; + var messages = this.refs.messageList.children; + for (var i = messages.length-1; i >= 0; --i) { + var m = messages[i]; + if (!m.dataset.scrollToken) continue; + if (m.dataset.scrollToken == scrollToken) { + node = m; + break; + } + } + + if (!node) { + console.error("No node with scrollToken '"+scrollToken+"'"); return; } + var scrollNode = this._getScrollNode(); + var messageWrapper = this.refs.messagePanel; var wrapperRect = ReactDOM.findDOMNode(messageWrapper).getBoundingClientRect(); var boundingRect = node.getBoundingClientRect(); var scrollDelta = boundingRect.bottom + pixelOffset - wrapperRect.bottom; @@ -997,59 +1094,11 @@ module.exports = React.createClass({ } if (DEBUG_SCROLL) { - console.log("Scrolled to event", eventId, "+", pixelOffset+":", scrollNode.scrollTop, "(delta: "+scrollDelta+")"); + console.log("Scrolled to token", node.dataset.scrollToken, "+", pixelOffset+":", scrollNode.scrollTop, "(delta: "+scrollDelta+")"); console.log("recentEventScroll now "+this.recentEventScroll); } }, - _restoreSavedScrollState: function() { - var scrollState = this.savedScrollState; - if (scrollState.atBottom) { - this.scrollToBottom(); - } else if (scrollState.lastDisplayedEvent) { - this.scrollToEvent(scrollState.lastDisplayedEvent, - scrollState.pixelOffset); - } - }, - - _calculateScrollState: function() { - // we don't save the absolute scroll offset, because that - // would be affected by window width, zoom level, amount of scrollback, - // etc. - // - // instead we save the id of the last fully-visible event, and the - // number of pixels the window was scrolled below it - which will - // hopefully be near enough. - // - if (this.eventNodes === undefined) return null; - - var messageWrapper = this.refs.messagePanel; - if (messageWrapper === undefined) return null; - var wrapperRect = ReactDOM.findDOMNode(messageWrapper).getBoundingClientRect(); - - var messageWrapperScroll = this._getScrollNode(); - // + 1 here to avoid fractional pixel rounding errors - var atBottom = messageWrapperScroll.scrollHeight - messageWrapperScroll.scrollTop <= messageWrapperScroll.clientHeight + 1; - - for (var i = this.state.room.timeline.length-1; i >= 0; --i) { - var ev = this.state.room.timeline[i]; - var node = this.eventNodes[ev.getId()]; - if (!node) continue; - - var boundingRect = node.getBoundingClientRect(); - if (boundingRect.bottom < wrapperRect.bottom) { - return { - atBottom: atBottom, - lastDisplayedEvent: ev.getId(), - pixelOffset: wrapperRect.bottom - boundingRect.bottom, - } - } - } - - // apparently the entire timeline is below the viewport. Give up. - return { atBottom: true }; - }, - // get the current scroll position of the room, so that it can be // restored when we switch back to it getScrollState: function() { @@ -1057,11 +1106,17 @@ module.exports = React.createClass({ }, restoreScrollState: function(scrollState) { + if (!this.refs.messagePanel) return; + if(scrollState.atBottom) { // we were at the bottom before. Ideally we'd scroll to the // 'read-up-to' mark here. - } else if (scrollState.lastDisplayedEvent) { - this.scrollToEvent(scrollState.lastDisplayedEvent, + } else if (scrollState.lastDisplayedScrollToken) { + // we might need to backfill, so we call scrollToEvent rather than + // _scrollToToken here. The scrollTokens on our DOM nodes are the + // event IDs, so lastDisplayedScrollToken will be the event ID we need, + // and we can pass it directly into scrollToEvent. + this.scrollToEvent(scrollState.lastDisplayedScrollToken, scrollState.pixelOffset); } }, @@ -1257,7 +1312,7 @@ module.exports = React.createClass({ aux = ; } else if (this.state.searching) { - aux = ; + aux = ; } var conferenceCallNotification = null; @@ -1349,7 +1404,7 @@ module.exports = React.createClass({
    -
      +
      1. {this.getEventTiles()} From 461e3f46dce30d3a122b77add900813010951e64 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Fri, 18 Dec 2015 16:56:37 +0000 Subject: [PATCH 14/17] Show an ErrorDialog when failing to forget a room --- src/components/structures/RoomView.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 66a60ba8e1..04aa81d3e1 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -871,7 +871,12 @@ module.exports = React.createClass({ MatrixClientPeg.get().forget(this.props.roomId).done(function() { dis.dispatch({ action: 'view_next_room' }); }, function(err) { - console.error("Failed to forget room: %s", err); + var errCode = err.errcode || "unknown error code"; + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Error", + description: `Failed to forget room (${errCode})` + }); }); }, From f0ff62166b3620de2f24bafdd0ca510085f87e3b Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Fri, 18 Dec 2015 17:13:26 +0000 Subject: [PATCH 15/17] Remove bottommost prop - can't DND on the bottom list anymore --- src/components/views/rooms/RoomList.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index f1d467f5ab..6a19e21d27 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -415,7 +415,6 @@ module.exports = React.createClass({ verb="demote" editable={ true } order="recent" - bottommost={ false } activityMap={ self.state.activityMap } selectedRoom={ self.props.selectedRoom } incomingCall={ self.state.incomingCall } @@ -425,7 +424,6 @@ module.exports = React.createClass({ label="Historical" editable={ false } order="recent" - bottommost={ true } activityMap={ self.state.activityMap } selectedRoom={ self.props.selectedRoom } collapsed={ self.props.collapsed } From fa99c1fc598c0e18aeab1a852d0c540d233be8e4 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Fri, 18 Dec 2015 17:18:08 +0000 Subject: [PATCH 16/17] make it clear result count is approx --- src/components/views/rooms/RoomHeader.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/rooms/RoomHeader.js b/src/components/views/rooms/RoomHeader.js index 1e287ac7dc..0ef6a3becb 100644 --- a/src/components/views/rooms/RoomHeader.js +++ b/src/components/views/rooms/RoomHeader.js @@ -106,7 +106,7 @@ module.exports = React.createClass({ // don't display the search count until the search completes and // gives us a non-null searchCount. if (this.props.searchInfo && this.props.searchInfo.searchCount !== null) { - searchStatus =
         ({ this.props.searchInfo.searchCount } results)
        ; + searchStatus =
         (~{ this.props.searchInfo.searchCount } results)
        ; } name = From d1baf5854cf2b16162e63db5ec6ff15ea2f505fc Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Fri, 18 Dec 2015 17:23:46 +0000 Subject: [PATCH 17/17] Only display the MessageComposer if you're joined and not viewing search results --- src/components/structures/RoomView.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 683076d932..fc8eed490c 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -1279,16 +1279,23 @@ module.exports = React.createClass({ } var messageComposer, searchInfo; - if (!this.state.searchResults) { + var canSpeak = ( + // joined and not showing search results + myMember && (myMember.membership == 'join') && !this.state.searchResults + ); + if (canSpeak) { messageComposer = } - else { + + // TODO: Why aren't we storing the term/scope/count in this format + // in this.state if this is what RoomHeader desires? + if (this.state.searchResults) { searchInfo = { searchTerm : this.state.searchTerm, searchScope : this.state.searchScope, searchCount : this.state.searchCount, - } + }; } var call = CallHandler.getCallForRoom(this.props.roomId);