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 }