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 (
-
-
-
- -
-
- {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}
+
+
+
+ );
+ },
+});