diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 04a567d18f..5cfe456692 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -81,6 +81,9 @@ module.exports = React.createClass({ this.startMatrixClient(); } this.focusComposer = false; + // scrollStateMap is a map from room id to the scroll state returned by + // RoomView.getScrollState() + this.scrollStateMap = {}; document.addEventListener("keydown", this.onKeyDown); window.addEventListener("focus", this.onFocus); if (this.state.logged_in) { @@ -233,27 +236,7 @@ module.exports = React.createClass({ }); break; case 'view_room': - this.focusComposer = true; - var newState = { - currentRoom: payload.room_id, - page_type: this.PageTypes.RoomView, - }; - if (this.sdkReady) { - // if the SDK is not ready yet, remember what room - // we're supposed to be on but don't notify about - // the new screen yet (we won't be showing it yet) - // The normal case where this happens is navigating - // to the room in the URL bar on page load. - var presentedId = payload.room_id; - var room = MatrixClientPeg.get().getRoom(payload.room_id); - if (room) { - var theAlias = MatrixTools.getCanonicalAliasForRoom(room); - if (theAlias) presentedId = theAlias; - } - this.notifyNewScreen('room/'+presentedId); - newState.ready = true; - } - this.setState(newState); + this._viewRoom(payload.room_id); break; case 'view_prev_room': roomIndexDelta = -1; @@ -270,11 +253,7 @@ module.exports = React.createClass({ } roomIndex = (roomIndex + roomIndexDelta) % allRooms.length; if (roomIndex < 0) roomIndex = allRooms.length - 1; - this.focusComposer = true; - this.setState({ - currentRoom: allRooms[roomIndex].roomId - }); - this.notifyNewScreen('room/'+allRooms[roomIndex].roomId); + this._viewRoom(allRooms[roomIndex].roomId); break; case 'view_indexed_room': var allRooms = RoomListSorter.mostRecentActivityFirst( @@ -282,11 +261,7 @@ module.exports = React.createClass({ ); var roomIndex = payload.roomIndex; if (allRooms[roomIndex]) { - this.focusComposer = true; - this.setState({ - currentRoom: allRooms[roomIndex].roomId - }); - this.notifyNewScreen('room/'+allRooms[roomIndex].roomId); + this._viewRoom(allRooms[roomIndex].roomId); } break; case 'view_room_alias': @@ -310,21 +285,15 @@ module.exports = React.createClass({ }); break; case 'view_user_settings': - this.setState({ - page_type: this.PageTypes.UserSettings, - }); + this._setPage(this.PageTypes.UserSettings); this.notifyNewScreen('settings'); break; case 'view_create_room': - this.setState({ - page_type: this.PageTypes.CreateRoom, - }); + this._setPage(this.PageTypes.CreateRoom); this.notifyNewScreen('new'); break; case 'view_room_directory': - this.setState({ - page_type: this.PageTypes.RoomDirectory, - }); + this._setPage(this.PageTypes.RoomDirectory); this.notifyNewScreen('directory'); break; case 'notifier_enabled': @@ -353,6 +322,58 @@ module.exports = React.createClass({ } }, + _setPage: function(pageType) { + // record the scroll state if we're in a room view. + this._updateScrollMap(); + + this.setState({ + page_type: this.PageTypes.RoomDirectory, + }); + }, + + _viewRoom: function(roomId) { + // before we switch room, record the scroll state of the current room + this._updateScrollMap(); + + this.focusComposer = true; + var newState = { + currentRoom: roomId, + page_type: this.PageTypes.RoomView, + }; + if (this.sdkReady) { + // if the SDK is not ready yet, remember what room + // we're supposed to be on but don't notify about + // the new screen yet (we won't be showing it yet) + // The normal case where this happens is navigating + // to the room in the URL bar on page load. + var presentedId = roomId; + var room = MatrixClientPeg.get().getRoom(roomId); + if (room) { + var theAlias = MatrixTools.getCanonicalAliasForRoom(room); + if (theAlias) presentedId = theAlias; + } + this.notifyNewScreen('room/'+presentedId); + newState.ready = true; + } + this.setState(newState); + if (this.scrollStateMap[roomId]) { + var scrollState = this.scrollStateMap[roomId]; + this.refs.roomView.restoreScrollState(scrollState); + } + }, + + // update scrollStateMap according to the current scroll state of the + // room view. + _updateScrollMap: function() { + if (!this.refs.roomView) { + return; + } + + var roomview = this.refs.roomView; + var state = roomview.getScrollState(); + this.scrollStateMap[roomview.props.roomId] = state; + }, + onLoggedIn: function(credentials) { console.log("onLoggedIn => %s", credentials.userId); MatrixClientPeg.replaceUsingAccessToken( @@ -621,6 +642,7 @@ module.exports = React.createClass({ case this.PageTypes.RoomView: page_element = ( diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 5a87508d68..8a865710fa 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -70,7 +70,7 @@ module.exports = React.createClass({ MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping); MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember); MatrixClientPeg.get().on("sync", this.onSyncStateChange); - this.atBottom = true; + this.savedScrollState = {atBottom: true}; }, componentWillUnmount: function() { @@ -175,7 +175,7 @@ module.exports = React.createClass({ if (!toStartOfTimeline && (ev.getSender() !== MatrixClientPeg.get().credentials.userId)) { // update unread count when scrolled up - if (this.atBottom) { + if (this.savedScrollState.atBottom) { currentUnread = 0; } else { @@ -183,15 +183,10 @@ module.exports = React.createClass({ } } - this.setState({ room: MatrixClientPeg.get().getRoom(this.props.roomId), numUnreadMessages: currentUnread }); - - if (toStartOfTimeline && !this.state.paginating) { - this.fillSpace(); - } }, onRoomName: function(room) { @@ -285,55 +280,58 @@ module.exports = React.createClass({ }, componentDidUpdate: function() { + // 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; - var messageWrapperScroll = this._getScrollNode(); - - if (this.state.paginating && !this.waiting_for_paginate) { - var heightGained = messageWrapperScroll.scrollHeight - this.oldScrollHeight; - messageWrapperScroll.scrollTop += heightGained; - this.oldScrollHeight = undefined; - if (!this.fillSpace()) { - this.setState({paginating: false}); - } - } else if (this.atBottom) { - messageWrapperScroll.scrollTop = messageWrapperScroll.scrollHeight; - if (this.state.numUnreadMessages !== 0) { - this.setState({numUnreadMessages: 0}); - } + if (this.state.searchResults) return; + var scrollState = this.savedScrollState; + if (scrollState.atBottom) { + this.scrollToBottom(); + } else if (scrollState.lastDisplayedEvent) { + this.scrollToEvent(scrollState.lastDisplayedEvent, + scrollState.pixelOffset); } }, + _paginateCompleted: function() { + this.setState({ + room: MatrixClientPeg.get().getRoom(this.props.roomId) + }); + + // 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}); + } + }, + + // 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) { - this.setState({paginating: true}); + // 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) { - this.waiting_for_paginate = false; var cap = Math.min(this.state.messageCap + PAGINATE_SIZE, this.state.room.timeline.length); - this.setState({messageCap: cap, paginating: true}); + this.setState({messageCap: cap}); } else { - this.waiting_for_paginate = true; var cap = this.state.messageCap + PAGINATE_SIZE; this.setState({messageCap: cap, paginating: true}); - var self = this; - MatrixClientPeg.get().scrollback(this.state.room, PAGINATE_SIZE).finally(function() { - self.waiting_for_paginate = false; - if (self.isMounted()) { - self.setState({ - room: MatrixClientPeg.get().getRoom(self.props.roomId) - }); - } - // wait and set paginating to false when the component updates - }); + MatrixClientPeg.get().scrollback(this.state.room, PAGINATE_SIZE).finally(this._paginateCompleted).done(); + return true; } - - return true; } return false; }, @@ -364,13 +362,10 @@ module.exports = React.createClass({ }, onMessageListScroll: function(ev) { - if (this.refs.messagePanel) { - var messageWrapperScroll = this._getScrollNode(); - var wasAtBottom = this.atBottom; - // + 1 here to avoid fractional pixel rounding errors - this.atBottom = messageWrapperScroll.scrollHeight - messageWrapperScroll.scrollTop <= messageWrapperScroll.clientHeight + 1; - if (this.atBottom && !wasAtBottom) { - this.forceUpdate(); // remove unread msg count + if (this.refs.messagePanel && !this.state.searchResults) { + this.savedScrollState = this._calculateScrollState(); + if (this.savedScrollState.atBottom && this.state.numUnreadMessages != 0) { + this.setState({numUnreadMessages: 0}); } } if (!this.state.paginating) this.fillSpace(); @@ -845,6 +840,111 @@ module.exports = React.createClass({ scrollNode.scrollTop = scrollNode.scrollHeight; }, + // scroll the event view to put the given event at the bottom. + // + // 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 + // it's fallen out of scrollback. We ought to backfill until we + // find it, but we'd have to be careful we didn't backfill forever + // looking for a non-existent event. + // + // for now, just scroll to the top of the buffer. + console.log("Refusing to scroll to unknown event "+eventId); + scrollNode.scrollTop = 0; + return; + } + + // we might need to roll back the messagecap (to generate tiles for + // older messages). This just means telling getEventTiles to create + // tiles for events we already have in our timeline (we already know + // the event in question is in our timeline, so we shouldn't need to + // backfill). + // + // we actually wind back slightly further than the event in question, + // because we want the event to be at the *bottom* of the container. + // Don't roll it back past the timeline we have, though. + var minCap = this.state.room.timeline.length - Math.min(idx - INITIAL_SIZE, 0); + if (minCap > this.state.messageCap) { + 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"); + return; + } + + var wrapperRect = ReactDOM.findDOMNode(messageWrapper).getBoundingClientRect(); + var boundingRect = node.getBoundingClientRect(); + scrollNode.scrollTop += boundingRect.bottom + pixelOffset - wrapperRect.bottom; + }, + + _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() { + return this.savedScrollState; + }, + + restoreScrollState: function(scrollState) { + 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, + scrollState.pixelOffset); + } + }, + onResize: function(e) { // It seems flexbox doesn't give us a way to constrain the auxPanel height to have // a minimum of the height of the video element, whilst also capping it from pushing out the page