FEATURE: implements last read message for threads (#26702)

This commit will now allow us to track read position in a thread and returns to this position when you open the thread.

Note this commit is also extracting the following components to make it possible:
- `<ChatMessagesScroller />`
- `<ChatMessagesContainer />`

The `UpdateUserThreadLastRead` has been updated to allow this.

Various refactorings have also been done to the code and specs to improve the support of last read.
This commit is contained in:
Joffrey JAFFEUX 2024-04-25 10:47:54 +02:00 committed by GitHub
parent 35bc27a36d
commit 52e8d57293
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 421 additions and 377 deletions

View File

@ -1,17 +1,15 @@
# frozen_string_literal: true # frozen_string_literal: true
class Chat::Api::ReadsController < Chat::ApiController class Chat::Api::ChannelsReadController < Chat::ApiController
def update def update
params.require(%i[channel_id message_id]) with_service(Chat::UpdateUserChannelLastRead) do
with_service(Chat::UpdateUserLastRead) do
on_success { render(json: success_json) } on_success { render(json: success_json) }
on_failure { render(json: failed_json, status: 422) } on_failure { render(json: failed_json, status: 422) }
on_failed_policy(:ensure_message_id_recency) do on_failed_policy(:ensure_message_id_recency) do
raise Discourse::InvalidParameters.new(:message_id) raise Discourse::InvalidParameters.new(:message_id)
end end
on_model_not_found(:message) { raise Discourse::NotFound } on_model_not_found(:message) { raise Discourse::NotFound }
on_model_not_found(:active_membership) { raise Discourse::NotFound } on_model_not_found(:membership) { raise Discourse::NotFound }
on_model_not_found(:channel) { raise Discourse::NotFound } on_model_not_found(:channel) { raise Discourse::NotFound }
on_failed_policy(:invalid_access) { raise Discourse::InvalidAccess } on_failed_policy(:invalid_access) { raise Discourse::InvalidAccess }
on_failed_contract do |contract| on_failed_contract do |contract|

View File

@ -1,13 +1,12 @@
# frozen_string_literal: true # frozen_string_literal: true
class Chat::Api::ThreadReadsController < Chat::ApiController class Chat::Api::ChannelsThreadsReadController < Chat::ApiController
def update def update
params.require(%i[channel_id thread_id])
with_service(Chat::UpdateUserThreadLastRead) do with_service(Chat::UpdateUserThreadLastRead) do
on_success { render(json: success_json) } on_success { render(json: success_json) }
on_failure { render(json: failed_json, status: 422) } on_failure { render(json: failed_json, status: 422) }
on_model_not_found(:thread) { raise Discourse::NotFound } on_model_not_found(:thread) { raise Discourse::NotFound }
on_model_not_found(:message) { raise Discourse::NotFound }
on_failed_policy(:invalid_access) { raise Discourse::InvalidAccess } on_failed_policy(:invalid_access) { raise Discourse::InvalidAccess }
on_failed_contract do |contract| on_failed_contract do |contract|
render(json: failed_json.merge(errors: contract.errors.full_messages), status: 400) render(json: failed_json.merge(errors: contract.errors.full_messages), status: 400)

View File

@ -55,12 +55,6 @@ module Chat
user_chat_thread_memberships.find_by(user: user) user_chat_thread_memberships.find_by(user: user)
end end
def mark_read_for_user!(user, last_read_message_id: nil)
membership_for(user)&.update!(
last_read_message_id: last_read_message_id || self.last_message_id,
)
end
def replies def replies
self.chat_messages.where.not(id: self.original_message_id).order("created_at ASC, id ASC") self.chat_messages.where.not(id: self.original_message_id).order("created_at ASC, id ASC")
end end

View File

@ -9,6 +9,10 @@ module Chat
belongs_to :thread, class_name: "Chat::Thread", foreign_key: :thread_id belongs_to :thread, class_name: "Chat::Thread", foreign_key: :thread_id
enum :notification_level, Chat::NotificationLevels.all enum :notification_level, Chat::NotificationLevels.all
def mark_read!(new_last_read_id = nil)
update!(last_read_message_id: new_last_read_id || thread.last_message_id)
end
end end
end end

View File

@ -69,9 +69,9 @@ module Chat
end end
def determine_target_message_id(contract:, membership:, guardian:) def determine_target_message_id(contract:, membership:, guardian:)
if contract.fetch_from_last_read if contract.fetch_from_last_read || !contract.target_message_id
context.target_message_id = membership&.last_read_message_id context.target_message_id = membership&.last_read_message_id
else elsif contract.target_message_id
context.target_message_id = contract.target_message_id context.target_message_id = contract.target_message_id
end end
end end

View File

@ -4,9 +4,9 @@ module Chat
# Service responsible for updating the last read message id of a membership. # Service responsible for updating the last read message id of a membership.
# #
# @example # @example
# Chat::UpdateUserLastRead.call(channel_id: 2, message_id: 3, guardian: guardian) # Chat::UpdateUserChannelLastRead.call(channel_id: 2, message_id: 3, guardian: guardian)
# #
class UpdateUserLastRead class UpdateUserChannelLastRead
include ::Service::Base include ::Service::Base
# @!method call(channel_id:, message_id:, guardian:) # @!method call(channel_id:, message_id:, guardian:)
@ -17,7 +17,7 @@ module Chat
contract contract
model :channel model :channel
model :active_membership model :membership
policy :invalid_access policy :invalid_access
model :message model :message
policy :ensure_message_id_recency policy :ensure_message_id_recency
@ -41,31 +41,30 @@ module Chat
::Chat::Channel.find_by(id: contract.channel_id) ::Chat::Channel.find_by(id: contract.channel_id)
end end
def fetch_active_membership(guardian:, channel:) def fetch_membership(guardian:, channel:)
::Chat::ChannelMembershipManager.new(channel).find_for_user(guardian.user, following: true) ::Chat::ChannelMembershipManager.new(channel).find_for_user(guardian.user, following: true)
end end
def invalid_access(guardian:, active_membership:) def invalid_access(guardian:, membership:)
guardian.can_join_chat_channel?(active_membership.chat_channel) guardian.can_join_chat_channel?(membership.chat_channel)
end end
def fetch_message(channel:, contract:) def fetch_message(channel:, contract:)
::Chat::Message.with_deleted.find_by(chat_channel_id: channel.id, id: contract.message_id) ::Chat::Message.with_deleted.find_by(chat_channel_id: channel.id, id: contract.message_id)
end end
def ensure_message_id_recency(message:, active_membership:) def ensure_message_id_recency(message:, membership:)
!active_membership.last_read_message_id || !membership.last_read_message_id || message.id >= membership.last_read_message_id
message.id >= active_membership.last_read_message_id
end end
def update_membership_state(message:, active_membership:) def update_membership_state(message:, membership:)
active_membership.update!(last_read_message_id: message.id, last_viewed_at: Time.zone.now) membership.update!(last_read_message_id: message.id, last_viewed_at: Time.zone.now)
end end
def mark_associated_mentions_as_read(active_membership:, message:) def mark_associated_mentions_as_read(membership:, message:)
::Chat::Action::MarkMentionsRead.call( ::Chat::Action::MarkMentionsRead.call(
active_membership.user, membership.user,
channel_ids: [active_membership.chat_channel.id], channel_ids: [membership.chat_channel.id],
message_id: message.id, message_id: message.id,
) )
end end

View File

@ -2,13 +2,10 @@
module Chat module Chat
# Service responsible for marking messages in a thread # Service responsible for marking messages in a thread
# as read. For now this just marks any mentions in the thread # as read.
# as read and marks the entire thread as read.
# As we add finer-grained user tracking state to threads it
# will work in a similar way to Chat::UpdateUserLastRead.
# #
# @example # @example
# Chat::UpdateUserThreadLastRead.call(channel_id: 2, thread_id: 3, guardian: guardian) # Chat::UpdateUserThreadLastRead.call(channel_id: 2, thread_id: 3, message_id: 4, guardian: guardian)
# #
class UpdateUserThreadLastRead class UpdateUserThreadLastRead
include ::Service::Base include ::Service::Base
@ -16,20 +13,25 @@ module Chat
# @!method call(channel_id:, thread_id:, guardian:) # @!method call(channel_id:, thread_id:, guardian:)
# @param [Integer] channel_id # @param [Integer] channel_id
# @param [Integer] thread_id # @param [Integer] thread_id
# @param [Integer] message_id
# @param [Guardian] guardian # @param [Guardian] guardian
# @return [Service::Base::Context] # @return [Service::Base::Context]
contract contract
model :thread model :thread
policy :invalid_access policy :invalid_access
model :membership
model :message
policy :ensure_valid_message
step :mark_associated_mentions_as_read step :mark_associated_mentions_as_read
step :mark_thread_read step :mark_thread_read
step :publish_new_last_read_to_clients step :publish_new_last_read_to_clients
# @!visibility private # @!visibility private
class Contract class Contract
attribute :thread_id, :integer
attribute :channel_id, :integer attribute :channel_id, :integer
attribute :thread_id, :integer
attribute :message_id, :integer
validates :thread_id, :channel_id, presence: true validates :thread_id, :channel_id, presence: true
end end
@ -40,31 +42,41 @@ module Chat
::Chat::Thread.find_by(id: contract.thread_id, channel_id: contract.channel_id) ::Chat::Thread.find_by(id: contract.thread_id, channel_id: contract.channel_id)
end end
def fetch_message(contract:, thread:)
::Chat::Message.with_deleted.find_by(
id: contract.message_id || thread.last_message_id,
thread_id: contract.thread_id,
chat_channel_id: contract.channel_id,
)
end
def fetch_membership(guardian:, thread:)
thread.membership_for(guardian.user)
end
def invalid_access(guardian:, thread:) def invalid_access(guardian:, thread:)
guardian.can_join_chat_channel?(thread.channel) guardian.can_join_chat_channel?(thread.channel)
end end
# NOTE: In future we will pass in a specific last_read_message_id def ensure_valid_message(message:, membership:)
# to the service, so this will need to change because currently it's !membership.last_read_message_id || message.id >= membership.last_read_message_id
# just using the thread's last_message_id.
def mark_thread_read(thread:, guardian:)
thread.mark_read_for_user!(guardian.user)
end end
def mark_associated_mentions_as_read(thread:, guardian:) def mark_thread_read(membership:, message:)
membership.mark_read!(message.id)
end
def mark_associated_mentions_as_read(thread:, guardian:, message:)
::Chat::Action::MarkMentionsRead.call( ::Chat::Action::MarkMentionsRead.call(
guardian.user, guardian.user,
channel_ids: [thread.channel_id], channel_ids: [thread.channel_id],
thread_id: thread.id, thread_id: thread.id,
message_id: message.id,
) )
end end
def publish_new_last_read_to_clients(guardian:, thread:) def publish_new_last_read_to_clients(guardian:, thread:, message:)
::Chat::Publisher.publish_user_tracking_state!( ::Chat::Publisher.publish_user_tracking_state!(guardian.user, thread.channel, message)
guardian.user,
thread.channel,
thread.last_message,
)
end end
end end
end end

View File

@ -1,7 +1,6 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { cached, tracked } from "@glimmer/tracking"; import { cached, tracked } from "@glimmer/tracking";
import { getOwner } from "@ember/application"; import { getOwner } from "@ember/application";
import { hash } from "@ember/helper";
import { action } from "@ember/object"; import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert"; import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import didUpdate from "@ember/render-modifiers/modifiers/did-update"; import didUpdate from "@ember/render-modifiers/modifiers/did-update";
@ -28,10 +27,7 @@ import {
READ_INTERVAL_MS, READ_INTERVAL_MS,
} from "discourse/plugins/chat/discourse/lib/chat-constants"; } from "discourse/plugins/chat/discourse/lib/chat-constants";
import ChatMessagesLoader from "discourse/plugins/chat/discourse/lib/chat-messages-loader"; import ChatMessagesLoader from "discourse/plugins/chat/discourse/lib/chat-messages-loader";
import { import { checkMessageTopVisibility } from "discourse/plugins/chat/discourse/lib/check-message-visibility";
checkMessageBottomVisibility,
checkMessageTopVisibility,
} from "discourse/plugins/chat/discourse/lib/check-message-visibility";
import DatesSeparatorsPositioner from "discourse/plugins/chat/discourse/lib/dates-separators-positioner"; import DatesSeparatorsPositioner from "discourse/plugins/chat/discourse/lib/dates-separators-positioner";
import { extractCurrentTopicInfo } from "discourse/plugins/chat/discourse/lib/extract-current-topic-info"; import { extractCurrentTopicInfo } from "discourse/plugins/chat/discourse/lib/extract-current-topic-info";
import { import {
@ -40,14 +36,14 @@ import {
} from "discourse/plugins/chat/discourse/lib/scroll-helpers"; } from "discourse/plugins/chat/discourse/lib/scroll-helpers";
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
import { stackingContextFix } from "../lib/chat-ios-hacks"; import { stackingContextFix } from "../lib/chat-ios-hacks";
import ChatOnResize from "../modifiers/chat/on-resize";
import ChatScrollableList from "../modifiers/chat/scrollable-list";
import ChatComposerChannel from "./chat/composer/channel"; import ChatComposerChannel from "./chat/composer/channel";
import ChatScrollToBottomArrow from "./chat/scroll-to-bottom-arrow"; import ChatScrollToBottomArrow from "./chat/scroll-to-bottom-arrow";
import ChatSelectionManager from "./chat/selection-manager"; import ChatSelectionManager from "./chat/selection-manager";
import ChatChannelPreviewCard from "./chat-channel-preview-card"; import ChatChannelPreviewCard from "./chat-channel-preview-card";
import ChatMentionWarnings from "./chat-mention-warnings"; import ChatMentionWarnings from "./chat-mention-warnings";
import Message from "./chat-message"; import Message from "./chat-message";
import ChatMessagesContainer from "./chat-messages-container";
import ChatMessagesScroller from "./chat-messages-scroller";
import ChatNotices from "./chat-notices"; import ChatNotices from "./chat-notices";
import ChatSkeleton from "./chat-skeleton"; import ChatSkeleton from "./chat-skeleton";
import ChatUploadDropZone from "./chat-upload-drop-zone"; import ChatUploadDropZone from "./chat-upload-drop-zone";
@ -78,7 +74,7 @@ export default class ChatChannel extends Component {
@tracked uploadDropZone; @tracked uploadDropZone;
@tracked isScrolling = false; @tracked isScrolling = false;
scrollable = null; scroller = null;
_mentionWarningsSeen = {}; _mentionWarningsSeen = {};
_unreachableGroupMentions = []; _unreachableGroupMentions = [];
_overMembersLimitGroupMentions = []; _overMembersLimitGroupMentions = [];
@ -101,8 +97,8 @@ export default class ChatChannel extends Component {
} }
@action @action
setScrollable(element) { registerScroller(element) {
this.scrollable = element; this.scroller = element;
} }
@action @action
@ -117,7 +113,8 @@ export default class ChatChannel extends Component {
@action @action
didResizePane() { didResizePane() {
this.debounceFillPaneAttempt(); this.debounceFillPaneAttempt();
DatesSeparatorsPositioner.apply(this.scrollable); this.debouncedUpdateLastReadMessage();
DatesSeparatorsPositioner.apply(this.scroller);
} }
@action @action
@ -180,7 +177,7 @@ export default class ChatChannel extends Component {
return; return;
} }
stackingContextFix(this.scrollable, () => { stackingContextFix(this.scroller, () => {
this.messagesManager.addMessages([message]); this.messagesManager.addMessages([message]);
}); });
this.debouncedUpdateLastReadMessage(); this.debouncedUpdateLastReadMessage();
@ -244,7 +241,7 @@ export default class ChatChannel extends Component {
} }
const targetMessageId = this.messagesManager.messages.lastObject.id; const targetMessageId = this.messagesManager.messages.lastObject.id;
stackingContextFix(this.scrollable, () => { stackingContextFix(this.scroller, () => {
this.messagesManager.addMessages(messages); this.messagesManager.addMessages(messages);
}); });
@ -261,14 +258,14 @@ export default class ChatChannel extends Component {
@action @action
async scrollToBottom() { async scrollToBottom() {
this._ignoreNextScroll = true; this._ignoreNextScroll = true;
await scrollListToBottom(this.scrollable); await scrollListToBottom(this.scroller);
this.debouncedUpdateLastReadMessage(); this.debouncedUpdateLastReadMessage();
} }
scrollToMessageId(messageId, options = {}) { scrollToMessageId(messageId, options = {}) {
this._ignoreNextScroll = true; this._ignoreNextScroll = true;
const message = this.messagesManager.findMessage(messageId); const message = this.messagesManager.findMessage(messageId);
scrollListToMessage(this.scrollable, message, options); scrollListToMessage(this.scroller, message, options);
} }
debounceFillPaneAttempt() { debounceFillPaneAttempt() {
@ -309,12 +306,12 @@ export default class ChatChannel extends Component {
schedule("afterRender", () => { schedule("afterRender", () => {
const firstMessageId = this.messagesManager.messages.firstObject?.id; const firstMessageId = this.messagesManager.messages.firstObject?.id;
const messageContainer = this.scrollable.querySelector( const messageContainer = this.scroller.querySelector(
`.chat-message-container[data-id="${firstMessageId}"]` `.chat-message-container[data-id="${firstMessageId}"]`
); );
if ( if (
messageContainer && messageContainer &&
checkMessageTopVisibility(this.scrollable, messageContainer) checkMessageTopVisibility(this.scroller, messageContainer)
) { ) {
this.fetchMoreMessages({ direction: PAST }); this.fetchMoreMessages({ direction: PAST });
} }
@ -415,41 +412,28 @@ export default class ChatChannel extends Component {
return; return;
} }
schedule("afterRender", () => { if (!this.lastFullyVisibleMessageId) {
let lastFullyVisibleMessageNode = null; return;
}
this.scrollable let lastUnreadVisibleMessage = this.messagesManager.findMessage(
.querySelectorAll(".chat-message-container") this.lastFullyVisibleMessageId
.forEach((item) => { );
if (checkMessageBottomVisibility(this.scrollable, item)) {
lastFullyVisibleMessageNode = item;
}
});
if (!lastFullyVisibleMessageNode) { if (!lastUnreadVisibleMessage) {
return; return;
} }
let lastUnreadVisibleMessage = this.messagesManager.findMessage( const lastReadId =
lastFullyVisibleMessageNode.dataset.id this.args.channel.currentUserMembership?.lastReadMessageId;
); if (lastReadId >= lastUnreadVisibleMessage.id) {
return;
}
if (!lastUnreadVisibleMessage) { return this.chatApi.markChannelAsRead(
return; this.args.channel.id,
} lastUnreadVisibleMessage.id
);
const lastReadId =
this.args.channel.currentUserMembership?.lastReadMessageId;
// we don't return early if === as we want to ensure different tabs will do the check
if (lastReadId > lastUnreadVisibleMessage.id) {
return;
}
return this.chatApi.markChannelAsRead(
this.args.channel.id,
lastUnreadVisibleMessage.id
);
});
} }
@action @action
@ -457,7 +441,7 @@ export default class ChatChannel extends Component {
if (this.messagesLoader.canLoadMoreFuture) { if (this.messagesLoader.canLoadMoreFuture) {
this.fetchMessages(); this.fetchMessages();
} else if (this.messagesManager.messages.length > 0) { } else if (this.messagesManager.messages.length > 0) {
this.scrollToBottom(this.scrollable); this.scrollToBottom(this.scroller);
} }
} }
@ -468,7 +452,7 @@ export default class ChatChannel extends Component {
return; return;
} }
DatesSeparatorsPositioner.apply(this.scrollable); DatesSeparatorsPositioner.apply(this.scroller);
this.needsArrow = this.needsArrow =
(this.messagesLoader.fetchedOnce && (this.messagesLoader.fetchedOnce &&
@ -476,6 +460,7 @@ export default class ChatChannel extends Component {
(state.distanceToBottom.pixels > 250 && !state.atBottom); (state.distanceToBottom.pixels > 250 && !state.atBottom);
this.isScrolling = true; this.isScrolling = true;
this.debouncedUpdateLastReadMessage(); this.debouncedUpdateLastReadMessage();
this.lastFullyVisibleMessageId = state.lastVisibleId;
if ( if (
state.atTop || state.atTop ||
@ -499,6 +484,7 @@ export default class ChatChannel extends Component {
(state.distanceToBottom.pixels > 250 && !state.atBottom); (state.distanceToBottom.pixels > 250 && !state.atBottom);
this.isScrolling = false; this.isScrolling = false;
this.atBottom = state.atBottom; this.atBottom = state.atBottom;
this.lastFullyVisibleMessageId = state.lastVisibleId;
if (state.atBottom) { if (state.atBottom) {
this.fetchMoreMessages({ direction: FUTURE }); this.fetchMoreMessages({ direction: FUTURE });
@ -537,7 +523,7 @@ export default class ChatChannel extends Component {
this.resetComposerMessage(); this.resetComposerMessage();
try { try {
stackingContextFix(this.scrollable, async () => { stackingContextFix(this.scroller, async () => {
await this.chatApi.editMessage(this.args.channel.id, message.id, data); await this.chatApi.editMessage(this.args.channel.id, message.id, data);
}); });
} catch (e) { } catch (e) {
@ -553,7 +539,7 @@ export default class ChatChannel extends Component {
resetIdle(); resetIdle();
stackingContextFix(this.scrollable, async () => { stackingContextFix(this.scroller, async () => {
await this.args.channel.stageMessage(message); await this.args.channel.stageMessage(message);
}); });
@ -712,19 +698,12 @@ export default class ChatChannel extends Component {
<ChatNotices @channel={{@channel}} /> <ChatNotices @channel={{@channel}} />
<ChatMentionWarnings /> <ChatMentionWarnings />
<div <ChatMessagesScroller
class="chat-messages-scroll chat-messages-container popper-viewport" @onRegisterScroller={{this.registerScroller}}
{{didInsert this.setScrollable}} @onScroll={{this.onScroll}}
{{ChatScrollableList @onScrollEnd={{this.onScrollEnd}}
(hash
onScroll=this.onScroll onScrollEnd=this.onScrollEnd reverse=true
)
}}
> >
<div <ChatMessagesContainer @didResizePane={{this.didResizePane}}>
class="chat-messages-container"
{{ChatOnResize this.didResizePane (hash delay=100 immediate=true)}}
>
{{#each this.messagesManager.messages key="id" as |message|}} {{#each this.messagesManager.messages key="id" as |message|}}
<Message <Message
@message={{message}} @message={{message}}
@ -738,7 +717,7 @@ export default class ChatChannel extends Component {
<ChatSkeleton /> <ChatSkeleton />
{{/unless}} {{/unless}}
{{/each}} {{/each}}
</div> </ChatMessagesContainer>
{{! at bottom even if shown at top due to column-reverse }} {{! at bottom even if shown at top due to column-reverse }}
{{#if this.messagesLoader.loadedPast}} {{#if this.messagesLoader.loadedPast}}
@ -746,7 +725,7 @@ export default class ChatChannel extends Component {
{{i18n "chat.all_loaded"}} {{i18n "chat.all_loaded"}}
</div> </div>
{{/if}} {{/if}}
</div> </ChatMessagesScroller>
<ChatScrollToBottomArrow <ChatScrollToBottomArrow
@onScrollToBottom={{this.scrollToLatestMessage}} @onScrollToBottom={{this.scrollToLatestMessage}}
@ -769,7 +748,6 @@ export default class ChatChannel extends Component {
@channel={{@channel}} @channel={{@channel}}
@uploadDropZone={{this.uploadDropZone}} @uploadDropZone={{this.uploadDropZone}}
@onSendMessage={{this.onSendMessage}} @onSendMessage={{this.onSendMessage}}
@scrollable={{this.scrollable}}
/> />
{{/if}} {{/if}}
{{/if}} {{/if}}

View File

@ -0,0 +1,13 @@
import { hash } from "@ember/helper";
import ChatOnResize from "../modifiers/chat/on-resize";
const ChatMessagesContainer = <template>
<div
class="chat-messages-container"
{{ChatOnResize @didResizePane (hash delay=100 immediate=true)}}
>
{{yield}}
</div>
</template>;
export default ChatMessagesContainer;

View File

@ -0,0 +1,17 @@
import { hash } from "@ember/helper";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import ChatScrollableList from "../modifiers/chat/scrollable-list";
const ChatMessagesScroller = <template>
<div
class="chat-messages-scroller popper-viewport"
{{didInsert @onRegisterScroller}}
{{ChatScrollableList
(hash onScroll=@onScroll onScrollEnd=@onScrollEnd reverse=true)
}}
>
{{yield}}
</div>
</template>;
export default ChatMessagesScroller;

View File

@ -1,7 +1,6 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { cached, tracked } from "@glimmer/tracking"; import { cached, tracked } from "@glimmer/tracking";
import { getOwner } from "@ember/application"; import { getOwner } from "@ember/application";
import { hash } from "@ember/helper";
import { action } from "@ember/object"; import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert"; import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy"; import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
@ -30,12 +29,12 @@ import {
} from "discourse/plugins/chat/discourse/lib/scroll-helpers"; } from "discourse/plugins/chat/discourse/lib/scroll-helpers";
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
import UserChatThreadMembership from "discourse/plugins/chat/discourse/models/user-chat-thread-membership"; import UserChatThreadMembership from "discourse/plugins/chat/discourse/models/user-chat-thread-membership";
import ChatOnResize from "../modifiers/chat/on-resize";
import ChatScrollableList from "../modifiers/chat/scrollable-list";
import ChatComposerThread from "./chat/composer/thread"; import ChatComposerThread from "./chat/composer/thread";
import ChatScrollToBottomArrow from "./chat/scroll-to-bottom-arrow"; import ChatScrollToBottomArrow from "./chat/scroll-to-bottom-arrow";
import ChatSelectionManager from "./chat/selection-manager"; import ChatSelectionManager from "./chat/selection-manager";
import Message from "./chat-message"; import Message from "./chat-message";
import ChatMessagesContainer from "./chat-messages-container";
import ChatMessagesScroller from "./chat-messages-scroller";
import ChatSkeleton from "./chat-skeleton"; import ChatSkeleton from "./chat-skeleton";
import ChatUploadDropZone from "./chat-upload-drop-zone"; import ChatUploadDropZone from "./chat-upload-drop-zone";
@ -58,7 +57,7 @@ export default class ChatThread extends Component {
@tracked needsArrow = false; @tracked needsArrow = false;
@tracked uploadDropZone; @tracked uploadDropZone;
scrollable = null; scroller = null;
@action @action
resetIdle() { resetIdle() {
@ -116,7 +115,7 @@ export default class ChatThread extends Component {
return; return;
} }
DatesSeparatorsPositioner.apply(this.scrollable); DatesSeparatorsPositioner.apply(this.scroller);
this.needsArrow = this.needsArrow =
(this.messagesLoader.fetchedOnce && (this.messagesLoader.fetchedOnce &&
@ -124,6 +123,7 @@ export default class ChatThread extends Component {
(state.distanceToBottom.pixels > 250 && !state.atBottom); (state.distanceToBottom.pixels > 250 && !state.atBottom);
this.isScrolling = true; this.isScrolling = true;
this.debounceUpdateLastReadMessage(); this.debounceUpdateLastReadMessage();
this.lastFullyVisibleMessageId = state.lastVisibleId;
if ( if (
state.atTop || state.atTop ||
@ -148,6 +148,7 @@ export default class ChatThread extends Component {
this.resetIdle(); this.resetIdle();
this.atBottom = state.atBottom; this.atBottom = state.atBottom;
this.args.setFullTitle?.(state.atTop); this.args.setFullTitle?.(state.atTop);
this.lastFullyVisibleMessageId = state.lastVisibleId;
if (state.atBottom) { if (state.atBottom) {
this.fetchMoreMessages({ direction: FUTURE }); this.fetchMoreMessages({ direction: FUTURE });
@ -164,16 +165,37 @@ export default class ChatThread extends Component {
@bind @bind
updateLastReadMessage() { updateLastReadMessage() {
// HACK: We don't have proper scroll visibility over if (!this.args.thread?.currentUserMembership) {
// what message we are looking at, don't have the lastReadMessageId return;
// for the thread, and this updateLastReadMessage function is only }
// called when scrolling all the way to the bottom.
this.markThreadAsRead(); if (!this.lastFullyVisibleMessageId) {
return;
}
const lastUnreadVisibleMessage = this.messagesManager.findMessage(
this.lastFullyVisibleMessageId
);
if (!lastUnreadVisibleMessage) {
return;
}
const lastReadId = this.args.thread.currentUserMembership.lastReadMessageId;
if (lastReadId >= lastUnreadVisibleMessage.id) {
return;
}
return this.chatApi.markThreadAsRead(
this.args.thread.channel.id,
this.args.thread.id,
lastUnreadVisibleMessage.id
);
} }
@action @action
setScrollable(element) { registerScroller(element) {
this.scrollable = element; this.scroller = element;
} }
@action @action
@ -191,7 +213,7 @@ export default class ChatThread extends Component {
this._ignoreNextScroll = true; this._ignoreNextScroll = true;
this.debounceFillPaneAttempt(); this.debounceFillPaneAttempt();
this.debounceUpdateLastReadMessage(); this.debounceUpdateLastReadMessage();
DatesSeparatorsPositioner.apply(this.scrollable); DatesSeparatorsPositioner.apply(this.scroller);
} }
async fetchMessages(findArgs = {}) { async fetchMessages(findArgs = {}) {
@ -215,17 +237,13 @@ export default class ChatThread extends Component {
} }
const [messages, meta] = this.processMessages(this.args.thread, result); const [messages, meta] = this.processMessages(this.args.thread, result);
stackingContextFix(this.scrollable, () => { stackingContextFix(this.scroller, () => {
this.messagesManager.addMessages(messages); this.messagesManager.addMessages(messages);
}); });
this.args.thread.details = meta; this.args.thread.details = meta;
if (this.args.targetMessageId) { if (meta.target_message_id) {
this.scrollToMessageId(this.args.targetMessageId, { highlight: true }); this.scrollToMessageId(meta.target_message_id, { highlight: true });
} else if (this.args.thread.currentUserMembership?.lastReadMessageId) {
this.scrollToMessageId(
this.args.thread.currentUserMembership?.lastReadMessageId
);
} else { } else {
this.scrollToTop(); this.scrollToTop();
} }
@ -249,7 +267,7 @@ export default class ChatThread extends Component {
return; return;
} }
stackingContextFix(this.scrollable, () => { stackingContextFix(this.scroller, () => {
this.messagesManager.addMessages(messages); this.messagesManager.addMessages(messages);
}); });
this.args.thread.details = meta; this.args.thread.details = meta;
@ -311,7 +329,7 @@ export default class ChatThread extends Component {
) { ) {
this._ignoreNextScroll = true; this._ignoreNextScroll = true;
const message = this.messagesManager.findMessage(messageId); const message = this.messagesManager.findMessage(messageId);
scrollListToMessage(this.scrollable, message, opts); scrollListToMessage(this.scroller, message, opts);
} }
@bind @bind
@ -322,7 +340,7 @@ export default class ChatThread extends Component {
return; return;
} }
stackingContextFix(this.scrollable, () => { stackingContextFix(this.scroller, () => {
this.messagesManager.addMessages([message]); this.messagesManager.addMessages([message]);
}); });
} }
@ -345,20 +363,6 @@ export default class ChatThread extends Component {
return [messages, result.meta]; return [messages, result.meta];
} }
// NOTE: At some point we want to do this based on visible messages
// and scrolling; for now it's enough to do it when the thread panel
// opens/messages are loaded since we have no pagination for threads.
markThreadAsRead() {
if (!this.args.thread) {
return;
}
return this.chatApi.markThreadAsRead(
this.args.thread.channel.id,
this.args.thread.id
);
}
@action @action
async onSendMessage(message) { async onSendMessage(message) {
resetIdle(); resetIdle();
@ -424,7 +428,7 @@ export default class ChatThread extends Component {
this.chatThreadPane.sending = true; this.chatThreadPane.sending = true;
this._ignoreNextScroll = true; this._ignoreNextScroll = true;
stackingContextFix(this.scrollable, async () => { stackingContextFix(this.scroller, async () => {
await this.args.thread.stageMessage(message); await this.args.thread.stageMessage(message);
}); });
this.resetComposerMessage(); this.resetComposerMessage();
@ -495,13 +499,13 @@ export default class ChatThread extends Component {
@action @action
async scrollToBottom() { async scrollToBottom() {
this._ignoreNextScroll = true; this._ignoreNextScroll = true;
await scrollListToBottom(this.scrollable); await scrollListToBottom(this.scroller);
} }
@action @action
async scrollToTop() { async scrollToTop() {
this._ignoreNextScroll = true; this._ignoreNextScroll = true;
await scrollListToTop(this.scrollable); await scrollListToTop(this.scroller);
} }
@action @action
@ -538,19 +542,12 @@ export default class ChatThread extends Component {
{{didInsert this.setup}} {{didInsert this.setup}}
{{willDestroy this.teardown}} {{willDestroy this.teardown}}
> >
<div <ChatMessagesScroller
class="chat-thread__body popper-viewport chat-messages-scroll" @onRegisterScroller={{this.registerScroller}}
{{didInsert this.setScrollable}} @onScroll={{this.onScroll}}
{{ChatScrollableList @onScrollEnd={{this.onScrollEnd}}
(hash
onScroll=this.onScroll onScrollEnd=this.onScrollEnd reverse=true
)
}}
> >
<div <ChatMessagesContainer @didResizePane={{this.didResizePane}}>
class="chat-messages-container"
{{ChatOnResize this.didResizePane (hash delay=100 immediate=true)}}
>
{{#each this.messagesManager.messages key="id" as |message|}} {{#each this.messagesManager.messages key="id" as |message|}}
<Message <Message
@message={{message}} @message={{message}}
@ -566,8 +563,8 @@ export default class ChatThread extends Component {
<ChatSkeleton /> <ChatSkeleton />
{{/if}} {{/if}}
{{/unless}} {{/unless}}
</div> </ChatMessagesContainer>
</div> </ChatMessagesScroller>
<ChatScrollToBottomArrow <ChatScrollToBottomArrow
@onScrollToBottom={{this.scrollToLatestMessage}} @onScrollToBottom={{this.scrollToLatestMessage}}
@ -582,7 +579,6 @@ export default class ChatThread extends Component {
@thread={{@thread}} @thread={{@thread}}
@onSendMessage={{this.onSendMessage}} @onSendMessage={{this.onSendMessage}}
@uploadDropZone={{this.uploadDropZone}} @uploadDropZone={{this.uploadDropZone}}
@scrollable={{this.scrollable}}
/> />
{{/if}} {{/if}}

View File

@ -36,7 +36,6 @@ export default class ChatChannelSubscriptionManager {
teardown() { teardown() {
this.messageBus.unsubscribe(this.messageBusChannel, this.onMessage); this.messageBus.unsubscribe(this.messageBusChannel, this.onMessage);
this.modelId = null;
} }
@bind @bind
@ -194,7 +193,7 @@ export default class ChatChannelSubscriptionManager {
if (message) { if (message) {
message.deletedAt = null; message.deletedAt = null;
} else { } else {
const newMessage = ChatMessage.create(this.model, data.chat_message); const newMessage = ChatMessage.create(this.channel, data.chat_message);
newMessage.manager = this.messagesManager; newMessage.manager = this.messagesManager;
this.messagesManager.addMessages([newMessage]); this.messagesManager.addMessages([newMessage]);
} }

View File

@ -27,6 +27,8 @@ export default class ChatScrollableList extends Modifier {
this.element.addEventListener("wheel", this.handleWheel, { this.element.addEventListener("wheel", this.handleWheel, {
passive: true, passive: true,
}); });
this.throttleComputeScroll();
} }
@bind @bind

View File

@ -493,21 +493,25 @@ export default class ChatApi extends Service {
* @returns {Promise} * @returns {Promise}
*/ */
markChannelAsRead(channelId, messageId = null) { markChannelAsRead(channelId, messageId = null) {
return this.#putRequest(`/channels/${channelId}/read/${messageId}`); return this.#putRequest(
`/channels/${channelId}/read?message_id=${messageId}`
);
} }
/** /**
* Marks all messages and mentions in a thread as read. This is quite * Marks messages for a single user chat thread membership as read. If no
* far-reaching for now, and is not granular since there is no membership/ * message ID is provided, then the latest message for the channel is fetched
* read state per-user for threads. In future this will be expanded to * on the server and used for the last read message.
* also pass message ID in the same way as markChannelAsRead
* *
* @param {number} channelId - The ID of the channel for the thread being marked as read. * @param {number} channelId - The ID of the channel for the thread being marked as read.
* @param {number} threadId - The ID of the thread being marked as read. * @param {number} threadId - The ID of the thread being marked as read.
* @param {number} messageId - The ID of the message being marked as read.
* @returns {Promise} * @returns {Promise}
*/ */
markThreadAsRead(channelId, threadId) { markThreadAsRead(channelId, threadId, messageId) {
return this.#putRequest(`/channels/${channelId}/threads/${threadId}/read`); return this.#putRequest(
`/channels/${channelId}/threads/${threadId}/read?message_id=${messageId}`
);
} }
/** /**

View File

@ -195,7 +195,7 @@ body.has-full-page-chat {
top: var(--header-offset); top: var(--header-offset);
} }
.chat-messages-scroll { .chat-messages-scroller {
box-sizing: border-box; box-sizing: border-box;
height: 100%; height: 100%;
} }

View File

@ -9,31 +9,18 @@
min-width: 250px; min-width: 250px;
@include chat-height(var(--chat-header-offset, 0px)); @include chat-height(var(--chat-header-offset, 0px));
.chat-messages-scroll { .join-channel-btn.in-float {
flex-grow: 1; position: absolute;
overflow-y: scroll; transform: translateX(-50%);
overscroll-behavior: contain; left: 50%;
display: flex; top: 10px;
flex-direction: column-reverse; z-index: 10;
z-index: 1; }
margin: 0 1px 0 0;
will-change: transform;
@include chat-scrollbar();
min-height: 1px;
.join-channel-btn.in-float { .all-loaded-message {
position: absolute; text-align: center;
transform: translateX(-50%); color: var(--primary-medium);
left: 50%; font-size: var(--font-down-1);
top: 10px; padding: 0.5em 0.25em 0.25em;
z-index: 10;
}
.all-loaded-message {
text-align: center;
color: var(--primary-medium);
font-size: var(--font-down-1);
padding: 0.5em 0.25em 0.25em;
}
} }
} }

View File

@ -0,0 +1,14 @@
.chat-messages-scroller {
flex-grow: 1;
overflow-y: scroll;
overscroll-behavior: contain;
display: flex;
flex-direction: column-reverse;
z-index: 1;
margin: 0 1px 0 0;
will-change: transform;
@include chat-scrollbar();
min-height: 1px;
box-sizing: border-box;
transition: padding-top 0.2s ease-in-out;
}

View File

@ -3,15 +3,4 @@
flex-direction: column; flex-direction: column;
position: relative; position: relative;
@include chat-height(var(--chat-header-expanded-offset, 0px)); @include chat-height(var(--chat-header-expanded-offset, 0px));
&__body {
overflow-y: scroll;
@include chat-scrollbar();
box-sizing: border-box;
flex-grow: 1;
overscroll-behavior: contain;
display: flex;
flex-direction: column-reverse;
transition: padding-top 0.2s ease-in-out;
}
} }

View File

@ -69,3 +69,4 @@
@import "chat-thread-title"; @import "chat-thread-title";
@import "chat-audio-upload"; @import "chat-audio-upload";
@import "chat-message-text"; @import "chat-message-text";
@import "chat-messages-scroller";

View File

@ -1,5 +1,5 @@
.chat-channel { .chat-channel {
.chat-messages-scroll { .chat-messages-scroller {
padding-bottom: 5px; padding-bottom: 5px;
} }
} }

View File

@ -3,7 +3,7 @@
margin: 0 1px 0 0; margin: 0 1px 0 0;
} }
.chat-messages-scroll { .chat-messages-scroller {
padding: 10px 10px 0 10px; padding: 10px 10px 0 10px;
} }
} }

View File

@ -7,8 +7,8 @@ Chat::Engine.routes.draw do
get "/me/channels" => "current_user_channels#index" get "/me/channels" => "current_user_channels#index"
get "/me/threads" => "current_user_threads#index" get "/me/threads" => "current_user_threads#index"
post "/channels" => "channels#create" post "/channels" => "channels#create"
put "/channels/read/" => "reads#update_all" put "/channels/read" => "channels_read#update_all"
put "/channels/:channel_id/read/:message_id" => "reads#update" put "/channels/:channel_id/read" => "channels_read#update"
post "/channels/:channel_id/messages/:message_id/flags" => "channels_messages_flags#create" post "/channels/:channel_id/messages/:message_id/flags" => "channels_messages_flags#create"
post "/channels/:channel_id/drafts" => "channels_drafts#create" post "/channels/:channel_id/drafts" => "channels_drafts#create"
delete "/channels/:channel_id" => "channels#destroy" delete "/channels/:channel_id" => "channels#destroy"
@ -45,7 +45,7 @@ Chat::Engine.routes.draw do
put "/channels/:channel_id/threads/:thread_id" => "channel_threads#update" put "/channels/:channel_id/threads/:thread_id" => "channel_threads#update"
get "/channels/:channel_id/threads/:thread_id" => "channel_threads#show" get "/channels/:channel_id/threads/:thread_id" => "channel_threads#show"
get "/channels/:channel_id/threads/:thread_id/messages" => "channel_thread_messages#index" get "/channels/:channel_id/threads/:thread_id/messages" => "channel_thread_messages#index"
put "/channels/:channel_id/threads/:thread_id/read" => "thread_reads#update" put "/channels/:channel_id/threads/:thread_id/read" => "channels_threads_read#update"
post "/channels/:channel_id/threads/:thread_id/drafts" => "channels_threads_drafts#create" post "/channels/:channel_id/threads/:thread_id/drafts" => "channels_threads_drafts#create"
put "/channels/:channel_id/threads/:thread_id/notifications-settings/me" => put "/channels/:channel_id/threads/:thread_id/notifications-settings/me" =>
"channel_threads_current_user_notifications_settings#update" "channel_threads_current_user_notifications_settings#update"

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
RSpec.describe Chat::Api::ReadsController do RSpec.describe Chat::Api::ChannelsReadController do
fab!(:current_user) { Fabricate(:user) } fab!(:current_user) { Fabricate(:user) }
before do before do
@ -17,7 +17,7 @@ RSpec.describe Chat::Api::ReadsController do
fab!(:message_2) { Fabricate(:chat_message, chat_channel: chat_channel, user: other_user) } fab!(:message_2) { Fabricate(:chat_message, chat_channel: chat_channel, user: other_user) }
it "returns a 404 when the user is not a channel member" do it "returns a 404 when the user is not a channel member" do
put "/chat/api/channels/#{chat_channel.id}/read/#{message_1.id}.json" put "/chat/api/channels/#{chat_channel.id}/read?message_id=#{message_1.id}.json"
expect(response.status).to eq(404) expect(response.status).to eq(404)
end end
@ -29,7 +29,7 @@ RSpec.describe Chat::Api::ReadsController do
following: false, following: false,
) )
put "/chat/api/channels/#{chat_channel.id}/read/#{message_1.id}.json" put "/chat/api/channels/#{chat_channel.id}/read?message_id=#{message_1.id}.json"
expect(response.status).to eq(404) expect(response.status).to eq(404)
end end
@ -49,7 +49,7 @@ RSpec.describe Chat::Api::ReadsController do
before { membership.update!(last_read_message_id: message_2.id) } before { membership.update!(last_read_message_id: message_2.id) }
it "raises an invalid request" do it "raises an invalid request" do
put "/chat/api/channels/#{chat_channel.id}/read/#{message_1.id}.json" put "/chat/api/channels/#{chat_channel.id}/read?message_id=#{message_1.id}.json"
expect(response.status).to eq(400) expect(response.status).to eq(400)
expect(response.parsed_body["errors"][0]).to match(/message_id/) expect(response.parsed_body["errors"][0]).to match(/message_id/)
end end
@ -59,14 +59,14 @@ RSpec.describe Chat::Api::ReadsController do
before { message_1.trash!(Discourse.system_user) } before { message_1.trash!(Discourse.system_user) }
it "works" do it "works" do
put "/chat/api/channels/#{chat_channel.id}/read/#{message_1.id}" put "/chat/api/channels/#{chat_channel.id}/read?message_id=#{message_1.id}"
expect(response.status).to eq(200) expect(response.status).to eq(200)
end end
end end
it "updates timing records" do it "updates timing records" do
expect { expect {
put "/chat/api/channels/#{chat_channel.id}/read/#{message_1.id}.json" put "/chat/api/channels/#{chat_channel.id}/read?message_id=#{message_1.id}.json"
}.not_to change { Chat::UserChatChannelMembership.count } }.not_to change { Chat::UserChatChannelMembership.count }
membership.reload membership.reload
@ -78,7 +78,7 @@ RSpec.describe Chat::Api::ReadsController do
it "marks all mention notifications as read for the channel" do it "marks all mention notifications as read for the channel" do
notification = create_notification_and_mention_for(current_user, other_user, message_1) notification = create_notification_and_mention_for(current_user, other_user, message_1)
put "/chat/api/channels/#{chat_channel.id}/read/#{message_2.id}.json" put "/chat/api/channels/#{chat_channel.id}/read?message_id=#{message_2.id}.json"
expect(response.status).to eq(200) expect(response.status).to eq(200)
expect(notification.reload.read).to eq(true) expect(notification.reload.read).to eq(true)
end end
@ -87,7 +87,7 @@ RSpec.describe Chat::Api::ReadsController do
message_3 = Fabricate(:chat_message, chat_channel: chat_channel, user: other_user) message_3 = Fabricate(:chat_message, chat_channel: chat_channel, user: other_user)
notification = create_notification_and_mention_for(current_user, other_user, message_3) notification = create_notification_and_mention_for(current_user, other_user, message_3)
put "/chat/api/channels/#{chat_channel.id}/read/#{message_2.id}.json" put "/chat/api/channels/#{chat_channel.id}/read?message_id=#{message_2.id}.json"
expect(response.status).to eq(200) expect(response.status).to eq(200)
expect(notification.reload.read).to eq(false) expect(notification.reload.read).to eq(false)
end end

View File

@ -0,0 +1,64 @@
# frozen_string_literal: true
RSpec.describe Chat::Api::ChannelsThreadsReadController do
fab!(:current_user) { Fabricate(:user) }
before do
SiteSetting.chat_enabled = true
SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone]
sign_in(current_user)
end
describe "#update" do
context "with valid params" do
fab!(:thread_1) { Fabricate(:chat_thread) }
before { thread_1.add(current_user) }
it "is a success" do
put "/chat/api/channels/#{thread_1.channel.id}/threads/#{thread_1.id}/read.json"
expect(response.status).to eq(200)
end
context "when a message_id is provided" do
fab!(:message_1) do
Fabricate(:chat_message, thread: thread_1, chat_channel: thread_1.channel)
end
it "updates the last read" do
expect {
put "/chat/api/channels/#{thread_1.channel.id}/threads/#{thread_1.id}/read?message_id=#{message_1.id}.json"
}.to change { thread_1.membership_for(current_user).last_read_message_id }.from(nil).to(
message_1.id,
)
expect(response.status).to eq(200)
end
end
end
context "when the thread doesn't exist" do
fab!(:channel_1) { Fabricate(:chat_channel) }
it "raises a not found" do
put "/chat/api/channels/#{channel_1.id}/threads/-999/read.json"
expect(response.status).to eq(404)
end
end
context "when the user can't join associated channel" do
fab!(:channel_1) { Fabricate(:private_category_channel) }
fab!(:thread_1) { Fabricate(:chat_thread, channel: channel_1) }
before { thread_1.add(current_user) }
it "raises a not found" do
put "/chat/api/channels/#{thread_1.channel.id}/threads/#{thread_1.id}/read.json"
expect(response.status).to eq(403)
end
end
end
end

View File

@ -1,72 +0,0 @@
# frozen_string_literal: true
RSpec.describe Chat::Api::ThreadReadsController do
fab!(:current_user) { Fabricate(:user) }
before do
SiteSetting.chat_enabled = true
SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone]
sign_in(current_user)
end
describe "#update" do
describe "marking the thread messages as read" do
fab!(:channel) { Fabricate(:chat_channel) }
fab!(:other_user) { Fabricate(:user) }
fab!(:thread) { Fabricate(:chat_thread, channel: channel) }
fab!(:message_1) do
Fabricate(:chat_message, chat_channel: channel, user: other_user, thread: thread)
end
fab!(:message_2) do
Fabricate(:chat_message, chat_channel: channel, user: other_user, thread: thread)
end
context "when the user cannot access the channel" do
fab!(:channel) { Fabricate(:private_category_channel) }
fab!(:thread) { Fabricate(:chat_thread, channel: channel) }
it "raises invalid access" do
put "/chat/api/channels/#{channel.id}/threads/#{thread.id}/read.json"
expect(response.status).to eq(403)
end
end
context "when the channel_id and thread_id params do not match" do
it "raises a not found" do
put "/chat/api/channels/#{Fabricate(:chat_channel).id}/threads/#{thread.id}/read.json"
expect(response.status).to eq(404)
end
end
it "marks all mention notifications as read for the thread" do
notification_1 = create_notification_and_mention_for(current_user, other_user, message_1)
notification_2 = create_notification_and_mention_for(current_user, other_user, message_2)
put "/chat/api/channels/#{channel.id}/threads/#{thread.id}/read.json"
expect(response.status).to eq(200)
expect(notification_1.reload.read).to eq(true)
expect(notification_2.reload.read).to eq(true)
end
end
end
def create_notification_and_mention_for(user, sender, msg)
Notification
.create!(
notification_type: Notification.types[:chat_mention],
user: user,
high_priority: true,
read: false,
data: {
message: "chat.mention_notification",
chat_message_id: msg.id,
chat_channel_id: msg.chat_channel_id,
chat_channel_title: msg.chat_channel.title(user),
chat_channel_slug: msg.chat_channel.slug,
mentioned_by_username: sender.username,
}.to_json,
)
.tap do |notification|
Chat::UserMention.create!(user: user, chat_message: msg, notifications: [notification])
end
end
end

View File

@ -170,9 +170,9 @@ RSpec.describe ::Chat::LookupChannelThreads do
[thread_4, thread_5, thread_6, thread_7].each do |t| [thread_4, thread_5, thread_6, thread_7].each do |t|
t.add(current_user) t.add(current_user)
t.mark_read_for_user!(current_user) t.membership_for(current_user).mark_read!
end end
[thread_1, thread_2, thread_3].each { |t| t.mark_read_for_user!(current_user) } [thread_1, thread_2, thread_3].each { |t| t.membership_for(current_user).mark_read! }
# The old unread messages. # The old unread messages.
Fabricate(:chat_message, chat_channel: channel_1, thread: thread_7).update!( Fabricate(:chat_message, chat_channel: channel_1, thread: thread_7).update!(

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
RSpec.describe Chat::UpdateUserLastRead do RSpec.describe Chat::UpdateUserChannelLastRead do
describe Chat::UpdateUserLastRead::Contract, type: :model do describe Chat::UpdateUserChannelLastRead::Contract, type: :model do
it { is_expected.to validate_presence_of :channel_id } it { is_expected.to validate_presence_of :channel_id }
it { is_expected.to validate_presence_of :message_id } it { is_expected.to validate_presence_of :message_id }
end end
@ -32,7 +32,7 @@ RSpec.describe Chat::UpdateUserLastRead do
context "when user has no membership" do context "when user has no membership" do
before { membership.destroy! } before { membership.destroy! }
it { is_expected.to fail_to_find_a_model(:active_membership) } it { is_expected.to fail_to_find_a_model(:membership) }
end end
context "when user cant access the channel" do context "when user cant access the channel" do

View File

@ -11,15 +11,23 @@ RSpec.describe Chat::UpdateUserThreadLastRead do
fab!(:chatters) { Fabricate(:group) } fab!(:chatters) { Fabricate(:group) }
fab!(:current_user) { Fabricate(:user, group_ids: [chatters.id]) } fab!(:current_user) { Fabricate(:user, group_ids: [chatters.id]) }
fab!(:channel) { Fabricate(:chat_channel) } fab!(:thread) { Fabricate(:chat_thread, old_om: true) }
fab!(:thread) { Fabricate(:chat_thread, channel: channel, old_om: true) } fab!(:reply_1) { Fabricate(:chat_message, thread: thread, chat_channel_id: thread.channel.id) }
fab!(:thread_reply_1) { Fabricate(:chat_message, chat_channel: channel, thread: thread) }
fab!(:thread_reply_2) { Fabricate(:chat_message, chat_channel: channel, thread: thread) }
let(:guardian) { Guardian.new(current_user) } let(:guardian) { Guardian.new(current_user) }
let(:params) { { guardian: guardian, channel_id: channel.id, thread_id: thread.id } } let(:params) do
{
message_id: reply_1.id,
guardian: guardian,
channel_id: thread.channel.id,
thread_id: thread.id,
}
end
before { SiteSetting.chat_allowed_groups = [chatters] } before do
thread.add(current_user)
SiteSetting.chat_allowed_groups = [chatters]
end
context "when params are not valid" do context "when params are not valid" do
before { params.delete(:thread_id) } before { params.delete(:thread_id) }
@ -27,81 +35,108 @@ RSpec.describe Chat::UpdateUserThreadLastRead do
it { is_expected.to fail_a_contract } it { is_expected.to fail_a_contract }
end end
context "when thread cannot be found" do
before { params[:channel_id] = Fabricate(:chat_channel).id }
it { is_expected.to fail_to_find_a_model(:thread) }
end
context "when user has no membership" do
before { thread.remove(current_user) }
it { is_expected.to fail_to_find_a_model(:membership) }
end
context "when user cant access the channel" do
fab!(:channel) { Fabricate(:private_category_channel) }
fab!(:thread) { Fabricate(:chat_thread, channel: channel) }
it { is_expected.to fail_a_policy(:invalid_access) }
end
context "when params are valid" do context "when params are valid" do
context "when user cant access the channel" do it "sets the service result as successful" do
fab!(:channel) { Fabricate(:private_category_channel) } expect(result).to be_a_success
fab!(:thread) { Fabricate(:chat_thread, channel: channel) }
it { is_expected.to fail_a_policy(:invalid_access) }
end end
context "when thread cannot be found" do it "publishes new last read to clients" do
before { params[:channel_id] = Fabricate(:chat_channel).id } messages = MessageBus.track_publish { result }
expect(messages.map(&:channel)).to include("/chat/user-tracking-state/#{current_user.id}")
it { is_expected.to fail_to_find_a_model(:thread) }
end end
context "when everything is fine" do context "when the user is a member of the thread" do
fab!(:notification_1) do fab!(:membership) do
Fabricate( Fabricate(:user_chat_thread_membership, user: current_user, thread: thread)
:notification,
notification_type: Notification.types[:chat_mention],
user: current_user,
)
end end
fab!(:notification_2) do
Fabricate( it "updates the last_read_message_id of the thread" do
:notification, expect { result }.to change { membership.reload.last_read_message_id }.from(nil).to(
notification_type: Notification.types[:chat_mention], reply_1.id,
user: current_user,
) )
end end
let(:messages) { MessageBus.track_publish { result } } context "when the provided last read id is before the existing one" do
fab!(:reply_2) { Fabricate(:chat_message, thread: thread) }
before do before { thread.membership_for(current_user).update!(last_read_message_id: reply_2.id) }
Jobs.run_immediately!
Chat::UserMention.create!( it { is_expected.to fail_a_policy(:ensure_valid_message) }
notifications: [notification_1],
user: current_user,
chat_message: Fabricate(:chat_message, chat_channel: channel, thread: thread),
)
Chat::UserMention.create!(
notifications: [notification_2],
user: current_user,
chat_message: Fabricate(:chat_message, chat_channel: channel, thread: thread),
)
end end
it "sets the service result as successful" do context "when the message doesnt exist" do
expect(result).to be_a_success it "fails" do
end params[:message_id] = 999
is_expected.to fail_to_find_a_model(:message)
it "marks existing notifications related to all messages in the thread as read" do
expect { result }.to change {
Notification.where(
notification_type: Notification.types[:chat_mention],
user: current_user,
read: false,
).count
}.by(-2)
end
it "publishes new last read to clients" do
expect(messages.map(&:channel)).to include("/chat/user-tracking-state/#{current_user.id}")
end
context "when the user is a member of the thread" do
fab!(:membership) do
Fabricate(:user_chat_thread_membership, user: current_user, thread: thread)
end
it "updates the last_read_message_id of the thread" do
result
expect(membership.reload.last_read_message_id).to eq(thread.reload.last_message.id)
end end
end end
end end
end end
context "when unread messages have associated notifications" do
before_all do
Jobs.run_immediately!
thread.channel.add(current_user)
end
fab!(:reply_2) do
Fabricate(
:chat_message,
thread: thread,
message: "hi @#{current_user.username}",
use_service: true,
)
end
fab!(:reply_3) do
Fabricate(
:chat_message,
thread: thread,
message: "hi @#{current_user.username}",
use_service: true,
)
end
it "marks notifications as read" do
params[:message_id] = reply_2.id
expect { described_class.call(params) }.to change {
::Notification
.where(notification_type: Notification.types[:chat_mention])
.where(user: current_user)
.where(read: false)
.count
}.by(-1)
params[:message_id] = reply_3.id
expect { described_class.call(params) }.to change {
::Notification
.where(notification_type: Notification.types[:chat_mention])
.where(user: current_user)
.where(read: false)
.count
}.by(-1)
end
end
end end
end end

View File

@ -146,7 +146,7 @@ RSpec.describe "Chat channel", type: :system do
end end
context "when returning to a channel where last read is not last message" do context "when returning to a channel where last read is not last message" do
it "jumps to the bottom of the channel" do it "scrolls to the correct last read message" do
channel_1.membership_for(current_user).update!(last_read_message: message_1) channel_1.membership_for(current_user).update!(last_read_message: message_1)
messages = Fabricate.times(50, :chat_message, chat_channel: channel_1) messages = Fabricate.times(50, :chat_message, chat_channel: channel_1)
chat_page.visit_channel(channel_1) chat_page.visit_channel(channel_1)
@ -348,7 +348,7 @@ RSpec.describe "Chat channel", type: :system do
".chat-message-actions-container[data-id='#{last_message["data-id"]}']", ".chat-message-actions-container[data-id='#{last_message["data-id"]}']",
) )
find(".chat-messages-scroll").scroll_to(0, -1000) find(".chat-messages-scroller").scroll_to(0, -1000)
expect(page).to have_no_css( expect(page).to have_no_css(
".chat-message-actions-container[data-id='#{last_message["data-id"]}']", ".chat-message-actions-container[data-id='#{last_message["data-id"]}']",

View File

@ -6,7 +6,7 @@ module PageObjects
class Messages < PageObjects::Components::Base class Messages < PageObjects::Components::Base
attr_reader :context attr_reader :context
SELECTOR = ".chat-messages-scroll" SELECTOR = ".chat-messages-scroller"
def initialize(context) def initialize(context)
@context = context @context = context

View File

@ -34,6 +34,17 @@ describe "Single thread in side panel", type: :system do
fab!(:channel) { Fabricate(:chat_channel, threading_enabled: true) } fab!(:channel) { Fabricate(:chat_channel, threading_enabled: true) }
fab!(:thread) { chat_thread_chain_bootstrap(channel: channel, users: [current_user, user_2]) } fab!(:thread) { chat_thread_chain_bootstrap(channel: channel, users: [current_user, user_2]) }
context "when returning to a thread where last read is not last message" do
it "scrolls to the correct last read message" do
message_1 = Fabricate(:chat_message, thread: thread, chat_channel: channel)
thread.membership_for(current_user).update!(last_read_message: message_1)
messages = Fabricate.times(50, :chat_message, thread: thread, chat_channel: channel)
chat_page.visit_thread(thread)
expect(page).to have_css("[data-id='#{message_1.id}'].-highlighted")
end
end
context "when in full page" do context "when in full page" do
context "when switching channel" do context "when switching channel" do
fab!(:channel_2) { Fabricate(:chat_channel, threading_enabled: true) } fab!(:channel_2) { Fabricate(:chat_channel, threading_enabled: true) }

View File

@ -23,8 +23,8 @@ RSpec.describe "Update last read", type: :system do
chat_page.visit_channel(channel_1) chat_page.visit_channel(channel_1)
try_until_success do try_until_success do
page.execute_script("document.querySelector('.chat-messages-scroll').scrollTo(0, 1)") page.execute_script("document.querySelector('.chat-messages-scroller').scrollTo(0, 1)")
page.execute_script("document.querySelector('.chat-messages-scroll').scrollTo(0, 0)") page.execute_script("document.querySelector('.chat-messages-scroller').scrollTo(0, 0)")
expect(membership.reload.last_read_message_id).to eq(last_message.id) expect(membership.reload.last_read_message_id).to eq(last_message.id)
end end
end end