From cdd539c3cd57ef4f31356bf4b3d1f9626e1c5a1f Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Tue, 22 Dec 2015 15:18:50 +0000 Subject: [PATCH] Factor out a separate 'ScrollPanel' Create an intelligent scrolling list, which doesn't care what it contains, to try and clean up some of the logic in RoomView. --- src/component-index.js | 1 + src/components/structures/RoomView.js | 422 ++++++++--------------- src/components/structures/ScrollPanel.js | 283 +++++++++++++++ 3 files changed, 433 insertions(+), 273 deletions(-) create mode 100644 src/components/structures/ScrollPanel.js diff --git a/src/component-index.js b/src/component-index.js index 2f89e6d94b..e7a74e0351 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -28,6 +28,7 @@ module.exports.components['structures.login.PostRegistration'] = require('./comp module.exports.components['structures.login.Registration'] = require('./components/structures/login/Registration'); module.exports.components['structures.MatrixChat'] = require('./components/structures/MatrixChat'); module.exports.components['structures.RoomView'] = require('./components/structures/RoomView'); +module.exports.components['structures.ScrollPanel'] = require('./components/structures/ScrollPanel'); module.exports.components['structures.UploadBar'] = require('./components/structures/UploadBar'); module.exports.components['structures.UserSettings'] = require('./components/structures/UserSettings'); module.exports.components['views.avatars.MemberAvatar'] = require('./components/views/avatars/MemberAvatar'); diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 8496d12fe2..29e2d3d4ff 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -23,7 +23,6 @@ limitations under the License. var React = require("react"); var ReactDOM = require("react-dom"); -var GeminiScrollbar = require('react-gemini-scrollbar'); var q = require("q"); var classNames = require("classnames"); var Matrix = require("matrix-js-sdk"); @@ -49,13 +48,6 @@ module.exports = React.createClass({ }, /* 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 */ @@ -84,7 +76,6 @@ 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.savedScrollState = {atBottom: true}; }, componentWillUnmount: function() { @@ -168,23 +159,6 @@ module.exports = React.createClass({ } }, - // get the DOM node which has the scrollTop property we care about for our - // message panel. - // - // If the gemini scrollbar is doing its thing, this will be a div within - // the message panel (ie, the gemini container); otherwise it will be the - // message panel itself. - _getScrollNode: function() { - var panel = ReactDOM.findDOMNode(this.refs.messagePanel); - if (!panel) return null; - - if (panel.classList.contains('gm-prevented')) { - return panel; - } else { - return panel.children[2]; // XXX: Fragile! - } - }, - onSyncStateChange: function(state, prevState) { if (state === "SYNCING" && prevState === "SYNCING") { return; @@ -218,7 +192,7 @@ module.exports = React.createClass({ if (!toStartOfTimeline && (ev.getSender() !== MatrixClientPeg.get().credentials.userId)) { // update unread count when scrolled up - if (!this.state.searchResults && this.savedScrollState.atBottom) { + if (!this.state.searchResults && this.refs.messagePanel && this.refs.messagePanel.isAtBottom()) { currentUnread = 0; } else { @@ -326,7 +300,8 @@ module.exports = React.createClass({ this.scrollToBottom(); this.sendReadReceipt(); - this.fillSpace(); + + this.refs.messagePanel.checkFillState(); }, componentDidUpdate: function() { @@ -338,11 +313,6 @@ module.exports = React.createClass({ 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). - this._restoreSavedScrollState(); }, _paginateCompleted: function() { @@ -352,37 +322,32 @@ module.exports = React.createClass({ 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. this.setState({paginating: false}); - if (!this.state.searchResults) { - this.fillSpace(); + // we might not have got enough (or, indeed, any) results from the + // pagination request, so give the messagePanel a chance to set off + // another. + + this.refs.messagePanel.checkFillState(); + }, + + onSearchResultsFillRequest: function(backwards) { + if (!backwards || this.state.searchInProgress) + return; + + 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"); } }, - // check the scroll position, and if we need to, set off a pagination - // request. - fillSpace: function() { - if (!this.refs.messagePanel) return; - var messageWrapperScroll = this._getScrollNode(); - if (messageWrapperScroll.scrollTop > messageWrapperScroll.clientHeight) { + // set off a pagination request. + onMessageListFillRequest: function(backwards) { + if (!backwards || this.state.paginating) 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. @@ -431,44 +396,9 @@ module.exports = React.createClass({ }, onMessageListScroll: function(ev) { - var sn = this._getScrollNode(); - if (DEBUG_SCROLL) console.log("Scroll event: offset now:", sn.scrollTop, "recentEventScroll:", this.recentEventScroll); - - // Sometimes we see attempts to write to scrollTop essentially being - // ignored. (Or rather, it is successfully written, but on the next - // scroll event, it's been reset again). - // - // This was observed on Chrome 47, when scrolling using the trackpad in OS - // X Yosemite. Can't reproduce on El Capitan. Our theory is that this is - // due to Chrome not being able to cope with the scroll offset being reset - // while a two-finger drag is in progress. - // - // By way of a workaround, we detect this situation and just keep - // resetting scrollTop until we see the scroll node have the right - // value. - if (this.recentEventScroll !== undefined) { - if(sn.scrollTop < this.recentEventScroll-200) { - console.log("Working around vector-im/vector-web#528"); - this._restoreSavedScrollState(); - return; - } - this.recentEventScroll = undefined; - } - - 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) { - this.fillSpace(); + if (this.state.numUnreadMessages != 0 && + this.refs.messagePanel.isAtBottom()) { + this.setState({numUnreadMessages: 0}); } }, @@ -530,7 +460,12 @@ module.exports = React.createClass({ searchCanPaginate: null, }); - this.savedSearchScrollState = {atBottom: true}; + // if we already have a search panel, we need to tell it to forget + // about its scroll state. + if (this.refs.searchResultsPanel) { + this.refs.searchResultsPanel.resetScrollState(); + } + this.nextSearchBatch = null; this._getSearchBatch(term, scope); }, @@ -630,78 +565,83 @@ module.exports = React.createClass({ } }, - getEventTiles: function() { + getSearchResultTiles: function() { var DateSeparator = sdk.getComponent('messages.DateSeparator'); var cli = MatrixClientPeg.get(); + var ret = []; + + var EventTile = sdk.getComponent('rooms.EventTile'); + + // XXX: todo: merge overlapping results somehow? + // XXX: why doesn't searching on name work? + + + if (this.state.searchCanPaginate === false) { + if (this.state.searchResults.length == 0) { + ret.push(
  • +

    No results

    +
  • + ); + } else { + ret.push(
  • +

    No more results

    +
  • + ); + } + } + + var lastRoomId; + + for (var i = this.state.searchResults.length - 1; i >= 0; i--) { + 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 (this.state.searchScope === 'All') { + var roomId = result.result.room_id; + if(roomId != lastRoomId) { + ret.push(
  • Room: { cli.getRoom(roomId).name }

  • ); + lastRoomId = roomId; + } + } + + 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(
  • ); + } + } + + 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; + }, + + getEventTiles: function() { + var DateSeparator = sdk.getComponent('messages.DateSeparator'); + var ret = []; var count = 0; var EventTile = sdk.getComponent('rooms.EventTile'); - var self = this; - - if (this.state.searchResults) - { - // XXX: todo: merge overlapping results somehow? - // XXX: why doesn't searching on name work? - - var lastRoomId; - - if (this.state.searchCanPaginate === false) { - if (this.state.searchResults.length == 0) { - ret.push(
  • -

    No results

    -
  • - ); - } else { - ret.push(
  • -

    No more results

    -
  • - ); - } - } - - for (var i = this.state.searchResults.length - 1; i >= 0; i--) { - 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 }

  • ); - lastRoomId = roomId; - } - } - - 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(
  • ); - } - } - - 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; - } var prevEvent = null; // the last event we showed @@ -996,10 +936,9 @@ module.exports = React.createClass({ }, scrollToBottom: function() { - var scrollNode = this._getScrollNode(); - if (!scrollNode) return; - scrollNode.scrollTop = scrollNode.scrollHeight; - if (DEBUG_SCROLL) console.log("Scrolled to bottom; offset now", scrollNode.scrollTop); + var messagePanel = this.refs.messagePanel; + if (!messagePanel) return; + messagePanel.scrollToBottom(); }, // scroll the event view to put the given event at the bottom. @@ -1007,6 +946,9 @@ 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 messagePanel = this.refs.messagePanel; + if (!messagePanel) return; + var idx = this._indexForEventId(eventId); if (idx === null) { // we don't seem to have this event in our timeline. Presumably @@ -1016,7 +958,7 @@ module.exports = React.createClass({ // // for now, just scroll to the top of the buffer. console.log("Refusing to scroll to unknown event "+eventId); - this._getScrollNode().scrollTop = 0; + messagePanel.scrollToTop(); return; } @@ -1036,117 +978,30 @@ module.exports = React.createClass({ // 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; - if(scrollDelta != 0) { - scrollNode.scrollTop += scrollDelta; - - // see the comments in onMessageListScroll regarding recentEventScroll - this.recentEventScroll = scrollNode.scrollTop; - } - - if (DEBUG_SCROLL) { - console.log("Scrolled to token", node.dataset.scrollToken, "+", pixelOffset+":", scrollNode.scrollTop, "(delta: "+scrollDelta+")"); - console.log("recentEventScroll now "+this.recentEventScroll); - } + messagePanel.scrollToToken(eventId, pixelOffset); }, // 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; + var messagePanel = this.refs.messagePanel; + if (!messagePanel) return null; + + return messagePanel.getScrollState(); }, restoreScrollState: function(scrollState) { - if (!this.refs.messagePanel) return; + var messagePanel = this.refs.messagePanel; + if (!messagePanel) return null; if(scrollState.atBottom) { // we were at the bottom before. Ideally we'd scroll to the // 'read-up-to' mark here. + messagePanel.scrollToBottom(); + } 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 + // 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, @@ -1212,6 +1067,7 @@ module.exports = React.createClass({ var CallView = sdk.getComponent("voip.CallView"); var RoomSettings = sdk.getComponent("rooms.RoomSettings"); var SearchBar = sdk.getComponent("rooms.SearchBar"); + var ScrollPanel = sdk.getComponent("structures.ScrollPanel"); if (!this.state.room) { if (this.props.roomId) { @@ -1433,6 +1289,33 @@ module.exports = React.createClass({ } + + // if we have search results, we keep the messagepanel (so that it preserves its + // scroll state), but hide it. + var searchResultsPanel; + var hideMessagePanel = false; + + if (this.state.searchResults) { + searchResultsPanel = ( + +
  • + {this.getSearchResultTiles()} +
    + ); + hideMessagePanel = true; + } + + var messagePanel = ( + +
  • + {this.getEventTiles()} +
    + ); + return (
    - -
    -
      -
    1. -
    2. - {this.getEventTiles()} -
    -
    -
    + { messagePanel } + { searchResultsPanel }
    diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js new file mode 100644 index 0000000000..2c68562ada --- /dev/null +++ b/src/components/structures/ScrollPanel.js @@ -0,0 +1,283 @@ +/* +Copyright 2015 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +var React = require("react"); +var ReactDOM = require("react-dom"); +var GeminiScrollbar = require('react-gemini-scrollbar'); + +var DEBUG_SCROLL = false; + +/* This component implements an intelligent scrolling list. + * + * It wraps a list of
  • children; when items are added to the start or end + * of the list, the scroll position is updated so that the user still sees the + * same position in the list. + * + * It also provides a hook which allows parents to provide more list elements + * when we get close to the start or end of the list. + * + * 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 is hopefully be near enough. + * + * Each child element should have a 'data-scroll-token'. This token is used to + * serialise the scroll state, and returned as the 'lastDisplayedScrollToken' + * attribute by getScrollState(). + */ +module.exports = React.createClass({ + displayName: 'ScrollPanel', + + propTypes: { + /* stickyBottom: if set to true, then once the user hits the bottom of + * the list, any new children added to the list will cause the list to + * scroll down to show the new element, rather than preserving the + * existing view. + */ + stickyBottom: React.PropTypes.bool, + + /* onFillRequest(backwards): a callback which is called on scroll when + * the user nears the start (backwards = true) or end (backwards = + * false) of the list + */ + onFillRequest: React.PropTypes.func, + + /* onScroll: a callback which is called whenever any scroll happens. + */ + onScroll: React.PropTypes.func, + + /* className: classnames to add to the top-level div + */ + className: React.PropTypes.string, + + /* style: styles to add to the top-level div + */ + style: React.PropTypes.object, + }, + + getDefaultProps: function() { + return { + stickyBottom: true, + onFillRequest: function(backwards) {}, + onScroll: function() {}, + }; + }, + + componentWillMount: function() { + this.resetScrollState(); + }, + + 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). + this._restoreSavedScrollState(); + }, + + onScroll: function(ev) { + var sn = this._getScrollNode(); + if (DEBUG_SCROLL) console.log("Scroll event: offset now:", sn.scrollTop, "recentEventScroll:", this.recentEventScroll); + + // Sometimes we see attempts to write to scrollTop essentially being + // ignored. (Or rather, it is successfully written, but on the next + // scroll event, it's been reset again). + // + // This was observed on Chrome 47, when scrolling using the trackpad in OS + // X Yosemite. Can't reproduce on El Capitan. Our theory is that this is + // due to Chrome not being able to cope with the scroll offset being reset + // while a two-finger drag is in progress. + // + // By way of a workaround, we detect this situation and just keep + // resetting scrollTop until we see the scroll node have the right + // value. + if (this.recentEventScroll !== undefined) { + if(sn.scrollTop < this.recentEventScroll-200) { + console.log("Working around vector-im/vector-web#528"); + this._restoreSavedScrollState(); + return; + } + this.recentEventScroll = undefined; + } + + this.scrollState = this._calculateScrollState(); + if (DEBUG_SCROLL) console.log("Saved scroll state", this.scrollState); + + this.props.onScroll(ev); + + this.checkFillState(); + }, + + isAtBottom: function() { + return this.scrollState && this.scrollState.atBottom; + }, + + // check the scroll state and send out backfill requests if necessary. + checkFillState: function() { + var sn = this._getScrollNode(); + + if (sn.scrollTop < sn.clientHeight) { + // there's less than a screenful of messages left - try to get some + // more messages. + this.props.onFillRequest(true); + } + }, + + // get the current scroll position of the room, so that it can be + // restored later + getScrollState: function() { + return this.scrollState; + }, + + /* reset the saved scroll state. + * + * This will cause the scroll to be reinitialised on the next update of the + * child list. + * + * This is useful if the list is being replaced, and you don't want to + * preserve scroll even if new children happen to have the same scroll + * tokens as old ones. + */ + resetScrollState: function() { + this.scrollState = null; + }, + + scrollToTop: function() { + this._getScrollNode().scrollTop = 0; + if (DEBUG_SCROLL) console.log("Scrolled to top"); + }, + + scrollToBottom: function() { + var scrollNode = this._getScrollNode(); + scrollNode.scrollTop = scrollNode.scrollHeight; + if (DEBUG_SCROLL) console.log("Scrolled to bottom; offset now", scrollNode.scrollTop); + }, + + // 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.itemlist.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 wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect(); + var boundingRect = node.getBoundingClientRect(); + var scrollDelta = boundingRect.bottom + pixelOffset - wrapperRect.bottom; + if(scrollDelta != 0) { + scrollNode.scrollTop += scrollDelta; + + // see the comments in onMessageListScroll regarding recentEventScroll + this.recentEventScroll = scrollNode.scrollTop; + } + + if (DEBUG_SCROLL) { + console.log("Scrolled to token", node.dataset.scrollToken, "+", pixelOffset+":", scrollNode.scrollTop, "(delta: "+scrollDelta+")"); + console.log("recentEventScroll now "+this.recentEventScroll); + } + }, + + _calculateScrollState: function() { + // 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 sn = this._getScrollNode(); + // + 1 here to avoid fractional pixel rounding errors + var atBottom = sn.scrollHeight - sn.scrollTop <= sn.clientHeight + 1; + + var itemlist = this.refs.itemlist; + var wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect(); + var messages = itemlist.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 }; + }, + + _restoreSavedScrollState: function() { + var scrollState = this.scrollState; + if (!scrollState || (this.props.stickyBottom && scrollState.atBottom)) { + this.scrollToBottom(); + } else if (scrollState.lastDisplayedScrollToken) { + this.scrollToToken(scrollState.lastDisplayedScrollToken, + scrollState.pixelOffset); + } + }, + + /* get the DOM node which has the scrollTop property we care about for our + * message panel. + */ + _getScrollNode: function() { + var panel = ReactDOM.findDOMNode(this.refs.geminiPanel); + + // If the gemini scrollbar is doing its thing, this will be a div within + // the message panel (ie, the gemini container); otherwise it will be the + // message panel itself. + + if (panel.classList.contains('gm-prevented')) { + return panel; + } else { + return panel.children[2]; // XXX: Fragile! + } + }, + + render: function() { + // TODO: the classnames on the div and ol could do with being updated to + // reflect the fact that we don't necessarily contain a list of messages. + // it's not obvious why we have a separate div and ol anyway. + return ( +
    +
      + {this.props.children} +
    +
    +
    + ); + }, +});