Merge pull request #66 from matrix-org/rav/factor_out_scrollpanel

Factor out a separate 'ScrollPanel'
This commit is contained in:
Richard van der Hoff 2015-12-22 15:21:23 +00:00
commit 1de42f42e1
3 changed files with 433 additions and 273 deletions

View file

@ -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.login.Registration'] = require('./components/structures/login/Registration');
module.exports.components['structures.MatrixChat'] = require('./components/structures/MatrixChat'); module.exports.components['structures.MatrixChat'] = require('./components/structures/MatrixChat');
module.exports.components['structures.RoomView'] = require('./components/structures/RoomView'); 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.UploadBar'] = require('./components/structures/UploadBar');
module.exports.components['structures.UserSettings'] = require('./components/structures/UserSettings'); module.exports.components['structures.UserSettings'] = require('./components/structures/UserSettings');
module.exports.components['views.avatars.MemberAvatar'] = require('./components/views/avatars/MemberAvatar'); module.exports.components['views.avatars.MemberAvatar'] = require('./components/views/avatars/MemberAvatar');

View file

@ -23,7 +23,6 @@ limitations under the License.
var React = require("react"); var React = require("react");
var ReactDOM = require("react-dom"); var ReactDOM = require("react-dom");
var GeminiScrollbar = require('react-gemini-scrollbar');
var q = require("q"); var q = require("q");
var classNames = require("classnames"); var classNames = require("classnames");
var Matrix = require("matrix-js-sdk"); var Matrix = require("matrix-js-sdk");
@ -49,13 +48,6 @@ module.exports = React.createClass({
}, },
/* properties in RoomView objects include: /* 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 * 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("RoomMember.typing", this.onRoomMemberTyping);
MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember); MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember);
MatrixClientPeg.get().on("sync", this.onSyncStateChange); MatrixClientPeg.get().on("sync", this.onSyncStateChange);
this.savedScrollState = {atBottom: true};
}, },
componentWillUnmount: function() { 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) { onSyncStateChange: function(state, prevState) {
if (state === "SYNCING" && prevState === "SYNCING") { if (state === "SYNCING" && prevState === "SYNCING") {
return; return;
@ -218,7 +192,7 @@ module.exports = React.createClass({
if (!toStartOfTimeline && if (!toStartOfTimeline &&
(ev.getSender() !== MatrixClientPeg.get().credentials.userId)) { (ev.getSender() !== MatrixClientPeg.get().credentials.userId)) {
// update unread count when scrolled up // 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; currentUnread = 0;
} }
else { else {
@ -326,7 +300,8 @@ module.exports = React.createClass({
this.scrollToBottom(); this.scrollToBottom();
this.sendReadReceipt(); this.sendReadReceipt();
this.fillSpace();
this.refs.messagePanel.checkFillState();
}, },
componentDidUpdate: function() { componentDidUpdate: function() {
@ -338,11 +313,6 @@ module.exports = React.createClass({
if (!this.refs.messagePanel.initialised) { if (!this.refs.messagePanel.initialised) {
this._initialiseMessagePanel(); 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() { _paginateCompleted: function() {
@ -352,28 +322,19 @@ module.exports = React.createClass({
room: MatrixClientPeg.get().getRoom(this.props.roomId) 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}); this.setState({paginating: false});
if (!this.state.searchResults) { // we might not have got enough (or, indeed, any) results from the
this.fillSpace(); // pagination request, so give the messagePanel a chance to set off
} // another.
this.refs.messagePanel.checkFillState();
}, },
// check the scroll position, and if we need to, set off a pagination onSearchResultsFillRequest: function(backwards) {
// request. if (!backwards || this.state.searchInProgress)
fillSpace: function() {
if (!this.refs.messagePanel) return;
var messageWrapperScroll = this._getScrollNode();
if (messageWrapperScroll.scrollTop > messageWrapperScroll.clientHeight) {
return; return;
}
// there's less than a screenful of messages left - try to get some
// more messages.
if (this.state.searchResults) {
if (this.nextSearchBatch) { if (this.nextSearchBatch) {
if (DEBUG_SCROLL) console.log("requesting more search results"); if (DEBUG_SCROLL) console.log("requesting more search results");
this._getSearchBatch(this.state.searchTerm, this._getSearchBatch(this.state.searchTerm,
@ -381,8 +342,12 @@ module.exports = React.createClass({
} else { } else {
if (DEBUG_SCROLL) console.log("no more search results"); if (DEBUG_SCROLL) console.log("no more search results");
} }
},
// set off a pagination request.
onMessageListFillRequest: function(backwards) {
if (!backwards || this.state.paginating)
return; return;
}
// Either wind back the message cap (if there are enough events in the // Either wind back the message cap (if there are enough events in the
// timeline to do so), or fire off a pagination request. // timeline to do so), or fire off a pagination request.
@ -431,45 +396,10 @@ module.exports = React.createClass({
}, },
onMessageListScroll: function(ev) { onMessageListScroll: function(ev) {
var sn = this._getScrollNode(); if (this.state.numUnreadMessages != 0 &&
if (DEBUG_SCROLL) console.log("Scroll event: offset now:", sn.scrollTop, "recentEventScroll:", this.recentEventScroll); this.refs.messagePanel.isAtBottom()) {
// 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}); this.setState({numUnreadMessages: 0});
} }
}
}
if (!this.state.paginating && !this.state.searchInProgress) {
this.fillSpace();
}
}, },
onDragOver: function(ev) { onDragOver: function(ev) {
@ -530,7 +460,12 @@ module.exports = React.createClass({
searchCanPaginate: null, 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.nextSearchBatch = null;
this._getSearchBatch(term, scope); this._getSearchBatch(term, scope);
}, },
@ -630,22 +565,17 @@ module.exports = React.createClass({
} }
}, },
getEventTiles: function() { getSearchResultTiles: function() {
var DateSeparator = sdk.getComponent('messages.DateSeparator'); var DateSeparator = sdk.getComponent('messages.DateSeparator');
var cli = MatrixClientPeg.get(); var cli = MatrixClientPeg.get();
var ret = []; var ret = [];
var count = 0;
var EventTile = sdk.getComponent('rooms.EventTile'); var EventTile = sdk.getComponent('rooms.EventTile');
var self = this;
if (this.state.searchResults)
{
// XXX: todo: merge overlapping results somehow? // XXX: todo: merge overlapping results somehow?
// XXX: why doesn't searching on name work? // XXX: why doesn't searching on name work?
var lastRoomId;
if (this.state.searchCanPaginate === false) { if (this.state.searchCanPaginate === false) {
if (this.state.searchResults.length == 0) { if (this.state.searchResults.length == 0) {
@ -661,6 +591,8 @@ module.exports = React.createClass({
} }
} }
var lastRoomId;
for (var i = this.state.searchResults.length - 1; i >= 0; i--) { for (var i = this.state.searchResults.length - 1; i >= 0; i--) {
var result = this.state.searchResults[i]; var result = this.state.searchResults[i];
var mxEv = new Matrix.MatrixEvent(result.result); var mxEv = new Matrix.MatrixEvent(result.result);
@ -673,7 +605,7 @@ module.exports = React.createClass({
var eventId = mxEv.getId(); var eventId = mxEv.getId();
if (self.state.searchScope === 'All') { if (this.state.searchScope === 'All') {
var roomId = result.result.room_id; var roomId = result.result.room_id;
if(roomId != lastRoomId) { if(roomId != lastRoomId) {
ret.push(<li key={eventId + "-room"}><h1>Room: { cli.getRoom(roomId).name }</h1></li>); ret.push(<li key={eventId + "-room"}><h1>Room: { cli.getRoom(roomId).name }</h1></li>);
@ -691,7 +623,7 @@ module.exports = React.createClass({
} }
} }
ret.push(<li key={eventId+"+0"} data-scroll-token={eventId+"+0"}><EventTile mxEvent={mxEv} highlights={self.state.searchHighlights}/></li>); ret.push(<li key={eventId+"+0"} data-scroll-token={eventId+"+0"}><EventTile mxEvent={mxEv} highlights={this.state.searchHighlights}/></li>);
if (result.context.events_after[0]) { if (result.context.events_after[0]) {
var mxEv2 = new Matrix.MatrixEvent(result.context.events_after[0]); var mxEv2 = new Matrix.MatrixEvent(result.context.events_after[0]);
@ -701,7 +633,15 @@ module.exports = React.createClass({
} }
} }
return ret; return ret;
} },
getEventTiles: function() {
var DateSeparator = sdk.getComponent('messages.DateSeparator');
var ret = [];
var count = 0;
var EventTile = sdk.getComponent('rooms.EventTile');
var prevEvent = null; // the last event we showed var prevEvent = null; // the last event we showed
@ -996,10 +936,9 @@ module.exports = React.createClass({
}, },
scrollToBottom: function() { scrollToBottom: function() {
var scrollNode = this._getScrollNode(); var messagePanel = this.refs.messagePanel;
if (!scrollNode) return; if (!messagePanel) return;
scrollNode.scrollTop = scrollNode.scrollHeight; messagePanel.scrollToBottom();
if (DEBUG_SCROLL) console.log("Scrolled to bottom; offset now", scrollNode.scrollTop);
}, },
// scroll the event view to put the given event at the bottom. // 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 // pixel_offset gives the number of pixels between the bottom of the event
// and the bottom of the container. // and the bottom of the container.
scrollToEvent: function(eventId, pixelOffset) { scrollToEvent: function(eventId, pixelOffset) {
var messagePanel = this.refs.messagePanel;
if (!messagePanel) return;
var idx = this._indexForEventId(eventId); var idx = this._indexForEventId(eventId);
if (idx === null) { if (idx === null) {
// we don't seem to have this event in our timeline. Presumably // 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. // for now, just scroll to the top of the buffer.
console.log("Refusing to scroll to unknown event "+eventId); console.log("Refusing to scroll to unknown event "+eventId);
this._getScrollNode().scrollTop = 0; messagePanel.scrollToTop();
return; return;
} }
@ -1036,117 +978,30 @@ module.exports = React.createClass({
// the scrollTokens on our DOM nodes are the event IDs, so we can pass // the scrollTokens on our DOM nodes are the event IDs, so we can pass
// eventId directly into _scrollToToken. // eventId directly into _scrollToToken.
this._scrollToToken(eventId, pixelOffset); messagePanel.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);
}
}, },
// get the current scroll position of the room, so that it can be // get the current scroll position of the room, so that it can be
// restored when we switch back to it // restored when we switch back to it
getScrollState: function() { getScrollState: function() {
return this.savedScrollState; var messagePanel = this.refs.messagePanel;
if (!messagePanel) return null;
return messagePanel.getScrollState();
}, },
restoreScrollState: function(scrollState) { restoreScrollState: function(scrollState) {
if (!this.refs.messagePanel) return; var messagePanel = this.refs.messagePanel;
if (!messagePanel) return null;
if(scrollState.atBottom) { if(scrollState.atBottom) {
// we were at the bottom before. Ideally we'd scroll to the // we were at the bottom before. Ideally we'd scroll to the
// 'read-up-to' mark here. // 'read-up-to' mark here.
messagePanel.scrollToBottom();
} else if (scrollState.lastDisplayedScrollToken) { } else if (scrollState.lastDisplayedScrollToken) {
// we might need to backfill, so we call scrollToEvent rather than // 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, // event IDs, so lastDisplayedScrollToken will be the event ID we need,
// and we can pass it directly into scrollToEvent. // and we can pass it directly into scrollToEvent.
this.scrollToEvent(scrollState.lastDisplayedScrollToken, this.scrollToEvent(scrollState.lastDisplayedScrollToken,
@ -1212,6 +1067,7 @@ module.exports = React.createClass({
var CallView = sdk.getComponent("voip.CallView"); var CallView = sdk.getComponent("voip.CallView");
var RoomSettings = sdk.getComponent("rooms.RoomSettings"); var RoomSettings = sdk.getComponent("rooms.RoomSettings");
var SearchBar = sdk.getComponent("rooms.SearchBar"); var SearchBar = sdk.getComponent("rooms.SearchBar");
var ScrollPanel = sdk.getComponent("structures.ScrollPanel");
if (!this.state.room) { if (!this.state.room) {
if (this.props.roomId) { if (this.props.roomId) {
@ -1433,6 +1289,33 @@ module.exports = React.createClass({
</div> </div>
} }
// 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 = (
<ScrollPanel ref="searchResultsPanel" className="mx_RoomView_messagePanel"
onFillRequest={ this.onSearchResultsFillRequest }>
<li className={scrollheader_classes}></li>
{this.getSearchResultTiles()}
</ScrollPanel>
);
hideMessagePanel = true;
}
var messagePanel = (
<ScrollPanel ref="messagePanel" className="mx_RoomView_messagePanel"
onScroll={ this.onMessageListScroll }
onFillRequest={ this.onMessageListFillRequest }
style={ hideMessagePanel ? { display: 'none' } : {} } >
<li className={scrollheader_classes}></li>
{this.getEventTiles()}
</ScrollPanel>
);
return ( return (
<div className={ "mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "") }> <div className={ "mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "") }>
<RoomHeader ref="header" room={this.state.room} searchInfo={searchInfo} <RoomHeader ref="header" room={this.state.room} searchInfo={searchInfo}
@ -1453,15 +1336,8 @@ module.exports = React.createClass({
{ conferenceCallNotification } { conferenceCallNotification }
{ aux } { aux }
</div> </div>
<GeminiScrollbar autoshow={true} ref="messagePanel" className="mx_RoomView_messagePanel" onScroll={ this.onMessageListScroll }> { messagePanel }
<div className="mx_RoomView_messageListWrapper"> { searchResultsPanel }
<ol ref="messageList" className="mx_RoomView_MessageList" aria-live="polite">
<li className={scrollheader_classes}>
</li>
{this.getEventTiles()}
</ol>
</div>
</GeminiScrollbar>
<div className="mx_RoomView_statusArea"> <div className="mx_RoomView_statusArea">
<div className="mx_RoomView_statusAreaBox"> <div className="mx_RoomView_statusAreaBox">
<div className="mx_RoomView_statusAreaBox_line"></div> <div className="mx_RoomView_statusAreaBox_line"></div>

View file

@ -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 <li> 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 (<GeminiScrollbar autoshow={true} ref="geminiPanel" onScroll={ this.onScroll }
className={this.props.className} style={this.props.style}>
<div className="mx_RoomView_messageListWrapper">
<ol ref="itemlist" className="mx_RoomView_MessageList" aria-live="polite">
{this.props.children}
</ol>
</div>
</GeminiScrollbar>
);
},
});