Merge pull request #66 from matrix-org/rav/factor_out_scrollpanel
Factor out a separate 'ScrollPanel'
This commit is contained in:
commit
1de42f42e1
3 changed files with 433 additions and 273 deletions
|
@ -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');
|
||||||
|
|
|
@ -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>
|
||||||
|
|
283
src/components/structures/ScrollPanel.js
Normal file
283
src/components/structures/ScrollPanel.js
Normal 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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
Loading…
Reference in a new issue