From 73be7b3dd8b5a85ec80fcec8a8b000cfaa9802e3 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Thu, 9 Mar 2023 19:06:33 +0100 Subject: [PATCH] FIX: improves unread state precision (#20615) - Will consider a message read only one the bottom of the message has been read - Will allow to mark a message bigger than the view port as read - Code should be more performant as the scroll is doing less (albeit more often) - Gives us a very precise scroll state. Problem with throttling scroll is that you could end up never getting the even where scrollTop is at 0, opening a whole range of edge cases to handle --- .../discourse/components/chat-live-pane.hbs | 9 +- .../discourse/components/chat-live-pane.js | 161 ++++++++++-------- .../discourse/components/chat-message.hbs | 6 +- .../discourse/modifiers/chat/track-message.js | 16 +- .../common/chat-message-left-gutter.scss | 13 +- plugins/chat/spec/system/update_last_read.rb | 79 +++++++++ .../components/chat-message-test.js | 9 +- 7 files changed, 194 insertions(+), 99 deletions(-) create mode 100644 plugins/chat/spec/system/update_last_read.rb diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.hbs index a9bd5db6a71..e10dbc9fa09 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.hbs @@ -40,17 +40,16 @@
- {{#if this.loadedOnce}} - {{#each @channel.messages key="id" as |message|}} {{/each}} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js b/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js index f3d303c3f55..f907ac6f3a6 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js @@ -53,14 +53,8 @@ export default class ChatLivePane extends Component { @tracked needsArrow = false; @tracked loadedOnce = false; - isAtBottom = true; - isTowardsBottom = false; - isTowardsTop = false; - isAtTop = false; - _loadedChannelId = null; _scrollerEl = null; - _previousScrollTop = 0; _lastSelectedMessage = null; _mentionWarningsSeen = {}; _unreachableGroupMentions = []; @@ -97,6 +91,8 @@ export default class ChatLivePane extends Component { @action updateChannel() { + this.loadedOnce = false; + // Technically we could keep messages to avoid re-fetching them, but // it's not worth the complexity for now this.args.channel?.clearMessages(); @@ -109,7 +105,7 @@ export default class ChatLivePane extends Component { } this.loadMessages(); - this._subscribeToUpdates(this.args.channel.id); + this._subscribeToUpdates(this.args.channel?.id); } @action @@ -158,8 +154,6 @@ export default class ChatLivePane extends Component { return; } - this.loadedOnce = false; - this._previousScrollTop = 0; this.loadingMorePast = true; const findArgs = { pageSize: PAGE_SIZE }; @@ -167,7 +161,8 @@ export default class ChatLivePane extends Component { if (this.requestedTargetMessageId) { findArgs["targetMessageId"] = this.requestedTargetMessageId; } else if (fetchingFromLastRead) { - findArgs["targetMessageId"] = this._getLastReadId(); + findArgs["targetMessageId"] = + this.args.channel.currentUserMembership.last_read_message_id; } return this.chatApi @@ -208,6 +203,7 @@ export default class ChatLivePane extends Component { this.requestedTargetMessageId = null; this.loadingMorePast = false; this.fillPaneAttempt(); + this.updateLastReadMessage(); }); } @@ -224,7 +220,7 @@ export default class ChatLivePane extends Component { !canLoadMore || this.loading || this[loadingMoreKey] || - !this.args.channel.messages.length + !this.args.channel.messages?.length > 0 ) { return Promise.resolve(); } @@ -254,7 +250,7 @@ export default class ChatLivePane extends Component { // prevents an edge case where user clicks bottom arrow // just after scrolling to top - if (loadingPast && this.isAtBottom) { + if (loadingPast && this.#isAtBottom()) { return; } @@ -268,7 +264,6 @@ export default class ChatLivePane extends Component { } this.args.channel.details = meta; - this.args.channel.addMessages(messages); // Edge case for IOS to avoid blank screens @@ -293,14 +288,14 @@ export default class ChatLivePane extends Component { }); } - @debounce(500, false) + @debounce(500) fillPaneAttempt() { if (this._selfDeleted) { return; } // safeguard - if (this.args.channel.messages.length > 200) { + if (this.args.channel.messages?.length > 200) { return; } @@ -313,9 +308,7 @@ export default class ChatLivePane extends Component { return; } - this.fetchMoreMessages({ - direction: PAST, - }); + this.fetchMoreMessages({ direction: PAST }); } @bind @@ -361,10 +354,6 @@ export default class ChatLivePane extends Component { return [messages, results.meta]; } - _getLastReadId() { - return this.args.channel.currentUserMembership.last_read_message_id; - } - @debounce(100) highlightOrFetchMessage(messageId) { const message = this.args.channel.findMessage(messageId); @@ -423,30 +412,52 @@ export default class ChatLivePane extends Component { } @action - didShowMessage(message) { + messageDidEnterViewport(message) { message.visible = true; - this.updateLastReadMessage(message); } @action - didHideMessage(message) { + messageDidLeaveViewport(message) { message.visible = false; } @debounce(READ_INTERVAL_MS) updateLastReadMessage() { - if (this._selfDeleted) { - return; - } + schedule("afterRender", () => { + if (this._selfDeleted) { + return; + } + + const lastReadId = + this.args.channel.currentUserMembership?.last_read_message_id; + let lastUnreadVisibleMessage = this.args.channel.visibleMessages.findLast( + (message) => !lastReadId || message.id > lastReadId + ); + + // all intersecting messages are read + if (!lastUnreadVisibleMessage) { + return; + } + + const element = this._scrollerEl.querySelector( + `[data-id='${lastUnreadVisibleMessage.id}']` + ); + + // if the last visible message is not fully visible, we don't want to mark it as read + // attempt to mark previous one as read + if (!this.#isBottomOfMessageVisible(element, this._scrollerEl)) { + lastUnreadVisibleMessage = lastUnreadVisibleMessage.previousMessage; + + if ( + !lastUnreadVisibleMessage && + lastReadId > lastUnreadVisibleMessage.id + ) { + return; + } + } - const lastReadId = - this.args.channel.currentUserMembership?.last_read_message_id; - const lastUnreadVisibleMessage = this.args.channel.visibleMessages.findLast( - (message) => !lastReadId || message.id > lastReadId - ); - if (lastUnreadVisibleMessage) { this.args.channel.updateLastReadMessage(lastUnreadVisibleMessage.id); - } + }); } @action @@ -454,7 +465,7 @@ export default class ChatLivePane extends Component { schedule("afterRender", () => { if (this.args.channel.canLoadMoreFuture) { this._fetchAndScrollToLatest(); - } else { + } else if (this.args.channel.messages?.length > 0) { this.scrollToMessage( this.args.channel.messages[this.args.channel.messages.length - 1].id ); @@ -463,51 +474,31 @@ export default class ChatLivePane extends Component { } @action - computeScrollState(event) { - if (this._selfDeleted) { - return; - } + computeArrow() { + this.needsArrow = Math.abs(this._scrollerEl.scrollTop) >= 100; + } + @action + computeScrollState() { cancel(this.onScrollEndedHandler); - this.isScrolling = true; - - const scrollPosition = Math.abs(event.target.scrollTop); - const total = event.target.scrollHeight - event.target.clientHeight; - const difference = total - scrollPosition; - this.isTowardsTop = difference >= 50 && difference <= 150; - this.isTowardsBottom = scrollPosition >= 50 && scrollPosition <= 150; - this.needsArrow = scrollPosition >= 50; - this.isAtBottom = scrollPosition < 50; - this.isAtTop = difference < 50; - - if (this._previousScrollTop - scrollPosition <= 0) { - if (this.isAtTop) { - this.fetchMoreMessages({ direction: PAST }); - } + if (this.#isAtTop()) { + this.fetchMoreMessages({ direction: PAST }); + this.onScrollEnded(); + } else if (this.#isAtBottom()) { + this.updateLastReadMessage(); + this.hasNewMessages = false; + this.fetchMoreMessages({ direction: FUTURE }); + this.onScrollEnded(); } else { - if (this.isAtBottom) { - this.fetchMoreMessages({ direction: FUTURE }); - } + this.isScrolling = true; + this.onScrollEndedHandler = discourseLater(this, this.onScrollEnded, 150); } - - this._previousScrollTop = scrollPosition; - this.onScrollEndedHandler = discourseLater(this, this.onScrollEnded, 150); } @bind onScrollEnded() { this.isScrolling = false; - - if (this.isAtBottom) { - this.hasNewMessages = false; - } - } - - _isBetween(target, a, b) { - const min = Math.min.apply(Math, [a, b]); - const max = Math.max.apply(Math, [a, b]); - return target > min && target < max; } removeMessage(msgData) { @@ -594,7 +585,7 @@ export default class ChatLivePane extends Component { if (this.args.channel.canLoadMoreFuture) { // If we can load more messages, we just notice the user of new messages this.hasNewMessages = true; - } else if (this.isAtBottom || this.isTowardsBottom) { + } else if (this.#isTowardsBottom()) { // If we are at the bottom, we append the message and scroll to it const message = ChatMessage.create(this.args.channel, data.chat_message); this.args.channel.addMessages([message]); @@ -1141,6 +1132,10 @@ export default class ChatLivePane extends Component { } _subscribeToUpdates(channelId) { + if (!channelId) { + return; + } + this._unsubscribeToUpdates(channelId); this.messageBus.subscribe( `/chat/${channelId}`, @@ -1277,4 +1272,26 @@ export default class ChatLivePane extends Component { }); }); } + + #isAtBottom() { + return Math.abs(this._scrollerEl.scrollTop) <= 2; + } + + #isTowardsBottom() { + return Math.abs(this._scrollerEl.scrollTop) <= 50; + } + + #isAtTop() { + return ( + Math.abs(this._scrollerEl.scrollTop) >= + this._scrollerEl.scrollHeight - this._scrollerEl.offsetHeight - 2 + ); + } + + #isBottomOfMessageVisible(element, container) { + const rect = element.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + // - 1.0 to account for rounding errors, especially on firefox + return rect.bottom - 1.0 <= containerRect.bottom; + } } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs index 78933c25feb..da06ec0699d 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs @@ -56,8 +56,10 @@ }} data-id={{@message.id}} {{chat/track-message - (fn @didShowMessage @message) - (fn @didHideMessage @message) + (hash + didEnterViewport=(fn @messageDidEnterViewport @message) + didLeaveViewport=(fn @messageDidLeaveViewport @message) + ) }} > {{#if this.show}} diff --git a/plugins/chat/assets/javascripts/discourse/modifiers/chat/track-message.js b/plugins/chat/assets/javascripts/discourse/modifiers/chat/track-message.js index 469188eaa32..519113ed822 100644 --- a/plugins/chat/assets/javascripts/discourse/modifiers/chat/track-message.js +++ b/plugins/chat/assets/javascripts/discourse/modifiers/chat/track-message.js @@ -3,23 +3,23 @@ import { registerDestructor } from "@ember/destroyable"; import { bind } from "discourse-common/utils/decorators"; export default class ChatTrackMessage extends Modifier { - visibleCallback = null; - notVisibleCallback = null; + didEnterViewport = null; + didLeaveViewport = null; constructor(owner, args) { super(owner, args); registerDestructor(this, (instance) => instance.cleanup()); } - modify(element, [visibleCallback, notVisibleCallback]) { - this.visibleCallback = visibleCallback; - this.notVisibleCallback = notVisibleCallback; + modify(element, [callbacks = {}]) { + this.didEnterViewport = callbacks.didEnterViewport; + this.didLeaveViewport = callbacks.didLeaveViewport; this.intersectionObserver = new IntersectionObserver( this._intersectionObserverCallback, { root: document, - threshold: 0.9, + threshold: 0, } ); @@ -34,9 +34,9 @@ export default class ChatTrackMessage extends Modifier { _intersectionObserverCallback(entries) { entries.forEach((entry) => { if (entry.isIntersecting) { - this.visibleCallback?.(); + this.didEnterViewport?.(); } else { - this.notVisibleCallback?.(); + this.didLeaveViewport?.(); } }); } diff --git a/plugins/chat/assets/stylesheets/common/chat-message-left-gutter.scss b/plugins/chat/assets/stylesheets/common/chat-message-left-gutter.scss index 955082fe5fb..bad68f19ce7 100644 --- a/plugins/chat/assets/stylesheets/common/chat-message-left-gutter.scss +++ b/plugins/chat/assets/stylesheets/common/chat-message-left-gutter.scss @@ -6,16 +6,15 @@ width: var(--message-left-width); } +.chat-message-container.is-hovered .chat-message-left-gutter { + .chat-time { + color: var(--secondary-mediumy); + } +} + .chat-message-left-gutter__date { color: var(--primary-high); font-size: var(--font-down-1); - - &:hover, - &:focus { - .chat-time { - color: var(--primary); - } - } } .chat-message-left-gutter__flag { diff --git a/plugins/chat/spec/system/update_last_read.rb b/plugins/chat/spec/system/update_last_read.rb new file mode 100644 index 00000000000..4941712ad28 --- /dev/null +++ b/plugins/chat/spec/system/update_last_read.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +RSpec.describe "Update last read", type: :system, js: true do + fab!(:current_user) { Fabricate(:user) } + fab!(:channel_1) { Fabricate(:chat_channel) } + fab!(:first_unread) { Fabricate(:chat_message, chat_channel: channel_1) } + + let(:chat_page) { PageObjects::Pages::Chat.new } + let(:channel_page) { PageObjects::Pages::ChatChannel.new } + let(:membership) { Chat::ChatChannelMembershipManager.new(channel_1).find_for_user(current_user) } + + before do + chat_system_bootstrap + channel_1.add(current_user) + membership.update!(last_read_message_id: first_unread.id) + 25.times { |i| Fabricate(:chat_message, chat_channel: channel_1) } + sign_in(current_user) + end + + context "when the full message is not visible" do + it "doesn’t mark it as read" do + before_last_message = Fabricate(:chat_message, chat_channel: channel_1) + last_message = Fabricate(:chat_message, chat_channel: channel_1) + chat_page.visit_channel(channel_1) + + page.execute_script("document.querySelector('.chat-messages-scroll').scrollTo(0, -5)") + + try_until_success(timeout: 5) do + expect(membership.reload.last_read_message_id).to eq(before_last_message.id) + end + end + end + + context "when the full message is visible" do + it "marks it as read" do + last_message = Fabricate(:chat_message, chat_channel: channel_1) + chat_page.visit_channel(channel_1) + + page.execute_script("document.querySelector('.chat-messages-scroll').scrollTo(0, 0)") + + try_until_success(timeout: 5) do + expect(membership.reload.last_read_message_id).to eq(last_message.id) + end + end + end + + context "when user had not previous last read" do + before { membership.update!(last_read_message_id: nil) } + + it "marks last message as read" do + last_message = Fabricate(:chat_message, chat_channel: channel_1) + chat_page.visit_channel(channel_1) + + try_until_success(timeout: 5) do + expect(membership.reload.last_read_message_id).to eq(last_message.id) + end + end + end + + context "when scrolling from not visible to bottom" do + it "marks last message as read" do + before_last_message = Fabricate(:chat_message, chat_channel: channel_1) + last_message = Fabricate(:chat_message, chat_channel: channel_1) + chat_page.visit_channel(channel_1) + + page.execute_script("document.querySelector('.chat-messages-scroll').scrollTo(0, -15)") + + try_until_success(timeout: 5) do + expect(membership.reload.last_read_message_id).to eq(before_last_message.id) + end + + page.execute_script("document.querySelector('.chat-messages-scroll').scrollTo(0, -1)") + + try_until_success(timeout: 5) do + expect(membership.reload.last_read_message_id).to eq(last_message.id) + end + end + end +end diff --git a/plugins/chat/test/javascripts/components/chat-message-test.js b/plugins/chat/test/javascripts/components/chat-message-test.js index d75f7fd5284..e220825339f 100644 --- a/plugins/chat/test/javascripts/components/chat-message-test.js +++ b/plugins/chat/test/javascripts/components/chat-message-test.js @@ -55,8 +55,8 @@ module("Discourse Chat | Component | chat-message", function (hooks) { onSelectMessage: () => {}, bulkSelectMessages: () => {}, onHoverMessage: () => {}, - didShowMessage: () => {}, - didHideMessage: () => {}, + messageDidEnterViewport: () => {}, + messageDidLeaveViewport: () => {}, forceRendering: () => {}, }; } @@ -74,9 +74,8 @@ module("Discourse Chat | Component | chat-message", function (hooks) { @onSelectMessage={{this.onSelectMessage}} @bulkSelectMessages={{this.bulkSelectMessages}} @onHoverMessage={{this.onHoverMessage}} - @didShowMessage={{this.didShowMessage}} - @didHideMessage={{this.didHideMessage}} - @didHideMessage={{this.didHideMessage}} + @messageDidEnterViewport={{this.messageDidEnterViewport}} + @messageDidLeaveViewport={{this.messageDidLeaveViewport}} @forceRendering={{this.forceRendering}} /> `;