Merge pull request #2456 from matrix-org/bwindels/extendtypingbartiming
Avoid "jumpiness" with inline typing indicatorpull/21833/head
						commit
						3c8bd3fc78
					
				|  | @ -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 { | ||||
|  |  | |||
|  | @ -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 = (<WhoIsTypingTile room={this.props.room} onVisible={this._scrollDownIfAtBottom} />); | ||||
|             whoIsTyping = (<WhoIsTypingTile room={this.props.room} onVisible={this._onTypingVisible} ref="whoIsTyping" />); | ||||
|         } | ||||
| 
 | ||||
|         return ( | ||||
|  |  | |||
|  | @ -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
 | ||||
|  |  | |||
|  | @ -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(); | ||||
|                 } | ||||
|             }); | ||||
|         }); | ||||
|     }, | ||||
| 
 | ||||
|  |  | |||
|  | @ -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 ( | ||||
|             <li className="mx_WhoIsTypingTile"> | ||||
|                 <div className="mx_WhoIsTypingTile_avatars"> | ||||
|                     { this._renderTypingIndicatorAvatars(this.props.whoIsTypingLimit) } | ||||
|                     { this._renderTypingIndicatorAvatars(usersTyping, this.props.whoIsTypingLimit) } | ||||
|                 </div> | ||||
|                 <div className="mx_WhoIsTypingTile_label"> | ||||
|                     <EmojiText>{ typingString }</EmojiText> | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue
	
	 Bruno Windels
						Bruno Windels