diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index fecf5f1ad7..805749b1cd 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -631,12 +631,22 @@ module.exports = React.createClass({ } }, - _onTypingVisible: function() { + _onTypingShown: function() { const scrollPanel = this.refs.scrollPanel; + // this will make the timeline grow, so checkScroll + scrollPanel.checkScroll(); if (scrollPanel && scrollPanel.getScrollState().stuckAtBottom) { - // scroll down if at bottom - scrollPanel.checkScroll(); - scrollPanel.blockShrinking(); + scrollPanel.preventShrinking(); + } + }, + + _onTypingHidden: function() { + const scrollPanel = this.refs.scrollPanel; + if (scrollPanel) { + // as hiding the typing notifications doesn't + // update the scrollPanel, we tell it to apply + // the shrinking prevention once the typing notifs are hidden + scrollPanel.updatePreventShrinking(); } }, @@ -652,15 +662,15 @@ module.exports = React.createClass({ // update the min-height, so once the last // person stops typing, no jumping occurs if (isAtBottom && isTypingVisible) { - scrollPanel.blockShrinking(); + scrollPanel.preventShrinking(); } } }, - clearTimelineHeight: function() { + onTimelineReset: function() { const scrollPanel = this.refs.scrollPanel; if (scrollPanel) { - scrollPanel.clearBlockShrinking(); + scrollPanel.clearPreventShrinking(); } }, @@ -688,7 +698,12 @@ module.exports = React.createClass({ let whoIsTyping; if (this.props.room) { - whoIsTyping = (); + whoIsTyping = ( + ); } return ( diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index 36cf4dc018..b1add8235f 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -175,6 +175,7 @@ module.exports = React.createClass({ // // This will also re-check the fill state, in case the paginate was inadequate this.checkScroll(); + this.updatePreventShrinking(); }, componentWillUnmount: function() { @@ -192,22 +193,23 @@ module.exports = React.createClass({ onScroll: function(ev) { this._scrollTimeout.restart(); this._saveScrollState(); - this._checkBlockShrinking(); this.checkFillState(); - + this.updatePreventShrinking(); this.props.onScroll(ev); }, onResize: function() { - this.clearBlockShrinking(); this.checkScroll(); + // update preventShrinkingState if present + if (this.preventShrinkingState) { + this.preventShrinking(); + } }, // after an update to the contents of the panel, check that the scroll is // where it ought to be, and set off pagination requests if necessary. checkScroll: function() { this._restoreSavedScrollState(); - this._checkBlockShrinking(); this.checkFillState(); }, @@ -718,39 +720,84 @@ module.exports = React.createClass({ }, /** - * Set the current height as the min height for the message list - * so the timeline cannot shrink. This is used to avoid - * jumping when the typing indicator gets replaced by a smaller message. - */ - blockShrinking: function() { + Mark the bottom offset of the last tile so we can balance it out when + anything below it changes, by calling updatePreventShrinking, to keep + the same minimum bottom offset, effectively preventing the timeline to shrink. + */ + preventShrinking: function() { const messageList = this.refs.itemlist; - if (messageList) { - const currentHeight = messageList.clientHeight; - messageList.style.minHeight = `${currentHeight}px`; + const tiles = messageList && messageList.children; + if (!messageList) { + return; } + let lastTileNode; + for (let i = tiles.length - 1; i >= 0; i--) { + const node = tiles[i]; + if (node.dataset.scrollTokens) { + lastTileNode = node; + break; + } + } + if (!lastTileNode) { + return; + } + this.clearPreventShrinking(); + const offsetFromBottom = messageList.clientHeight - (lastTileNode.offsetTop + lastTileNode.clientHeight); + this.preventShrinkingState = { + offsetFromBottom: offsetFromBottom, + offsetNode: lastTileNode, + }; + debuglog("prevent shrinking, last tile ", offsetFromBottom, "px from bottom"); + }, + + /** Clear shrinking prevention. Used internally, and when the timeline is reloaded. */ + clearPreventShrinking: function() { + const messageList = this.refs.itemlist; + const balanceElement = messageList && messageList.parentElement; + if (balanceElement) balanceElement.style.paddingBottom = null; + this.preventShrinkingState = null; + debuglog("prevent shrinking cleared"); }, /** - * Clear the previously set min height - */ - clearBlockShrinking: function() { - const messageList = this.refs.itemlist; - if (messageList) { - messageList.style.minHeight = null; - } - }, - - _checkBlockShrinking: function() { - const sn = this._getScrollNode(); - const scrollState = this.scrollState; - if (!scrollState.stuckAtBottom) { - const spaceBelowViewport = sn.scrollHeight - (sn.scrollTop + sn.clientHeight); - // only if we've scrolled up 200px from the bottom - // should we clear the min-height used by the typing notifications, - // otherwise we might still see it jump as the whitespace disappears - // when scrolling up from the bottom - if (spaceBelowViewport >= 200) { - this.clearBlockShrinking(); + update the container padding to balance + the bottom offset of the last tile since + preventShrinking was called. + Clears the prevent-shrinking state ones the offset + from the bottom of the marked tile grows larger than + what it was when marking. + */ + updatePreventShrinking: function() { + if (this.preventShrinkingState) { + const sn = this._getScrollNode(); + const scrollState = this.scrollState; + const messageList = this.refs.itemlist; + const {offsetNode, offsetFromBottom} = this.preventShrinkingState; + // element used to set paddingBottom to balance the typing notifs disappearing + const balanceElement = messageList.parentElement; + // if the offsetNode got unmounted, clear + let shouldClear = !offsetNode.parentElement; + // also if 200px from bottom + if (!shouldClear && !scrollState.stuckAtBottom) { + const spaceBelowViewport = sn.scrollHeight - (sn.scrollTop + sn.clientHeight); + shouldClear = spaceBelowViewport >= 200; + } + // try updating if not clearing + if (!shouldClear) { + const currentOffset = messageList.clientHeight - (offsetNode.offsetTop + offsetNode.clientHeight); + const offsetDiff = offsetFromBottom - currentOffset; + if (offsetDiff > 0) { + balanceElement.style.paddingBottom = `${offsetDiff}px`; + if (this.scrollState.stuckAtBottom) { + sn.scrollTop = sn.scrollHeight; + } + debuglog("update prevent shrinking ", offsetDiff, "px from bottom"); + } else if (offsetDiff < 0) { + shouldClear = true; + } + } + if (shouldClear) { + this.clearPreventShrinking(); } } }, diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index c983f904a0..aba7964a15 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -939,7 +939,7 @@ var TimelinePanel = React.createClass({ // clear the timeline min-height when // (re)loading the timeline if (this.refs.messagePanel) { - this.refs.messagePanel.clearTimelineHeight(); + this.refs.messagePanel.onTimelineReset(); } this._reloadEvents(); diff --git a/src/components/views/rooms/WhoIsTypingTile.js b/src/components/views/rooms/WhoIsTypingTile.js index 9dd690f6e5..95cf0717c7 100644 --- a/src/components/views/rooms/WhoIsTypingTile.js +++ b/src/components/views/rooms/WhoIsTypingTile.js @@ -29,7 +29,8 @@ module.exports = React.createClass({ propTypes: { // the room this statusbar is representing. room: PropTypes.object.isRequired, - onVisible: PropTypes.func, + onShown: PropTypes.func, + onHidden: PropTypes.func, // Number of names to display in typing indication. E.g. set to 3, will // result in "X, Y, Z and 100 others are typing." whoIsTypingLimit: PropTypes.number, @@ -59,11 +60,13 @@ module.exports = React.createClass({ }, componentDidUpdate: function(_, prevState) { - if (this.props.onVisible && - !prevState.usersTyping.length && - this.state.usersTyping.length - ) { - this.props.onVisible(); + const wasVisible = this._isVisible(prevState); + const isVisible = this._isVisible(this.state); + if (this.props.onShown && !wasVisible && isVisible) { + this.props.onShown(); + } + else if (this.props.onHidden && wasVisible && !isVisible) { + this.props.onHidden(); } }, @@ -77,8 +80,12 @@ module.exports = React.createClass({ Object.values(this.state.delayedStopTypingTimers).forEach((t) => t.abort()); }, + _isVisible: function(state) { + return state.usersTyping.length !== 0 || Object.keys(state.delayedStopTypingTimers).length !== 0; + }, + isVisible: function() { - return this.state.usersTyping.length !== 0 || Object.keys(this.state.delayedStopTypingTimers).length !== 0; + return this._isVisible(this.state); }, onRoomTimeline: function(event, room) {