diff --git a/res/css/structures/_RoomView.scss b/res/css/structures/_RoomView.scss index a21d273475..77b868e391 100644 --- a/res/css/structures/_RoomView.scss +++ b/res/css/structures/_RoomView.scss @@ -171,6 +171,10 @@ limitations under the License. .mx_RoomView_MessageList { list-style-type: none; padding: 18px; + margin: 0; + /* needed as min-height is set to clientHeight in ScrollPanel + to prevent shrinking when WhoIsTypingTile is hidden */ + box-sizing: border-box; } .mx_RoomView_MessageList li { diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index 5fe2aae471..fc3b421e89 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -631,13 +631,29 @@ module.exports = React.createClass({ } }, - _scrollDownIfAtBottom: function() { + _onTypingVisible: function() { const scrollPanel = this.refs.scrollPanel; - if (scrollPanel) { + if (scrollPanel && scrollPanel.getScrollState().stuckAtBottom) { + scrollPanel.blockShrinking(); + // scroll down if at bottom scrollPanel.checkScroll(); } }, + updateTimelineMinHeight: function() { + const scrollPanel = this.refs.scrollPanel; + const whoIsTyping = this.refs.whoIsTyping; + const isTypingVisible = whoIsTyping && whoIsTyping.isVisible(); + + if (scrollPanel) { + if (isTypingVisible) { + scrollPanel.blockShrinking(); + } else { + scrollPanel.clearBlockShrinking(); + } + } + }, + onResize: function() { dis.dispatch({ action: 'timeline_resize' }, true); }, @@ -666,7 +682,7 @@ 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 0fdbc9a349..be5f23c420 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -223,6 +223,8 @@ module.exports = React.createClass({ onResize: function() { this.props.onResize(); + // clear min-height as the height might have changed + this.clearBlockShrinking(); this.checkScroll(); if (this._gemScroll) this._gemScroll.forceUpdate(); }, @@ -372,6 +374,8 @@ module.exports = React.createClass({ } this._unfillDebouncer = setTimeout(() => { this._unfillDebouncer = null; + // if timeline shrinks, min-height should be cleared + this.clearBlockShrinking(); this.props.onUnfillRequest(backwards, markerScrollToken); }, UNFILL_REQUEST_DEBOUNCE_MS); } @@ -678,6 +682,29 @@ module.exports = React.createClass({ this._gemScroll = gemScroll; }, + /** + * 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() { + const messageList = this.refs.itemlist; + if (messageList) { + const currentHeight = messageList.clientHeight; + messageList.style.minHeight = `${currentHeight}px`; + } + }, + + /** + * Clear the previously set min height + */ + clearBlockShrinking: function() { + const messageList = this.refs.itemlist; + if (messageList) { + messageList.style.minHeight = null; + } + }, + render: function() { const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper"); // TODO: the classnames on the div and ol could do with being updated to diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index ab10ec4aca..9fe83c2c2d 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -455,7 +455,7 @@ var TimelinePanel = React.createClass({ // const myUserId = MatrixClientPeg.get().credentials.userId; const sender = ev.sender ? ev.sender.userId : null; - var callback = null; + var callRMUpdated = false; if (sender != myUserId && !UserActivity.userCurrentlyActive()) { updatedState.readMarkerVisible = true; } else if (lastEv && this.getReadMarkerPosition() === 0) { @@ -465,11 +465,16 @@ var TimelinePanel = React.createClass({ this._setReadMarker(lastEv.getId(), lastEv.getTs(), true); updatedState.readMarkerVisible = false; updatedState.readMarkerEventId = lastEv.getId(); - callback = this.props.onReadMarkerUpdated; + callRMUpdated = true; } } - this.setState(updatedState, callback); + this.setState(updatedState, () => { + this.refs.messagePanel.updateTimelineMinHeight(); + if (callRMUpdated) { + this.props.onReadMarkerUpdated(); + } + }); }); }, diff --git a/src/components/views/rooms/WhoIsTypingTile.js b/src/components/views/rooms/WhoIsTypingTile.js index 9d49c35d83..4ee11d77b2 100644 --- a/src/components/views/rooms/WhoIsTypingTile.js +++ b/src/components/views/rooms/WhoIsTypingTile.js @@ -19,6 +19,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import sdk from '../../../index'; import WhoIsTyping from '../../../WhoIsTyping'; +import Timer from '../../../utils/Timer'; import MatrixClientPeg from '../../../MatrixClientPeg'; import MemberAvatar from '../avatars/MemberAvatar'; @@ -43,11 +44,18 @@ module.exports = React.createClass({ getInitialState: function() { return { usersTyping: WhoIsTyping.usersTypingApartFromMe(this.props.room), + // a map with userid => Timer to delay + // hiding the "x is typing" message for a + // user so hiding it can coincide + // with the sent message by the other side + // resulting in less timeline jumpiness + delayedStopTypingTimers: {}, }; }, componentWillMount: function() { MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping); + MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline); }, componentDidUpdate: function(_, prevState) { @@ -64,18 +72,90 @@ module.exports = React.createClass({ const client = MatrixClientPeg.get(); if (client) { client.removeListener("RoomMember.typing", this.onRoomMemberTyping); + client.removeListener("Room.timeline", this.onRoomTimeline); + } + Object.values(this.state.delayedStopTypingTimers).forEach((t) => t.abort()); + }, + + isVisible: function() { + return this.state.usersTyping.length !== 0 || Object.keys(this.state.delayedStopTypingTimers).length !== 0; + }, + + onRoomTimeline: function(event, room) { + if (room.roomId === this.props.room.roomId) { + const userId = event.getSender(); + // remove user from usersTyping + const usersTyping = this.state.usersTyping.filter((m) => m.userId !== userId); + this.setState({usersTyping}); + // abort timer if any + this._abortUserTimer(userId); } }, onRoomMemberTyping: function(ev, member) { + const usersTyping = WhoIsTyping.usersTypingApartFromMeAndIgnored(this.props.room); this.setState({ - usersTyping: WhoIsTyping.usersTypingApartFromMeAndIgnored(this.props.room), + delayedStopTypingTimers: this._updateDelayedStopTypingTimers(usersTyping), + usersTyping, }); }, - _renderTypingIndicatorAvatars: function(limit) { - let users = this.state.usersTyping; + _updateDelayedStopTypingTimers(usersTyping) { + const usersThatStoppedTyping = this.state.usersTyping.filter((a) => { + return !usersTyping.some((b) => a.userId === b.userId); + }); + const usersThatStartedTyping = usersTyping.filter((a) => { + return !this.state.usersTyping.some((b) => a.userId === b.userId); + }); + // abort all the timers for the users that started typing again + usersThatStartedTyping.forEach((m) => { + const timer = this.state.delayedStopTypingTimers[m.userId]; + if (timer) { + timer.abort(); + } + }); + // prepare new delayedStopTypingTimers object to update state with + let delayedStopTypingTimers = Object.assign({}, this.state.delayedStopTypingTimers); + // remove members that started typing again + delayedStopTypingTimers = usersThatStartedTyping.reduce((delayedStopTypingTimers, m) => { + delete delayedStopTypingTimers[m.userId]; + return delayedStopTypingTimers; + }, delayedStopTypingTimers); + // start timer for members that stopped typing + delayedStopTypingTimers = usersThatStoppedTyping.reduce((delayedStopTypingTimers, m) => { + if (!delayedStopTypingTimers[m.userId]) { + const timer = new Timer(5000); + delayedStopTypingTimers[m.userId] = timer; + timer.start(); + timer.finished().then( + () => this._removeUserTimer(m.userId), // on elapsed + () => {/* aborted */}, + ); + } + return delayedStopTypingTimers; + }, delayedStopTypingTimers); + return delayedStopTypingTimers; + }, + + _abortUserTimer: function(userId) { + const timer = this.state.delayedStopTypingTimers[userId]; + if (timer) { + timer.abort(); + this._removeUserTimer(userId); + } + }, + + _removeUserTimer: function(userId) { + const timer = this.state.delayedStopTypingTimers[userId]; + if (timer) { + const delayedStopTypingTimers = Object.assign({}, this.state.delayedStopTypingTimers); + delete delayedStopTypingTimers[userId]; + this.setState({delayedStopTypingTimers}); + } + }, + + _renderTypingIndicatorAvatars: function(users, limit) { let othersCount = 0; if (users.length > limit) { othersCount = users.length - limit + 1; @@ -106,8 +186,19 @@ module.exports = React.createClass({ }, render: function() { + let usersTyping = this.state.usersTyping; + const stoppedUsersOnTimer = Object.keys(this.state.delayedStopTypingTimers) + .map((userId) => this.props.room.getMember(userId)); + // append the users that have been reported not typing anymore + // but have a timeout timer running so they can disappear + // when a message comes in + usersTyping = usersTyping.concat(stoppedUsersOnTimer); + // sort them so the typing members don't change order when + // moved to delayedStopTypingTimers + usersTyping.sort((a, b) => a.name.localeCompare(b.name)); + const typingString = WhoIsTyping.whoIsTypingString( - this.state.usersTyping, + usersTyping, this.props.whoIsTypingLimit, ); if (!typingString) { @@ -119,7 +210,7 @@ module.exports = React.createClass({ return (
  • - { this._renderTypingIndicatorAvatars(this.props.whoIsTypingLimit) } + { this._renderTypingIndicatorAvatars(usersTyping, this.props.whoIsTypingLimit) }
    { typingString }