FIX: improves reliability of last visit line in chat (#30948)

This commit does several changes:

- it moves the ownership of the last message info to the channel instead
of storing it on the message, it avoids the need to iterate over every
messages
- makes an optimistic update of the last read message id
- adds a spec to confirm this behavior
This commit is contained in:
Joffrey JAFFEUX
2025-01-27 10:38:41 +01:00
committed by GitHub
parent ad8f9465c3
commit 2f3355695e
8 changed files with 85 additions and 23 deletions

View File

@ -322,7 +322,7 @@ export default class ChatChannel extends Component {
processMessages(channel, result) { processMessages(channel, result) {
const messages = []; const messages = [];
let foundFirstNew = false; let foundFirstNew = false;
const hasNewest = this.messagesManager.messages.some((m) => m.newest); channel.newestMessage = null;
result?.messages?.forEach((messageData, index) => { result?.messages?.forEach((messageData, index) => {
messageData.firstOfResults = index === 0; messageData.firstOfResults = index === 0;
@ -342,19 +342,22 @@ export default class ChatChannel extends Component {
messageData.expanded = !(messageData.hidden || messageData.deleted_at); messageData.expanded = !(messageData.hidden || messageData.deleted_at);
} }
const message = ChatMessage.create(channel, messageData);
message.manager = channel.messagesManager;
// newest has to be in after fetch callback as we don't want to make it // newest has to be in after fetch callback as we don't want to make it
// dynamic or it will make the pane jump around, it will disappear on reload // dynamic or it will make the pane jump around, it will disappear on reload
if ( if (
!hasNewest &&
!foundFirstNew && !foundFirstNew &&
messageData.id > this.currentUserMembership?.lastReadMessageId messageData.id > this.currentUserMembership?.lastReadMessageId
) { ) {
foundFirstNew = true; foundFirstNew = true;
messageData.newest = true; if (message !== channel.lastMessage) {
channel.newestMessage = message;
} else {
channel.newestMessage = null;
}
} }
const message = ChatMessage.create(channel, messageData);
message.manager = channel.messagesManager;
if (message.thread) { if (message.thread) {
this.#preloadThreadTrackingState( this.#preloadThreadTrackingState(
@ -430,6 +433,10 @@ export default class ChatChannel extends Component {
return; return;
} }
// optimistic update
this.args.channel.currentUserMembership.lastReadMessageId = firstMessage.id;
this.args.channel.updateLastViewedAt();
return this.chatApi.markChannelAsRead( return this.chatApi.markChannelAsRead(
this.args.channel.id, this.args.channel.id,
firstMessage.id firstMessage.id

View File

@ -62,6 +62,10 @@ export default class ChatMessageSeparator extends Component {
} }
} }
get isNewestMessage() {
return this.args.message.id === this.args.message.channel.newestMessage?.id;
}
#areDatesOnSameDay(a, b) { #areDatesOnSameDay(a, b) {
return ( return (
a.getFullYear() === b.getFullYear() && a.getFullYear() === b.getFullYear() &&
@ -87,17 +91,19 @@ export default class ChatMessageSeparator extends Component {
{{#if this.formattedFirstMessageDate}} {{#if this.formattedFirstMessageDate}}
<div <div
class={{concatClass class={{concatClass
"chat-message-separator"
"chat-message-separator-date" "chat-message-separator-date"
(if @message.newest "with-last-visit") (if this.isNewestMessage "with-last-visit")
}} }}
role="button" role="button"
{{on "click" this.onDateClick passive=true}} {{on "click" this.onDateClick passive=true}}
data-id={{@message.id}}
> >
<div class="chat-message-separator__text-container" {{this.track}}> <div class="chat-message-separator__text-container" {{this.track}}>
<span class="chat-message-separator__text"> <span class="chat-message-separator__text">
{{this.formattedFirstMessageDate}} {{this.formattedFirstMessageDate}}
{{#if @message.newest}} {{#if this.isNewestMessage}}
<span class="chat-message-separator__last-visit"> <span class="chat-message-separator__last-visit">
<span <span
class="chat-message-separator__last-visit-separator" class="chat-message-separator__last-visit-separator"
@ -112,8 +118,11 @@ export default class ChatMessageSeparator extends Component {
<div class="chat-message-separator__line-container"> <div class="chat-message-separator__line-container">
<div class="chat-message-separator__line"></div> <div class="chat-message-separator__line"></div>
</div> </div>
{{else if @message.newest}} {{else if this.isNewestMessage}}
<div class="chat-message-separator-new"> <div
class="chat-message-separator chat-message-separator-new"
data-id={{@message.id}}
>
<div class="chat-message-separator__text-container"> <div class="chat-message-separator__text-container">
<span class="chat-message-separator__text"> <span class="chat-message-separator__text">
{{i18n "chat.last_visit"}} {{i18n "chat.last_visit"}}

View File

@ -4,12 +4,6 @@
</Styleguide::Component> </Styleguide::Component>
<Styleguide::Controls> <Styleguide::Controls>
<Styleguide::Controls::Row @name="Last Visit">
<DToggleSwitch
@state={{this.message.newest}}
{{on "click" this.toggleLastVisit}}
/>
</Styleguide::Controls::Row>
<Styleguide::Controls::Row @name="Deleted"> <Styleguide::Controls::Row @name="Deleted">
<DToggleSwitch <DToggleSwitch
@state={{not (not this.message.deletedAt)}} @state={{not (not this.message.deletedAt)}}

View File

@ -46,11 +46,6 @@ export default class ChatStyleguideChatMessage extends Component {
this.message.edited = !this.message.edited; this.message.edited = !this.message.edited;
} }
@action
toggleLastVisit() {
this.message.newest = !this.message.newest;
}
@action @action
toggleThread() { toggleThread() {
if (this.message.thread) { if (this.message.thread) {

View File

@ -72,6 +72,7 @@ export default class ChatChannel {
@tracked tracking; @tracked tracking;
@tracked threadingEnabled; @tracked threadingEnabled;
@tracked draft; @tracked draft;
@tracked newestMessage;
threadsManager = new ChatThreadsManager(getOwnerWithFallback(this)); threadsManager = new ChatThreadsManager(getOwnerWithFallback(this));
messagesManager = new ChatMessagesManager(getOwnerWithFallback(this)); messagesManager = new ChatMessagesManager(getOwnerWithFallback(this));

View File

@ -45,7 +45,6 @@ export default class ChatMessage {
@tracked chatWebhookEvent; @tracked chatWebhookEvent;
@tracked mentionWarning; @tracked mentionWarning;
@tracked availableFlags; @tracked availableFlags;
@tracked newest;
@tracked highlighted; @tracked highlighted;
@tracked firstOfResults; @tracked firstOfResults;
@tracked message; @tracked message;
@ -62,7 +61,6 @@ export default class ChatMessage {
this.channel = channel; this.channel = channel;
this.streaming = args.streaming; this.streaming = args.streaming;
this.manager = args.manager; this.manager = args.manager;
this.newest = args.newest ?? false;
this.draftSaved = args.draftSaved ?? args.draft_saved ?? false; this.draftSaved = args.draftSaved ?? args.draft_saved ?? false;
this.firstOfResults = args.firstOfResults ?? args.first_of_results ?? false; this.firstOfResults = args.firstOfResults ?? args.first_of_results ?? false;
this.staged = args.staged ?? false; this.staged = args.staged ?? false;

View File

@ -0,0 +1,48 @@
# frozen_string_literal: true
RSpec.describe "Last visit", type: :system do
fab!(:channel_1) { Fabricate(:chat_channel, threading_enabled: false) }
fab!(:channel_2) { Fabricate(:chat_channel, threading_enabled: false) }
fab!(:user_1) { Fabricate(:user) }
fab!(:current_user) { Fabricate(:user) }
let(:chat_page) { PageObjects::Pages::Chat.new }
let(:channel_page) { PageObjects::Pages::ChatChannel.new }
let(:sidebar_page) { PageObjects::Pages::Sidebar.new }
before do
chat_system_bootstrap
channel_1.add(user_1)
channel_1.add(current_user)
channel_2.add(current_user)
sign_in(current_user)
end
it "correctly updates the last visit line" do
# a slightly complicated setup to ensure we test against a non trivial case
message_1 = Fabricate(:chat_message, user: user_1, chat_channel: channel_1, use_service: true)
Fabricate(:chat_message, user: user_1, chat_channel: channel_1, use_service: true)
Fabricate(
:chat_message,
user: user_1,
chat_channel: channel_1,
in_reply_to: message_1,
use_service: true,
)
chat_page.visit_channel(channel_1)
expect(channel_page).to have_last_visit_line_at_id(message_1.id)
sidebar_page.open_channel(channel_2)
sidebar_page.open_channel(channel_1)
expect(channel_page).to have_no_last_visit_line
sidebar_page.open_channel(channel_2)
message_4 = Fabricate(:chat_message, user: user_1, chat_channel: channel_1, use_service: true)
sidebar_page.open_channel(channel_1)
expect(channel_page).to have_last_visit_line_at_id(message_4.id)
end
end

View File

@ -52,6 +52,16 @@ module PageObjects
find(message_by_id_selector(id)) find(message_by_id_selector(id))
end end
def has_last_visit_line_at_id?(id)
find(".chat-message-separator[data-id=\"#{id}\"]").has_content?(
I18n.t("js.chat.last_visit"),
)
end
def has_no_last_visit_line?
has_no_content?(I18n.t("js.chat.last_visit"))
end
def has_no_loading_skeleton? def has_no_loading_skeleton?
has_no_css?(".chat-skeleton") has_no_css?(".chat-skeleton")
end end