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
This commit is contained in:
Joffrey JAFFEUX
2023-03-09 19:06:33 +01:00
committed by GitHub
parent 4f9afabf87
commit 73be7b3dd8
7 changed files with 194 additions and 99 deletions

View File

@@ -40,17 +40,16 @@
<div <div
class="chat-messages-scroll chat-messages-container" class="chat-messages-scroll chat-messages-container"
{{chat/on-throttled-scroll this.computeScrollState (hash delay=50)}} {{on "scroll" this.computeScrollState passive=true}}
{{chat/on-throttled-scroll this.resetIdle (hash delay=500)}} {{chat/on-throttled-scroll this.resetIdle (hash delay=500)}}
{{chat/on-throttled-scroll this.computeArrow (hash delay=150)}}
> >
<div class="chat-message-actions-desktop-anchor"></div> <div class="chat-message-actions-desktop-anchor"></div>
<div <div
class="chat-messages-container" class="chat-messages-container"
{{chat/did-mutate-childlist this.computeDatesSeparators}} {{chat/did-mutate-childlist this.computeDatesSeparators}}
> >
{{#if this.loadedOnce}} {{#if this.loadedOnce}}
{{#each @channel.messages key="id" as |message|}} {{#each @channel.messages key="id" as |message|}}
<ChatMessage <ChatMessage
@message={{message}} @message={{message}}
@@ -66,8 +65,8 @@
@isHovered={{eq message.id this.hoveredMessageId}} @isHovered={{eq message.id this.hoveredMessageId}}
@onHoverMessage={{this.onHoverMessage}} @onHoverMessage={{this.onHoverMessage}}
@resendStagedMessage={{this.resendStagedMessage}} @resendStagedMessage={{this.resendStagedMessage}}
@didShowMessage={{this.didShowMessage}} @messageDidEnterViewport={{this.messageDidEnterViewport}}
@didHideMessage={{this.didHideMessage}} @messageDidLeaveViewport={{this.messageDidLeaveViewport}}
@forceRendering={{this.forceRendering}} @forceRendering={{this.forceRendering}}
/> />
{{/each}} {{/each}}

View File

@@ -53,14 +53,8 @@ export default class ChatLivePane extends Component {
@tracked needsArrow = false; @tracked needsArrow = false;
@tracked loadedOnce = false; @tracked loadedOnce = false;
isAtBottom = true;
isTowardsBottom = false;
isTowardsTop = false;
isAtTop = false;
_loadedChannelId = null; _loadedChannelId = null;
_scrollerEl = null; _scrollerEl = null;
_previousScrollTop = 0;
_lastSelectedMessage = null; _lastSelectedMessage = null;
_mentionWarningsSeen = {}; _mentionWarningsSeen = {};
_unreachableGroupMentions = []; _unreachableGroupMentions = [];
@@ -97,6 +91,8 @@ export default class ChatLivePane extends Component {
@action @action
updateChannel() { updateChannel() {
this.loadedOnce = false;
// Technically we could keep messages to avoid re-fetching them, but // Technically we could keep messages to avoid re-fetching them, but
// it's not worth the complexity for now // it's not worth the complexity for now
this.args.channel?.clearMessages(); this.args.channel?.clearMessages();
@@ -109,7 +105,7 @@ export default class ChatLivePane extends Component {
} }
this.loadMessages(); this.loadMessages();
this._subscribeToUpdates(this.args.channel.id); this._subscribeToUpdates(this.args.channel?.id);
} }
@action @action
@@ -158,8 +154,6 @@ export default class ChatLivePane extends Component {
return; return;
} }
this.loadedOnce = false;
this._previousScrollTop = 0;
this.loadingMorePast = true; this.loadingMorePast = true;
const findArgs = { pageSize: PAGE_SIZE }; const findArgs = { pageSize: PAGE_SIZE };
@@ -167,7 +161,8 @@ export default class ChatLivePane extends Component {
if (this.requestedTargetMessageId) { if (this.requestedTargetMessageId) {
findArgs["targetMessageId"] = this.requestedTargetMessageId; findArgs["targetMessageId"] = this.requestedTargetMessageId;
} else if (fetchingFromLastRead) { } else if (fetchingFromLastRead) {
findArgs["targetMessageId"] = this._getLastReadId(); findArgs["targetMessageId"] =
this.args.channel.currentUserMembership.last_read_message_id;
} }
return this.chatApi return this.chatApi
@@ -208,6 +203,7 @@ export default class ChatLivePane extends Component {
this.requestedTargetMessageId = null; this.requestedTargetMessageId = null;
this.loadingMorePast = false; this.loadingMorePast = false;
this.fillPaneAttempt(); this.fillPaneAttempt();
this.updateLastReadMessage();
}); });
} }
@@ -224,7 +220,7 @@ export default class ChatLivePane extends Component {
!canLoadMore || !canLoadMore ||
this.loading || this.loading ||
this[loadingMoreKey] || this[loadingMoreKey] ||
!this.args.channel.messages.length !this.args.channel.messages?.length > 0
) { ) {
return Promise.resolve(); return Promise.resolve();
} }
@@ -254,7 +250,7 @@ export default class ChatLivePane extends Component {
// prevents an edge case where user clicks bottom arrow // prevents an edge case where user clicks bottom arrow
// just after scrolling to top // just after scrolling to top
if (loadingPast && this.isAtBottom) { if (loadingPast && this.#isAtBottom()) {
return; return;
} }
@@ -268,7 +264,6 @@ export default class ChatLivePane extends Component {
} }
this.args.channel.details = meta; this.args.channel.details = meta;
this.args.channel.addMessages(messages); this.args.channel.addMessages(messages);
// Edge case for IOS to avoid blank screens // Edge case for IOS to avoid blank screens
@@ -293,14 +288,14 @@ export default class ChatLivePane extends Component {
}); });
} }
@debounce(500, false) @debounce(500)
fillPaneAttempt() { fillPaneAttempt() {
if (this._selfDeleted) { if (this._selfDeleted) {
return; return;
} }
// safeguard // safeguard
if (this.args.channel.messages.length > 200) { if (this.args.channel.messages?.length > 200) {
return; return;
} }
@@ -313,9 +308,7 @@ export default class ChatLivePane extends Component {
return; return;
} }
this.fetchMoreMessages({ this.fetchMoreMessages({ direction: PAST });
direction: PAST,
});
} }
@bind @bind
@@ -361,10 +354,6 @@ export default class ChatLivePane extends Component {
return [messages, results.meta]; return [messages, results.meta];
} }
_getLastReadId() {
return this.args.channel.currentUserMembership.last_read_message_id;
}
@debounce(100) @debounce(100)
highlightOrFetchMessage(messageId) { highlightOrFetchMessage(messageId) {
const message = this.args.channel.findMessage(messageId); const message = this.args.channel.findMessage(messageId);
@@ -423,30 +412,52 @@ export default class ChatLivePane extends Component {
} }
@action @action
didShowMessage(message) { messageDidEnterViewport(message) {
message.visible = true; message.visible = true;
this.updateLastReadMessage(message);
} }
@action @action
didHideMessage(message) { messageDidLeaveViewport(message) {
message.visible = false; message.visible = false;
} }
@debounce(READ_INTERVAL_MS) @debounce(READ_INTERVAL_MS)
updateLastReadMessage() { updateLastReadMessage() {
if (this._selfDeleted) { schedule("afterRender", () => {
return; 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); this.args.channel.updateLastReadMessage(lastUnreadVisibleMessage.id);
} });
} }
@action @action
@@ -454,7 +465,7 @@ export default class ChatLivePane extends Component {
schedule("afterRender", () => { schedule("afterRender", () => {
if (this.args.channel.canLoadMoreFuture) { if (this.args.channel.canLoadMoreFuture) {
this._fetchAndScrollToLatest(); this._fetchAndScrollToLatest();
} else { } else if (this.args.channel.messages?.length > 0) {
this.scrollToMessage( this.scrollToMessage(
this.args.channel.messages[this.args.channel.messages.length - 1].id this.args.channel.messages[this.args.channel.messages.length - 1].id
); );
@@ -463,51 +474,31 @@ export default class ChatLivePane extends Component {
} }
@action @action
computeScrollState(event) { computeArrow() {
if (this._selfDeleted) { this.needsArrow = Math.abs(this._scrollerEl.scrollTop) >= 100;
return; }
}
@action
computeScrollState() {
cancel(this.onScrollEndedHandler); cancel(this.onScrollEndedHandler);
this.isScrolling = true; if (this.#isAtTop()) {
this.fetchMoreMessages({ direction: PAST });
const scrollPosition = Math.abs(event.target.scrollTop); this.onScrollEnded();
const total = event.target.scrollHeight - event.target.clientHeight; } else if (this.#isAtBottom()) {
const difference = total - scrollPosition; this.updateLastReadMessage();
this.isTowardsTop = difference >= 50 && difference <= 150; this.hasNewMessages = false;
this.isTowardsBottom = scrollPosition >= 50 && scrollPosition <= 150; this.fetchMoreMessages({ direction: FUTURE });
this.needsArrow = scrollPosition >= 50; this.onScrollEnded();
this.isAtBottom = scrollPosition < 50;
this.isAtTop = difference < 50;
if (this._previousScrollTop - scrollPosition <= 0) {
if (this.isAtTop) {
this.fetchMoreMessages({ direction: PAST });
}
} else { } else {
if (this.isAtBottom) { this.isScrolling = true;
this.fetchMoreMessages({ direction: FUTURE }); this.onScrollEndedHandler = discourseLater(this, this.onScrollEnded, 150);
}
} }
this._previousScrollTop = scrollPosition;
this.onScrollEndedHandler = discourseLater(this, this.onScrollEnded, 150);
} }
@bind @bind
onScrollEnded() { onScrollEnded() {
this.isScrolling = false; 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) { removeMessage(msgData) {
@@ -594,7 +585,7 @@ export default class ChatLivePane extends Component {
if (this.args.channel.canLoadMoreFuture) { if (this.args.channel.canLoadMoreFuture) {
// If we can load more messages, we just notice the user of new messages // If we can load more messages, we just notice the user of new messages
this.hasNewMessages = true; 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 // If we are at the bottom, we append the message and scroll to it
const message = ChatMessage.create(this.args.channel, data.chat_message); const message = ChatMessage.create(this.args.channel, data.chat_message);
this.args.channel.addMessages([message]); this.args.channel.addMessages([message]);
@@ -1141,6 +1132,10 @@ export default class ChatLivePane extends Component {
} }
_subscribeToUpdates(channelId) { _subscribeToUpdates(channelId) {
if (!channelId) {
return;
}
this._unsubscribeToUpdates(channelId); this._unsubscribeToUpdates(channelId);
this.messageBus.subscribe( this.messageBus.subscribe(
`/chat/${channelId}`, `/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;
}
} }

View File

@@ -56,8 +56,10 @@
}} }}
data-id={{@message.id}} data-id={{@message.id}}
{{chat/track-message {{chat/track-message
(fn @didShowMessage @message) (hash
(fn @didHideMessage @message) didEnterViewport=(fn @messageDidEnterViewport @message)
didLeaveViewport=(fn @messageDidLeaveViewport @message)
)
}} }}
> >
{{#if this.show}} {{#if this.show}}

View File

@@ -3,23 +3,23 @@ import { registerDestructor } from "@ember/destroyable";
import { bind } from "discourse-common/utils/decorators"; import { bind } from "discourse-common/utils/decorators";
export default class ChatTrackMessage extends Modifier { export default class ChatTrackMessage extends Modifier {
visibleCallback = null; didEnterViewport = null;
notVisibleCallback = null; didLeaveViewport = null;
constructor(owner, args) { constructor(owner, args) {
super(owner, args); super(owner, args);
registerDestructor(this, (instance) => instance.cleanup()); registerDestructor(this, (instance) => instance.cleanup());
} }
modify(element, [visibleCallback, notVisibleCallback]) { modify(element, [callbacks = {}]) {
this.visibleCallback = visibleCallback; this.didEnterViewport = callbacks.didEnterViewport;
this.notVisibleCallback = notVisibleCallback; this.didLeaveViewport = callbacks.didLeaveViewport;
this.intersectionObserver = new IntersectionObserver( this.intersectionObserver = new IntersectionObserver(
this._intersectionObserverCallback, this._intersectionObserverCallback,
{ {
root: document, root: document,
threshold: 0.9, threshold: 0,
} }
); );
@@ -34,9 +34,9 @@ export default class ChatTrackMessage extends Modifier {
_intersectionObserverCallback(entries) { _intersectionObserverCallback(entries) {
entries.forEach((entry) => { entries.forEach((entry) => {
if (entry.isIntersecting) { if (entry.isIntersecting) {
this.visibleCallback?.(); this.didEnterViewport?.();
} else { } else {
this.notVisibleCallback?.(); this.didLeaveViewport?.();
} }
}); });
} }

View File

@@ -6,16 +6,15 @@
width: var(--message-left-width); 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 { .chat-message-left-gutter__date {
color: var(--primary-high); color: var(--primary-high);
font-size: var(--font-down-1); font-size: var(--font-down-1);
&:hover,
&:focus {
.chat-time {
color: var(--primary);
}
}
} }
.chat-message-left-gutter__flag { .chat-message-left-gutter__flag {

View File

@@ -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 "doesnt 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

View File

@@ -55,8 +55,8 @@ module("Discourse Chat | Component | chat-message", function (hooks) {
onSelectMessage: () => {}, onSelectMessage: () => {},
bulkSelectMessages: () => {}, bulkSelectMessages: () => {},
onHoverMessage: () => {}, onHoverMessage: () => {},
didShowMessage: () => {}, messageDidEnterViewport: () => {},
didHideMessage: () => {}, messageDidLeaveViewport: () => {},
forceRendering: () => {}, forceRendering: () => {},
}; };
} }
@@ -74,9 +74,8 @@ module("Discourse Chat | Component | chat-message", function (hooks) {
@onSelectMessage={{this.onSelectMessage}} @onSelectMessage={{this.onSelectMessage}}
@bulkSelectMessages={{this.bulkSelectMessages}} @bulkSelectMessages={{this.bulkSelectMessages}}
@onHoverMessage={{this.onHoverMessage}} @onHoverMessage={{this.onHoverMessage}}
@didShowMessage={{this.didShowMessage}} @messageDidEnterViewport={{this.messageDidEnterViewport}}
@didHideMessage={{this.didHideMessage}} @messageDidLeaveViewport={{this.messageDidLeaveViewport}}
@didHideMessage={{this.didHideMessage}}
@forceRendering={{this.forceRendering}} @forceRendering={{this.forceRendering}}
/> />
`; `;