Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve performance of the list of chat messages #1271

Merged
merged 15 commits into from
Nov 20, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions css/comments.scss
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@
color: grey;
}

/* Internal wrappers used by the virtual list. */
#commentsTabView .comments .wrapper-background,
#commentsTabView .comments .wrapper {
position: absolute;
left: 0;
right: 0;
}

#commentsTabView .comment {
position: relative;
margin-bottom: 30px;
Expand Down
21 changes: 17 additions & 4 deletions css/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -225,10 +225,6 @@
flex-direction: column;
}

#app-content-wrapper #commentsTabView .comment:first-child {
padding-top: 15px;
}

/* The lateral padding is set for each child instead of for the chat view as a
whole to prevent showing the scroll bar padded from the border of the chat
view (which could be fixed by using a negative margin and a positive padding
Expand All @@ -251,6 +247,15 @@
padding-right: 44px;
}


#app-content-wrapper #commentsTabView .comments .wrapper-background,
#app-content-wrapper #commentsTabView .comments .wrapper {
/* Padding is not respected in the comment wrapper due to its absolute
* positioning, so it must be set through its position. */
left: 44px;
right: 44px;
}

/* Hide all siblings of the chat view when shown as the main view */
#app-content-wrapper #commentsTabView ~ *:not(#video-fullscreen):not([id^=tooltip]) {
display: none !important;
Expand Down Expand Up @@ -1223,6 +1228,14 @@ video {
padding-right: 15px;
}

#app-sidebar #commentsTabView .comments .wrapper-background,
#app-sidebar #commentsTabView .comments .wrapper {
/* Padding is not respected in the comment wrapper due to its absolute
* positioning, so it must be set through its position. */
left: 15px;
right: 15px;
}

#app-sidebar #commentsTabView .newCommentForm {
/* Make room to show the "Add" button when chat is shown in the sidebar. */
margin-right: 44px;
Expand Down
45 changes: 41 additions & 4 deletions js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -472,18 +472,16 @@
var flags = this.activeRoom.get('participantFlags') || 0;
var inCall = flags & OCA.SpreedMe.app.FLAG_IN_CALL !== 0;
if (inCall && this._chatViewInMainView === true) {
this._chatView.saveScrollPosition();
this._chatView.$el.detach();
this._sidebarView.addTab('chat', { label: t('spreed', 'Chat'), icon: 'icon-comment', priority: 100 }, this._chatView);
this._sidebarView.selectTab('chat');
this._chatView.restoreScrollPosition();
this._chatView.reloadMessageList();
this._chatView.setTooltipContainer(this._chatView.$el);
this._chatViewInMainView = false;
} else if (!inCall && !this._chatViewInMainView) {
this._chatView.saveScrollPosition();
this._sidebarView.removeTab('chat');
this._chatView.$el.prependTo('#app-content-wrapper');
this._chatView.restoreScrollPosition();
this._chatView.reloadMessageList();
this._chatView.setTooltipContainer($('#app'));
this._chatView.focusChatInput();
this._chatViewInMainView = true;
Expand Down Expand Up @@ -671,6 +669,45 @@
this._chatView.restoreScrollPosition();
}.bind(this));

// Opening or closing the sidebar changes the width of the main
// view, so if the chat view is in the main view it needs to be
// reloaded.
var reloadMessageListOnSidebarVisibilityChange = function() {
if (!this._chatViewInMainView) {
return;
}

this._chatView.reloadMessageList();
}.bind(this);
this._chatView.listenTo(this._sidebarView, 'opened', reloadMessageListOnSidebarVisibilityChange);
this._chatView.listenTo(this._sidebarView, 'closed', reloadMessageListOnSidebarVisibilityChange);

// Resizing the window can change the size of the chat view, both
// when it is in the main view and in the sidebar, so the chat view
// needs to be reloaded. The initial reload is not very heavy, so
// the handler is not debounced for a snappier feel and to reduce
// flickering.
// However, resizing the window below certain width causes the
// navigation bar to be hidden; an explicit handling is needed in
// this case because the app navigation (or, more specifically, its
// Snap object) adds a transition to the app content, so the reload
// needs to be delayed to give the transition time to end and thus
// give the app content time to get its final size.
var reloadMessageListOnWindowResize = function() {
var chatView = this._chatView;

if ($(window).width() >= 768 || !this._chatViewInMainView) {
chatView.reloadMessageList();

return;
}

setTimeout(function() {
chatView.reloadMessageList();
}, 300);
}.bind(this);
$(window).resize(reloadMessageListOnWindowResize);

this._messageCollection.listenTo(roomChannel, 'leaveCurrentRoom', function() {
this.stopReceivingMessages();
});
Expand Down
2 changes: 2 additions & 0 deletions js/models/chatmessagecollection.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,9 @@
},

_messagesReceived: function(messages) {
this.trigger('add:start');
this.set(messages);
this.trigger('add:end');
},

receiveMessages: function() {
Expand Down
115 changes: 42 additions & 73 deletions js/views/chatview.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@

initialize: function() {
this.listenTo(this.collection, 'reset', this.render);
this.listenTo(this.collection, 'add:start', this._onAddModelStart);
this.listenTo(this.collection, 'add', this._onAddModel);
this.listenTo(this.collection, 'add:end', this._onAddModelEnd);

this._guestNameEditableTextLabel = new OCA.SpreedMe.Views.EditableTextLabel({
model: this.getOption('guestNameModel'),
Expand Down Expand Up @@ -244,6 +246,8 @@
this.$el.find('.has-tooltip').tooltip({container: this._tooltipContainer});
this.$container = this.$el.find('ul.comments');

this._virtualList = new OCA.SpreedMe.Views.VirtualList(this.$container);

if (OC.getCurrentUser().uid) {
this.$el.find('.avatar').avatar(OC.getCurrentUser().uid, 32, undefined, false, undefined, OC.getCurrentUser().displayName);
} else {
Expand Down Expand Up @@ -304,56 +308,50 @@
* be able to restore the scroll position when attached again.
*/
saveScrollPosition: function() {
var self = this;

if (_.isUndefined(this.$container)) {
return;
}

var containerHeight = this.$container.outerHeight();

this._$lastVisibleComment = this.$container.children('.comment').filter(function() {
return self._getCommentTopPosition($(this)) < containerHeight;
}).last();
this._savedScrollPosition = this.$container.scrollTop();
},

/**
* Restores the scroll position of the message list to the last saved
* position.
*
* When the scroll position is restored the size of the message list may
* have changed (for example, if the chat view was detached from the
* main view and attached to the sidebar); it is not possible to
* guarantee that exactly the same messages that were visible when the
* scroll position was saved will be visible when the scroll position is
* restored. Due to this, restoring the scroll position just ensures
* that the last message that was partially visible when it was saved
* will be fully visible when it is restored.
* Note that the saved scroll position is valid only if the chat view
* was not resized since it was saved; restoring the scroll position
* after the chat view was resized may or may not work as expected.
*/
restoreScrollPosition: function() {
if (_.isUndefined(this.$container) || _.isUndefined(this._$lastVisibleComment)) {
if (_.isUndefined(this.$container) || _.isUndefined(this._savedScrollPosition)) {
return;
}

var scrollBottom = this.$container.scrollTop();

// When the last visible comment has a next sibling the scroll
// position is based on the top position of that next sibling.
// Basing it on the last visible comment top position and its height
// could cause the next sibling to be shown due to a negative margin
// "pulling it up" over the last visible comment bottom margin.
var $nextSibling = this._$lastVisibleComment.next();
if ($nextSibling.length > 0) {
// Substract 1px to ensure that it does not scroll into the next
// element (which would cause the next element to be fully shown
// if saving and restoring the scroll position again) due to
// rounding.
scrollBottom += this._getCommentTopPosition($nextSibling) - 1;
} else if (this._$lastVisibleComment.length > 0) {
scrollBottom += this._getCommentTopPosition(this._$lastVisibleComment) + this._getCommentOuterHeight(this._$lastVisibleComment);
this.$container.scrollTop(this._savedScrollPosition);
},

/**
* Reloads the message list.
*
* This needs to be called whenever the size of the chat view has
* changed.
*
* When the message list is reloaded its size may have changed (for
* example, if the chat view was detached from the main view and
* attached to the sidebar); it is not possible to guarantee that
* exactly the same messages that were visible before will be visible
* after the message list is reloaded. Due to this, in those cases
* reloading the message list just ensures that the last message that
* was partially visible before will be fully visible after the message
* list is reloaded.
*/
reloadMessageList: function() {
if (!this._virtualList) {
return;
}

this.$container.scrollTop(scrollBottom - this.$container.outerHeight());
this._virtualList.reload();
},

_formatItem: function(commentModel) {
Expand Down Expand Up @@ -388,18 +386,15 @@
return data;
},

_onAddModel: function(model, collection, options) {
this.$el.find('.emptycontent').toggleClass('hidden', true);
_onAddModelStart: function() {
this._virtualList.appendElementStart();

var $newestComment = this.$container.children('.comment').last();
var scrollToNew = $newestComment.length > 0 && this._getCommentTopPosition($newestComment) < this.$container.outerHeight();
this._scrollToNew = this._virtualList.getLastElement() === this._virtualList.getLastVisibleElement();
},

_onAddModel: function(model) {
var $el = $(this.commentTemplate(this._formatItem(model)));
if (!_.isUndefined(options.at) && collection.length > 1) {
this.$container.find('li').eq(options.at).before($el);
} else {
this.$container.append($el);
}
this._virtualList.appendElement($el);

if (this._modelsHaveSameActor(this._lastAddedMessageModel, model) &&
this._modelsAreTemporaryNear(this._lastAddedMessageModel, model) &&
Expand Down Expand Up @@ -427,42 +422,16 @@
this._lastAddedMessageModel = model;

this._postRenderItem(model, $el);

if (scrollToNew) {
var newestCommentHiddenHeight = (this._getCommentTopPosition($newestComment) + this._getCommentOuterHeight($newestComment)) - this.$container.outerHeight();
this.$container.scrollTop(this.$container.scrollTop() + newestCommentHiddenHeight + $el.outerHeight(true));
}
},

_getCommentTopPosition: function($element) {
// When the margin is positive, jQuery returns the proper top
// position of the element (that is, including the top margin).
// However, when it is negative, jQuery returns where the top
// position of the element would be if there was no margin. Grouped
// messages use a negative top margin to "pull them up" closer to
// the previous message, so in those cases the top position returned
// by jQuery is below the actual top position of the element.
var marginTop = parseInt($element.css('margin-top'));
if (marginTop >= 0) {
return $element.position().top;
}
_onAddModelEnd: function() {
this.$el.find('.emptycontent').toggleClass('hidden', true);

return $element.position().top + marginTop;
},
this._virtualList.appendElementEnd();

_getCommentOuterHeight: function($element) {
// When the margin is positive, jQuery returns the proper outer
// height of the element. However, when it is negative, it
// substracts the negative margin from the overall height of the
// element. Grouped messages use a negative top margin to "pull them
// up" closer to the previous message, so in those cases the outer
// height returned by jQuery is smaller than the actual height.
var marginTop = parseInt($element.css('margin-top'));
if (marginTop >= 0) {
return $element.outerHeight(true);
if (this._scrollToNew) {
this._virtualList.scrollTo(this._virtualList.getLastElement());
}

return $element.outerHeight(true) - marginTop;
},

_getDateSeparator: function(timestamp) {
Expand Down
Loading