From 00656fc1dcf81f09c9a5ac0d6ab38b624a444515 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Thu, 10 Dec 2015 16:26:36 +0000 Subject: [PATCH] Preserve scroll offset when switching rooms When we change rooms, save the scroll offset, and restore the scroll when we switch back. Hopefully this fixes https://github.com/vector-im/vector-web/issues/80. --- src/components/structures/MatrixChat.js | 81 +++++++++++++--------- src/components/structures/RoomView.js | 90 +++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 31 deletions(-) diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 25f25e919e..1a522da361 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -80,6 +80,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) { @@ -202,27 +205,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; @@ -239,11 +222,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( @@ -251,11 +230,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': @@ -322,6 +297,49 @@ module.exports = React.createClass({ } }, + _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( @@ -590,6 +608,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 176d61b42e..46258f9580 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -803,6 +803,96 @@ 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(event_id, pixel_offset) { + var scrollNode = this._getScrollNode(); + if (!scrollNode) return; + + var messageWrapper = this.refs.messagePanel; + if (messageWrapper === undefined) return; + + var idx = this._indexForEventId(event_id); + 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 "+event_id); + scrollNode.scrollTop = 0; + return; + } + + // we might need to roll back the messagecap (to generate tiles for + // older messages). 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[event_id]; + 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 + pixel_offset - wrapperRect.bottom; + }, + + // get the current scroll position of the room, so that it can be + // restored when we switch back to it + getScrollState: 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(); + + 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: this.atBottom, + lastDisplayedEvent: ev.getId(), + pixelOffset: wrapperRect.bottom - boundingRect.bottom, + } + } + } + + // apparently the entire timeline is below the viewport. Give up. + return null; + }, + + 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.lastDisplayed) { + this.scrollToEvent(scrollState.lastDisplayedEvent, + scrollState.pixelOffset); + } + }, + render: function() { var RoomHeader = sdk.getComponent('rooms.RoomHeader'); var MessageComposer = sdk.getComponent('rooms.MessageComposer');