FEATURE: Reacting to MessageBus in chat thread panel (#21070)

This commit introduces a ChatChannelPaneSubscriptionsManager
and a ChatChannelThreadPaneSubscriptionsManager that inherits
from the first service that handle MessageBus subscriptions
for the main channel and the thread panel respectively.

This necessitated a change to Chat::Publisher to be able to
send MessageBus messages to multiple channels based on whether
a message was an OM for a thread, a thread reply, or a regular
channel message.

An initial change to update the thread indicator with new replies
has been done too, but that will be improved in future as we have
more data to update on the indicators.

Still remaining is to fully move over the handleSentMessage
functionality which includes scrolling and new message indicator
things.

Co-authored-by: Joffrey JAFFEUX <j.jaffeux@gmail.com>
This commit is contained in:
Martin Brennan
2023-04-13 22:45:50 +10:00
committed by GitHub
parent e52f322cb5
commit bd5c5c4b5f
27 changed files with 818 additions and 355 deletions

View File

@ -312,11 +312,11 @@ module Chat
end end
def thread_reply? def thread_reply?
in_thread? && !is_thread_om? in_thread? && !thread_om?
end end
def is_thread_om? def thread_om?
self.thread.original_message_id == self.id in_thread? && self.thread.original_message_id == self.id
end end
private private

View File

@ -5,6 +5,22 @@ module Chat
has_one :original_message_user, serializer: BasicUserWithStatusSerializer, embed: :objects has_one :original_message_user, serializer: BasicUserWithStatusSerializer, embed: :objects
has_one :original_message, serializer: Chat::ThreadOriginalMessageSerializer, embed: :objects has_one :original_message, serializer: Chat::ThreadOriginalMessageSerializer, embed: :objects
attributes :id, :title, :status attributes :id, :title, :status, :channel_id, :meta
def initialize(object, opts)
super(object, opts)
@opts = opts
end
def meta
{ message_bus_last_ids: { thread_message_bus_last_id: thread_message_bus_last_id } }
end
private
def thread_message_bus_last_id
@opts[:thread_message_bus_last_id] ||
MessageBus.last_id(Chat::Publisher.thread_message_bus_channel(object.channel_id, object.id))
end
end end
end end

View File

@ -10,8 +10,27 @@ module Chat
"/chat/#{chat_channel_id}" "/chat/#{chat_channel_id}"
end end
def self.thread_message_bus_channel(chat_channel_id, thread_id)
"#{root_message_bus_channel(chat_channel_id)}/thread/#{thread_id}"
end
def self.calculate_publish_targets(channel, message)
targets =
if message.thread_om?
[
root_message_bus_channel(channel.id),
thread_message_bus_channel(channel.id, message.thread_id),
]
elsif message.thread_reply?
[thread_message_bus_channel(channel.id, message.thread_id)]
else
[root_message_bus_channel(channel.id)]
end
targets
end
def self.publish_new!(chat_channel, chat_message, staged_id) def self.publish_new!(chat_channel, chat_message, staged_id)
return if chat_message.thread_reply? message_bus_targets = calculate_publish_targets(chat_channel, chat_message)
content = content =
Chat::MessageSerializer.new( Chat::MessageSerializer.new(
@ -22,8 +41,26 @@ module Chat
content[:staged_id] = staged_id content[:staged_id] = staged_id
permissions = permissions(chat_channel) permissions = permissions(chat_channel)
MessageBus.publish(root_message_bus_channel(chat_channel.id), content.as_json, permissions) message_bus_targets.each do |message_bus_channel|
MessageBus.publish(message_bus_channel, content.as_json, permissions)
end
if chat_message.thread_reply?
MessageBus.publish(
root_message_bus_channel(chat_channel.id),
{
type: :update_thread_original_message,
original_message_id: chat_message.thread.original_message_id,
action: :increment_reply_count,
}.as_json,
permissions,
)
end
# NOTE: This means that the read count is only updated in the client
# for new messages in the main channel stream, maybe in future we want to
# do this for thread messages as well?
if !chat_message.thread_reply?
MessageBus.publish( MessageBus.publish(
self.new_messages_message_bus_channel(chat_channel.id), self.new_messages_message_bus_channel(chat_channel.id),
{ {
@ -36,6 +73,7 @@ module Chat
permissions, permissions,
) )
end end
end
def self.publish_thread_created!(chat_channel, chat_message) def self.publish_thread_created!(chat_channel, chat_message)
content = content =
@ -50,7 +88,7 @@ module Chat
end end
def self.publish_processed!(chat_message) def self.publish_processed!(chat_message)
return if chat_message.thread_reply? message_bus_targets = calculate_publish_targets(chat_channel, chat_message)
chat_channel = chat_message.chat_channel chat_channel = chat_message.chat_channel
content = { content = {
@ -60,15 +98,14 @@ module Chat
cooked: chat_message.cooked, cooked: chat_message.cooked,
}, },
} }
MessageBus.publish(
root_message_bus_channel(chat_channel.id), message_bus_targets.each do |message_bus_channel|
content.as_json, MessageBus.publish(message_bus_channel, content.as_json, permissions(chat_channel))
permissions(chat_channel), end
)
end end
def self.publish_edit!(chat_channel, chat_message) def self.publish_edit!(chat_channel, chat_message)
return if chat_message.thread_reply? message_bus_targets = calculate_publish_targets(chat_channel, chat_message)
content = content =
Chat::MessageSerializer.new( Chat::MessageSerializer.new(
@ -76,15 +113,14 @@ module Chat
{ scope: anonymous_guardian, root: :chat_message }, { scope: anonymous_guardian, root: :chat_message },
).as_json ).as_json
content[:type] = :edit content[:type] = :edit
MessageBus.publish(
root_message_bus_channel(chat_channel.id), message_bus_targets.each do |message_bus_channel|
content.as_json, MessageBus.publish(message_bus_channel, content.as_json, permissions(chat_channel))
permissions(chat_channel), end
)
end end
def self.publish_refresh!(chat_channel, chat_message) def self.publish_refresh!(chat_channel, chat_message)
return if chat_message.thread_reply? message_bus_targets = calculate_publish_targets(chat_channel, chat_message)
content = content =
Chat::MessageSerializer.new( Chat::MessageSerializer.new(
@ -92,15 +128,14 @@ module Chat
{ scope: anonymous_guardian, root: :chat_message }, { scope: anonymous_guardian, root: :chat_message },
).as_json ).as_json
content[:type] = :refresh content[:type] = :refresh
MessageBus.publish(
root_message_bus_channel(chat_channel.id), message_bus_targets.each do |message_bus_channel|
content.as_json, MessageBus.publish(message_bus_channel, content.as_json, permissions(chat_channel))
permissions(chat_channel), end
)
end end
def self.publish_reaction!(chat_channel, chat_message, action, user, emoji) def self.publish_reaction!(chat_channel, chat_message, action, user, emoji)
return if chat_message.thread_reply? message_bus_targets = calculate_publish_targets(chat_channel, chat_message)
content = { content = {
action: action, action: action,
@ -109,11 +144,10 @@ module Chat
type: :reaction, type: :reaction,
chat_message_id: chat_message.id, chat_message_id: chat_message.id,
} }
MessageBus.publish(
root_message_bus_channel(chat_channel.id), message_bus_targets.each do |message_bus_channel|
content.as_json, MessageBus.publish(message_bus_channel, content.as_json, permissions(chat_channel))
permissions(chat_channel), end
)
end end
def self.publish_presence!(chat_channel, user, typ) def self.publish_presence!(chat_channel, user, typ)
@ -121,16 +155,20 @@ module Chat
end end
def self.publish_delete!(chat_channel, chat_message) def self.publish_delete!(chat_channel, chat_message)
return if chat_message.thread_reply? message_bus_targets = calculate_publish_targets(chat_channel, chat_message)
message_bus_targets.each do |message_bus_channel|
MessageBus.publish( MessageBus.publish(
root_message_bus_channel(chat_channel.id), message_bus_channel,
{ type: "delete", deleted_id: chat_message.id, deleted_at: chat_message.deleted_at }, { type: "delete", deleted_id: chat_message.id, deleted_at: chat_message.deleted_at },
permissions(chat_channel), permissions(chat_channel),
) )
end end
end
def self.publish_bulk_delete!(chat_channel, deleted_message_ids) def self.publish_bulk_delete!(chat_channel, deleted_message_ids)
# TODO (martin) Handle sending this through for all the threads that
# may contain the deleted messages as well.
MessageBus.publish( MessageBus.publish(
root_message_bus_channel(chat_channel.id), root_message_bus_channel(chat_channel.id),
{ typ: "bulk_delete", deleted_ids: deleted_message_ids, deleted_at: Time.zone.now }, { typ: "bulk_delete", deleted_ids: deleted_message_ids, deleted_at: Time.zone.now },
@ -139,7 +177,7 @@ module Chat
end end
def self.publish_restore!(chat_channel, chat_message) def self.publish_restore!(chat_channel, chat_message)
return if chat_message.thread_reply? message_bus_targets = calculate_publish_targets(chat_channel, chat_message)
content = content =
Chat::MessageSerializer.new( Chat::MessageSerializer.new(
@ -147,19 +185,19 @@ module Chat
{ scope: anonymous_guardian, root: :chat_message }, { scope: anonymous_guardian, root: :chat_message },
).as_json ).as_json
content[:type] = :restore content[:type] = :restore
MessageBus.publish(
root_message_bus_channel(chat_channel.id), message_bus_targets.each do |message_bus_channel|
content.as_json, MessageBus.publish(message_bus_channel, content.as_json, permissions(chat_channel))
permissions(chat_channel), end
)
end end
def self.publish_flag!(chat_message, user, reviewable, score) def self.publish_flag!(chat_message, user, reviewable, score)
return if chat_message.thread_reply? message_bus_targets = calculate_publish_targets(chat_message.chat_channel, chat_message)
message_bus_targets.each do |message_bus_channel|
# Publish to user who created flag # Publish to user who created flag
MessageBus.publish( MessageBus.publish(
"/chat/#{chat_message.chat_channel_id}", message_bus_channel,
{ {
type: "self_flagged", type: "self_flagged",
user_flag_status: score.status_for_database, user_flag_status: score.status_for_database,
@ -167,14 +205,17 @@ module Chat
}.as_json, }.as_json,
user_ids: [user.id], user_ids: [user.id],
) )
end
message_bus_targets.each do |message_bus_channel|
# Publish flag with link to reviewable to staff # Publish flag with link to reviewable to staff
MessageBus.publish( MessageBus.publish(
"/chat/#{chat_message.chat_channel_id}", message_bus_channel,
{ type: "flag", chat_message_id: chat_message.id, reviewable_id: reviewable.id }.as_json, { type: "flag", chat_message_id: chat_message.id, reviewable_id: reviewable.id }.as_json,
group_ids: [Group::AUTO_GROUPS[:staff]], group_ids: [Group::AUTO_GROUPS[:staff]],
) )
end end
end
def self.user_tracking_state_message_bus_channel(user_id) def self.user_tracking_state_message_bus_channel(user_id)
"/chat/user-tracking-state/#{user_id}" "/chat/user-tracking-state/#{user_id}"

View File

@ -4,7 +4,10 @@ import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
import ChatMessageDraft from "discourse/plugins/chat/discourse/models/chat-message-draft"; import ChatMessageDraft from "discourse/plugins/chat/discourse/models/chat-message-draft";
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { bind, debounce } from "discourse-common/utils/decorators"; import { bind, debounce } from "discourse-common/utils/decorators";
import EmberObject, { action } from "@ember/object"; import { action } from "@ember/object";
// TODO (martin) Remove this when the handleSentMessage logic inside chatChannelPaneSubscriptionsManager
// is moved over from this file completely.
import { handleStagedMessage } from "discourse/plugins/chat/discourse/services/chat-pane-base-subscriptions-manager";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
import { cancel, schedule, throttle } from "@ember/runloop"; import { cancel, schedule, throttle } from "@ember/runloop";
@ -34,6 +37,7 @@ export default class ChatLivePane extends Component {
@service chatStateManager; @service chatStateManager;
@service chatChannelComposer; @service chatChannelComposer;
@service chatChannelPane; @service chatChannelPane;
@service chatChannelPaneSubscriptionsManager;
@service chatApi; @service chatApi;
@service currentUser; @service currentUser;
@service appEvents; @service appEvents;
@ -108,7 +112,7 @@ export default class ChatLivePane extends Component {
} }
this.loadMessages(); this.loadMessages();
this._subscribeToUpdates(this.args.channel?.id); this._subscribeToUpdates(this.args.channel);
} }
@action @action
@ -209,8 +213,8 @@ export default class ChatLivePane extends Component {
const loadingMoreKey = `loadingMore${capitalize(direction)}`; const loadingMoreKey = `loadingMore${capitalize(direction)}`;
const canLoadMore = loadingPast const canLoadMore = loadingPast
? this.args.channel.messagesManager.canLoadMorePast ? this.#messagesManager.canLoadMorePast
: this.args.channel.messagesManager.canLoadMoreFuture; : this.#messagesManager.canLoadMoreFuture;
if ( if (
!canLoadMore || !canLoadMore ||
@ -261,7 +265,7 @@ export default class ChatLivePane extends Component {
} }
this.args.channel.details = meta; this.args.channel.details = meta;
this.args.channel.messagesManager.addMessages(messages); this.#messagesManager.addMessages(messages);
// Edge case for IOS to avoid blank screens // Edge case for IOS to avoid blank screens
// and/or scrolling to bottom losing track of scroll position // and/or scrolling to bottom losing track of scroll position
@ -508,9 +512,9 @@ export default class ChatLivePane extends Component {
} }
removeMessage(msgData) { removeMessage(msgData) {
const message = this.args.channel.messagesManager.findMessage(msgData.id); const message = this.#messagesManager.findMessage(msgData.id);
if (message) { if (message) {
this.args.channel.messagesManager.removeMessage(message); this.#messagesManager.removeMessage(message);
} }
} }
@ -520,72 +524,6 @@ export default class ChatLivePane extends Component {
case "sent": case "sent":
this.handleSentMessage(data); this.handleSentMessage(data);
break; break;
case "processed":
this.handleProcessedMessage(data);
break;
case "edit":
this.handleEditMessage(data);
break;
case "refresh":
this.handleRefreshMessage(data);
break;
case "delete":
this.handleDeleteMessage(data);
break;
case "bulk_delete":
this.handleBulkDeleteMessage(data);
break;
case "reaction":
this.handleReactionMessage(data);
break;
case "restore":
this.handleRestoreMessage(data);
break;
case "mention_warning":
this.handleMentionWarning(data);
break;
case "self_flagged":
this.handleSelfFlaggedMessage(data);
break;
case "flag":
this.handleFlaggedMessage(data);
break;
case "thread_created":
this.handleThreadCreated(data);
break;
}
}
handleThreadCreated(data) {
const message = this.args.channel.messagesManager.findMessage(
data.chat_message.id
);
if (message) {
message.threadId = data.chat_message.thread_id;
message.threadReplyCount = 1;
}
}
_handleStagedMessage(stagedMessage, data) {
stagedMessage.error = null;
stagedMessage.id = data.chat_message.id;
stagedMessage.staged = false;
stagedMessage.excerpt = data.chat_message.excerpt;
stagedMessage.threadId = data.chat_message.thread_id;
stagedMessage.channelId = data.chat_message.chat_channel_id;
stagedMessage.createdAt = data.chat_message.created_at;
const inReplyToMsg = this.args.channel.messagesManager.findMessage(
data.chat_message.in_reply_to?.id
);
if (inReplyToMsg && !inReplyToMsg.threadId) {
inReplyToMsg.threadId = data.chat_message.thread_id;
}
// some markdown is cooked differently on the server-side, e.g.
// quotes, avatar images etc.
if (data.chat_message?.cooked !== stagedMessage.cooked) {
stagedMessage.cooked = data.chat_message.cooked;
} }
} }
@ -595,139 +533,30 @@ export default class ChatLivePane extends Component {
} }
if (data.chat_message.user.id === this.currentUser.id && data.staged_id) { if (data.chat_message.user.id === this.currentUser.id && data.staged_id) {
const stagedMessage = this.args.channel.messagesManager.findStagedMessage( const stagedMessage = handleStagedMessage(this.#messagesManager, data);
data.staged_id
);
if (stagedMessage) { if (stagedMessage) {
return this._handleStagedMessage(stagedMessage, data); return;
} }
} }
if (this.args.channel.messagesManager.canLoadMoreFuture) { if (this.#messagesManager.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.#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.messagesManager.addMessages([message]); this.#messagesManager.addMessages([message]);
this.scrollToLatestMessage(); this.scrollToLatestMessage();
this.updateLastReadMessage(); this.updateLastReadMessage();
} else { } else {
// If we are almost at the bottom, we append the message and notice the user // If we are almost at the bottom, we append the message and notice the user
const message = ChatMessage.create(this.args.channel, data.chat_message); const message = ChatMessage.create(this.args.channel, data.chat_message);
this.args.channel.messagesManager.addMessages([message]); this.#messagesManager.addMessages([message]);
this.hasNewMessages = true; this.hasNewMessages = true;
} }
} }
handleProcessedMessage(data) {
const message = this.args.channel.messagesManager.findMessage(
data.chat_message.id
);
if (message) {
message.cooked = data.chat_message.cooked;
this.scrollToLatestMessage();
}
}
handleRefreshMessage(data) {
const message = this.args.channel.messagesManager.findMessage(
data.chat_message.id
);
if (message) {
message.incrementVersion();
}
}
handleEditMessage(data) {
const message = this.args.channel.messagesManager.findMessage(
data.chat_message.id
);
if (message) {
message.message = data.chat_message.message;
message.cooked = data.chat_message.cooked;
message.excerpt = data.chat_message.excerpt;
message.uploads = cloneJSON(data.chat_message.uploads || []);
message.edited = true;
message.incrementVersion();
}
}
handleBulkDeleteMessage(data) {
data.deleted_ids.forEach((deletedId) => {
this.handleDeleteMessage({
deleted_id: deletedId,
deleted_at: data.deleted_at,
});
});
}
handleDeleteMessage(data) {
const deletedId = data.deleted_id;
const targetMsg = this.args.channel.messagesManager.findMessage(deletedId);
if (!targetMsg) {
return;
}
if (this.currentUser.staff || this.currentUser.id === targetMsg.user.id) {
targetMsg.deletedAt = data.deleted_at;
targetMsg.expanded = false;
} else {
this.args.channel.messagesManager.removeMessage(targetMsg);
}
}
handleReactionMessage(data) {
const message = this.args.channel.messagesManager.findMessage(
data.chat_message_id
);
if (message) {
message.react(data.emoji, data.action, data.user, this.currentUser.id);
}
}
handleRestoreMessage(data) {
const message = this.args.channel.messagesManager.findMessage(
data.chat_message.id
);
if (message) {
message.deletedAt = null;
} else {
this.args.channel.messagesManager.addMessages([
ChatMessage.create(this.args.channel, data.chat_message),
]);
}
}
handleMentionWarning(data) {
const message = this.args.channel.messagesManager.findMessage(
data.chat_message_id
);
if (message) {
message.mentionWarning = EmberObject.create(data);
}
}
handleSelfFlaggedMessage(data) {
const message = this.args.channel.messagesManager.findMessage(
data.chat_message_id
);
if (message) {
message.userFlagStatus = data.user_flag_status;
}
}
handleFlaggedMessage(data) {
const message = this.args.channel.messagesManager.findMessage(
data.chat_message_id
);
if (message) {
message.reviewableId = data.reviewable_id;
}
}
// TODO (martin) Maybe change this to public, since its referred to by // TODO (martin) Maybe change this to public, since its referred to by
// livePanel.linkedComponent at the moment. // livePanel.linkedComponent at the moment.
get _selfDeleted() { get _selfDeleted() {
@ -788,13 +617,13 @@ export default class ChatLivePane extends Component {
if (stagedMessage.inReplyTo) { if (stagedMessage.inReplyTo) {
if (!this.args.channel.threadingEnabled) { if (!this.args.channel.threadingEnabled) {
this.args.channel.messagesManager.addMessages([stagedMessage]); this.#messagesManager.addMessages([stagedMessage]);
} }
} else { } else {
this.args.channel.messagesManager.addMessages([stagedMessage]); this.#messagesManager.addMessages([stagedMessage]);
} }
if (!this.args.channel.messagesManager.canLoadMoreFuture) { if (!this.#messagesManager.canLoadMoreFuture) {
this.scrollToLatestMessage(); this.scrollToLatestMessage();
} }
@ -844,8 +673,7 @@ export default class ChatLivePane extends Component {
} }
_onSendError(id, error) { _onSendError(id, error) {
const stagedMessage = const stagedMessage = this.#messagesManager.findStagedMessage(id);
this.args.channel.messagesManager.findStagedMessage(id);
if (stagedMessage) { if (stagedMessage) {
if (error.jqXHR?.responseJSON?.errors?.length) { if (error.jqXHR?.responseJSON?.errors?.length) {
// only network errors are retryable // only network errors are retryable
@ -910,20 +738,22 @@ export default class ChatLivePane extends Component {
return; return;
} }
this.chatChannelPaneSubscriptionsManager.unsubscribe();
this.messageBus.unsubscribe(`/chat/${channelId}`, this.onMessage); this.messageBus.unsubscribe(`/chat/${channelId}`, this.onMessage);
} }
_subscribeToUpdates(channelId) { _subscribeToUpdates(channel) {
if (!channelId) { if (!channel) {
return; return;
} }
this._unsubscribeToUpdates(channelId); this._unsubscribeToUpdates(channel.id);
this.messageBus.subscribe( this.messageBus.subscribe(
`/chat/${channelId}`, `/chat/${channel.id}`,
this.onMessage, this.onMessage,
this.args.channel.channelMessageBusLastId channel.channelMessageBusLastId
); );
this.chatChannelPaneSubscriptionsManager.subscribe(channel);
} }
@bind @bind

View File

@ -1,14 +1,17 @@
<div <div
class={{concat-class "chat-thread" (if this.loading "loading")}} class={{concat-class "chat-thread" (if this.loading "loading")}}
data-id={{this.thread.id}} data-id={{this.thread.id}}
{{did-insert this.subscribeToUpdates}}
{{did-insert this.loadMessages}} {{did-insert this.loadMessages}}
{{did-update this.subscribeToUpdates this.thread.id}}
{{did-update this.loadMessages this.thread.id}} {{did-update this.loadMessages this.thread.id}}
{{will-destroy this.unsubscribeFromUpdates}}
> >
{{#if @includeHeader}} {{#if @includeHeader}}
<div class="chat-thread__header"> <div class="chat-thread__header">
<span class="chat-thread__label">{{i18n "chat.thread.label"}}</span> <span class="chat-thread__label">{{i18n "chat.thread.label"}}</span>
<LinkTo <LinkTo
class="chat-thread__close" class="chat-thread__close btn-flat btn btn-icon no-text"
@route="chat.channel" @route="chat.channel"
@models={{this.chat.activeChannel.routeModels}} @models={{this.chat.activeChannel.routeModels}}
> >

View File

@ -21,6 +21,7 @@ export default class ChatThreadPanel extends Component {
@service chatComposerPresenceManager; @service chatComposerPresenceManager;
@service chatChannelThreadComposer; @service chatChannelThreadComposer;
@service chatChannelThreadPane; @service chatChannelThreadPane;
@service chatChannelThreadPaneSubscriptionsManager;
@service appEvents; @service appEvents;
@service capabilities; @service capabilities;
@ -37,6 +38,16 @@ export default class ChatThreadPanel extends Component {
return this.chat.activeChannel; return this.chat.activeChannel;
} }
@action
subscribeToUpdates() {
this.chatChannelThreadPaneSubscriptionsManager.subscribe(this.thread);
}
@action
unsubscribeFromUpdates() {
this.chatChannelThreadPaneSubscriptionsManager.unsubscribe();
}
@action @action
setScrollable(element) { setScrollable(element) {
this.scrollable = element; this.scrollable = element;
@ -189,7 +200,7 @@ export default class ChatThreadPanel extends Component {
.sendMessage(this.channel.id, { .sendMessage(this.channel.id, {
message: stagedMessage.message, message: stagedMessage.message,
in_reply_to_id: stagedMessage.inReplyTo?.id, in_reply_to_id: stagedMessage.inReplyTo?.id,
staged_id: stagedMessage.stagedId, staged_id: stagedMessage.id,
upload_ids: stagedMessage.uploads.map((upload) => upload.id), upload_ids: stagedMessage.uploads.map((upload) => upload.id),
thread_id: stagedMessage.threadId, thread_id: stagedMessage.threadId,
}) })
@ -197,7 +208,7 @@ export default class ChatThreadPanel extends Component {
this.scrollToBottom(); this.scrollToBottom();
}) })
.catch((error) => { .catch((error) => {
this.#onSendError(stagedMessage.stagedId, error); this.#onSendError(stagedMessage.id, error);
}) })
.finally(() => { .finally(() => {
if (this._selfDeleted) { if (this._selfDeleted) {

View File

@ -229,7 +229,16 @@ export default class ChatMessageInteractor {
copyLink() { copyLink() {
const { protocol, host } = window.location; const { protocol, host } = window.location;
let url = getURL(`/chat/c/-/${this.message.channelId}/${this.message.id}`); const channelId = this.message.channelId;
const threadId = this.message.threadId;
let url;
if (threadId) {
url = getURL(`/chat/c/-/${channelId}/t/${threadId}`);
} else {
url = getURL(`/chat/c/-/${channelId}/${this.message.id}`);
}
url = url.indexOf("/") === 0 ? protocol + "//" + host + url : url; url = url.indexOf("/") === 0 ? protocol + "//" + host + url : url;
clipboardCopy(url); clipboardCopy(url);
} }

View File

@ -47,6 +47,14 @@ export default class ChatThreadsManager {
this.#cache(model); this.#cache(model);
} }
if (
threadObject.meta?.message_bus_last_ids?.thread_message_bus_last_id !==
undefined
) {
model.threadMessageBusLastId =
threadObject.meta.message_bus_last_ids.thread_message_bus_last_id;
}
return model; return model;
} }

View File

@ -30,12 +30,20 @@ export default function withChatChannel(extendedClass) {
} }
if (channelTitle && channelTitle !== model.slugifiedTitle) { if (channelTitle && channelTitle !== model.slugifiedTitle) {
const nearMessageParams = this.paramsFor("chat.channel.near-message"); messageId = this.paramsFor("chat.channel.near-message").messageId;
if (nearMessageParams.messageId) { const threadId = this.paramsFor("chat.channel.thread").threadId;
if (threadId) {
this.router.replaceWith(
"chat.channel.thread",
...model.routeModels,
threadId
);
} else if (messageId) {
this.router.replaceWith( this.router.replaceWith(
"chat.channel.near-message", "chat.channel.near-message",
...model.routeModels, ...model.routeModels,
nearMessageParams.messageId messageId
); );
} else { } else {
this.router.replaceWith("chat.channel", ...model.routeModels); this.router.replaceWith("chat.channel", ...model.routeModels);

View File

@ -16,7 +16,7 @@ export default class ChatChannelComposer extends Service {
this.replyToMsg = null; this.replyToMsg = null;
} }
get #model() { get model() {
return this.chat.activeChannel; return this.chat.activeChannel;
} }
@ -26,7 +26,7 @@ export default class ChatChannelComposer extends Service {
const message = const message =
typeof messageOrId === "number" typeof messageOrId === "number"
? this.#model.messagesManager.findMessage(messageOrId) ? this.model.messagesManager.findMessage(messageOrId)
: messageOrId; : messageOrId;
this.replyToMsg = message; this.replyToMsg = message;
this.focusComposer(); this.focusComposer();
@ -38,7 +38,7 @@ export default class ChatChannelComposer extends Service {
} }
editButtonClicked(messageId) { editButtonClicked(messageId) {
const message = this.#model.messagesManager.findMessage(messageId); const message = this.model.messagesManager.findMessage(messageId);
this.editingMessage = message; this.editingMessage = message;
// TODO (martin) Move scrollToLatestMessage to live panel. // TODO (martin) Move scrollToLatestMessage to live panel.
@ -53,13 +53,13 @@ export default class ChatChannelComposer extends Service {
replyToMsg, replyToMsg,
inProgressUploadsCount, inProgressUploadsCount,
}) { }) {
if (!this.#model) { if (!this.model) {
return; return;
} }
if (!this.editingMessage && !this.#model.isDraft) { if (!this.editingMessage && !this.model.isDraft) {
if (typeof value !== "undefined") { if (typeof value !== "undefined" && this.model.draft) {
this.#model.draft.message = value; this.model.draft.message = value;
} }
// only save the uploads to the draft if we are not still uploading other // only save the uploads to the draft if we are not still uploading other
@ -69,17 +69,18 @@ export default class ChatChannelComposer extends Service {
if ( if (
typeof uploads !== "undefined" && typeof uploads !== "undefined" &&
inProgressUploadsCount !== "undefined" && inProgressUploadsCount !== "undefined" &&
inProgressUploadsCount === 0 inProgressUploadsCount === 0 &&
this.model.draft
) { ) {
this.#model.draft.uploads = uploads; this.model.draft.uploads = uploads;
} }
if (typeof replyToMsg !== "undefined") { if (typeof replyToMsg !== "undefined" && this.model.draft) {
this.#model.draft.replyToMsg = replyToMsg; this.model.draft.replyToMsg = replyToMsg;
} }
} }
if (!this.#model.isDraft) { if (!this.model.isDraft) {
this.#reportReplyingPresence(value); this.#reportReplyingPresence(value);
} }
@ -103,25 +104,25 @@ export default class ChatChannelComposer extends Service {
return; return;
} }
if (this.#model.isDraft) { if (this.model.isDraft) {
return; return;
} }
const replying = !this.editingMessage && !!composerValue; const replying = !this.editingMessage && !!composerValue;
this.chatComposerPresenceManager.notifyState(this.#model.id, replying); this.chatComposerPresenceManager.notifyState(this.model.id, replying);
} }
@debounce(2000) @debounce(2000)
_persistDraft() { _persistDraft() {
if (this.#componentDeleted || !this.#model) { if (this.#componentDeleted || !this.model) {
return; return;
} }
if (!this.#model.draft) { if (!this.model.draft) {
return; return;
} }
return this.chatApi.saveDraft(this.#model.id, this.#model.draft.toJSON()); return this.chatApi.saveDraft(this.model.id, this.model.draft.toJSON());
} }
get #componentDeleted() { get #componentDeleted() {

View File

@ -0,0 +1,57 @@
import { inject as service } from "@ember/service";
import ChatPaneBaseSubscriptionsManager from "./chat-pane-base-subscriptions-manager";
export default class ChatChannelPaneSubscriptionsManager extends ChatPaneBaseSubscriptionsManager {
@service chat;
@service currentUser;
get messageBusChannel() {
return `/chat/${this.model.id}`;
}
get messageBusLastId() {
return this.model.channelMessageBusLastId;
}
// TODO (martin) Implement this for the channel, since it involves a bunch
// of scrolling and pane-specific logic. Will leave the existing sub inside
// ChatLivePane for now.
handleSentMessage() {
return;
}
// TODO (martin) Move scrolling functionality to pane from ChatLivePane?
afterProcessedMessage() {
// this.scrollToLatestMessage();
return;
}
handleBulkDeleteMessage(data) {
data.deleted_ids.forEach((deletedId) => {
this.handleDeleteMessage({
deleted_id: deletedId,
deleted_at: data.deleted_at,
});
});
}
handleThreadCreated(data) {
const message = this.messagesManager.findMessage(data.chat_message.id);
if (message) {
message.threadId = data.chat_message.thread_id;
message.threadReplyCount = 0;
}
}
handleThreadOriginalMessageUpdate(data) {
const message = this.messagesManager.findMessage(data.original_message_id);
if (message) {
if (data.action === "increment_reply_count") {
// TODO (martin) In future we should use a replies_count delivered
// from the server and simply update the message accordingly, for
// now we don't have an accurate enough count for this.
message.threadReplyCount += 1;
}
}
}
}

View File

@ -1,7 +1,7 @@
import ChatChannelComposer from "./chat-channel-composer"; import ChatChannelComposer from "./chat-channel-composer";
export default class extends ChatChannelComposer { export default class extends ChatChannelComposer {
get #model() { get model() {
return this.chat.activeChannel.activeThread; return this.chat.activeChannel.activeThread;
} }

View File

@ -0,0 +1,52 @@
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
import ChatPaneBaseSubscriptionsManager from "./chat-pane-base-subscriptions-manager";
export default class ChatChannelThreadPaneSubscriptionsManager extends ChatPaneBaseSubscriptionsManager {
get messageBusChannel() {
return `/chat/${this.model.channelId}/thread/${this.model.id}`;
}
get messageBusLastId() {
return this.model.threadMessageBusLastId;
}
handleSentMessage(data) {
if (data.chat_message.user.id === this.currentUser.id && data.staged_id) {
const stagedMessage = this.handleStagedMessageInternal(data);
if (stagedMessage) {
return;
}
}
const message = ChatMessage.create(
this.chat.activeChannel,
data.chat_message
);
this.messagesManager.addMessages([message]);
// TODO (martin) All the scrolling and new message indicator shenanigans.
}
// NOTE: noop, there is nothing to do when a thread is created
// inside the thread panel.
handleThreadCreated() {
return;
}
// NOTE: noop, there is nothing to do when a thread original message
// is updated inside the thread panel (for now).
handleThreadOriginalMessageUpdate() {
return;
}
// TODO (martin) Hook this up correctly in Chat::Publisher for threads.
handleBulkDeleteMessage() {
return;
}
// NOTE: noop for now, later we may want to do scrolling or something like
// we do in the channel pane.
afterProcessedMessage() {
return;
}
}

View File

@ -0,0 +1,238 @@
import Service, { inject as service } from "@ember/service";
import EmberObject from "@ember/object";
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
import { cloneJSON } from "discourse-common/lib/object";
import { bind } from "discourse-common/utils/decorators";
// TODO (martin) This export can be removed once we move the handleSentMessage
// code completely out of ChatLivePane
export function handleStagedMessage(messagesManager, data) {
const stagedMessage = messagesManager.findStagedMessage(data.staged_id);
if (!stagedMessage) {
return;
}
stagedMessage.error = null;
stagedMessage.id = data.chat_message.id;
stagedMessage.staged = false;
stagedMessage.excerpt = data.chat_message.excerpt;
stagedMessage.threadId = data.chat_message.thread_id;
stagedMessage.channelId = data.chat_message.chat_channel_id;
stagedMessage.createdAt = data.chat_message.created_at;
const inReplyToMsg = messagesManager.findMessage(
data.chat_message.in_reply_to?.id
);
if (inReplyToMsg && !inReplyToMsg.threadId) {
inReplyToMsg.threadId = data.chat_message.thread_id;
}
// some markdown is cooked differently on the server-side, e.g.
// quotes, avatar images etc.
if (data.chat_message?.cooked !== stagedMessage.cooked) {
stagedMessage.cooked = data.chat_message.cooked;
}
return stagedMessage;
}
/**
* Handles subscriptions for MessageBus messages sent from Chat::Publisher
* to the channel and thread panes. There are individual services for
* each (ChatChannelPaneSubscriptionsManager and ChatChannelThreadPaneSubscriptionsManager)
* that implement their own logic where necessary. Functions which will
* always be different between the two raise a "not implemented" error in
* the base class, and the child class must define the associated function,
* even if it is a noop in that context.
*
* For example, in the thread context there is no need to handle the thread
* creation event, because the panel will not be open in that case.
*/
export default class ChatPaneBaseSubscriptionsManager extends Service {
@service chat;
@service currentUser;
get messageBusChannel() {
throw "not implemented";
}
get messageBusLastId() {
throw "not implemented";
}
get messagesManager() {
return this.model.messagesManager;
}
subscribe(model) {
this.unsubscribe();
this.model = model;
this.messageBus.subscribe(
this.messageBusChannel,
this.onMessage,
this.messageBusLastId
);
}
unsubscribe() {
if (!this.model) {
return;
}
this.messageBus.unsubscribe(this.messageBusChannel, this.onMessage);
this.model = null;
}
// TODO (martin) This can be removed once we move the handleSentMessage
// code completely out of ChatLivePane
handleStagedMessageInternal(data) {
return handleStagedMessage(this.messagesManager, data);
}
@bind
onMessage(busData) {
switch (busData.type) {
case "sent":
this.handleSentMessage(busData);
break;
case "reaction":
this.handleReactionMessage(busData);
break;
case "processed":
this.handleProcessedMessage(busData);
break;
case "edit":
this.handleEditMessage(busData);
break;
case "refresh":
this.handleRefreshMessage(busData);
break;
case "delete":
this.handleDeleteMessage(busData);
break;
case "bulk_delete":
this.handleBulkDeleteMessage(busData);
break;
case "restore":
this.handleRestoreMessage(busData);
break;
case "mention_warning":
this.handleMentionWarning(busData);
break;
case "self_flagged":
this.handleSelfFlaggedMessage(busData);
break;
case "flag":
this.handleFlaggedMessage(busData);
break;
case "thread_created":
this.handleThreadCreated(busData);
break;
case "update_thread_original_message":
this.handleThreadOriginalMessageUpdate(busData);
break;
}
}
handleSentMessage() {
throw "not implemented";
}
handleProcessedMessage(data) {
const message = this.messagesManager.findMessage(data.chat_message.id);
if (message) {
message.cooked = data.chat_message.cooked;
this.afterProcessedMessage(message);
}
}
afterProcessedMessage() {
throw "not implemented";
}
handleReactionMessage(data) {
const message = this.messagesManager.findMessage(data.chat_message_id);
if (message) {
message.react(data.emoji, data.action, data.user, this.currentUser.id);
}
}
handleEditMessage(data) {
const message = this.messagesManager.findMessage(data.chat_message.id);
if (message) {
message.message = data.chat_message.message;
message.cooked = data.chat_message.cooked;
message.excerpt = data.chat_message.excerpt;
message.uploads = cloneJSON(data.chat_message.uploads || []);
message.edited = true;
message.incrementVersion();
}
}
handleRefreshMessage(data) {
const message = this.messagesManager.findMessage(data.chat_message.id);
if (message) {
message.incrementVersion();
}
}
handleBulkDeleteMessage() {
throw "not implemented";
}
handleDeleteMessage(data) {
const deletedId = data.deleted_id;
const targetMsg = this.messagesManager.findMessage(deletedId);
if (!targetMsg) {
return;
}
if (this.currentUser.staff || this.currentUser.id === targetMsg.user.id) {
targetMsg.deletedAt = data.deleted_at;
targetMsg.expanded = false;
} else {
this.messagesManager.removeMessage(targetMsg);
}
}
handleRestoreMessage(data) {
const message = this.messagesManager.findMessage(data.chat_message.id);
if (message) {
message.deletedAt = null;
} else {
this.messagesManager.addMessages([
ChatMessage.create(this.args.channel, data.chat_message),
]);
}
}
handleMentionWarning(data) {
const message = this.messagesManager.findMessage(data.chat_message_id);
if (message) {
message.mentionWarning = EmberObject.create(data);
}
}
handleSelfFlaggedMessage(data) {
const message = this.messagesManager.findMessage(data.chat_message_id);
if (message) {
message.userFlagStatus = data.user_flag_status;
}
}
handleFlaggedMessage(data) {
const message = this.messagesManager.findMessage(data.chat_message_id);
if (message) {
message.reviewableId = data.reviewable_id;
}
}
handleThreadCreated() {
throw "not implemented";
}
handleThreadOriginalMessageUpdate() {
throw "not implemented";
}
}

View File

@ -12,7 +12,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding-inline: 1.5rem; padding-inline: 1rem;
} }
&__body { &__body {
@ -27,12 +27,4 @@
flex-direction: column-reverse; flex-direction: column-reverse;
will-change: transform; will-change: transform;
} }
&__close {
color: var(--primary-medium);
&:visited {
color: var(--primary-medium);
}
}
} }

View File

@ -20,6 +20,13 @@ module ChatSystemHelpers
Group.refresh_automatic_groups! Group.refresh_automatic_groups!
end end
def chat_system_user_bootstrap(user:, channel:)
user.activate
user.user_option.update!(chat_enabled: true)
Group.refresh_automatic_group!("trust_level_#{user.trust_level}".to_sym)
Fabricate(:user_chat_channel_membership, chat_channel: channel, user: user)
end
def chat_thread_chain_bootstrap(channel:, users:, messages_count: 4) def chat_thread_chain_bootstrap(channel:, users:, messages_count: 4)
last_user = nil last_user = nil
last_message = nil last_message = nil

View File

@ -14,4 +14,42 @@ describe Chat::Publisher do
expect(data["type"]).to eq("refresh") expect(data["type"]).to eq("refresh")
end end
end end
describe ".calculate_publish_targets" do
context "when the chat message is the original message of a thread" do
fab!(:thread) { Fabricate(:chat_thread, original_message: message, channel: channel) }
it "generates the correct targets" do
targets = described_class.calculate_publish_targets(channel, message)
expect(targets).to contain_exactly(
"/chat/#{channel.id}",
"/chat/#{channel.id}/thread/#{thread.id}",
)
end
end
context "when the chat message is a thread reply" do
fab!(:thread) do
Fabricate(
:chat_thread,
original_message: Fabricate(:chat_message, chat_channel: channel),
channel: channel,
)
end
before { message.update!(thread: thread) }
it "generates the correct targets" do
targets = described_class.calculate_publish_targets(channel, message)
expect(targets).to contain_exactly("/chat/#{channel.id}/thread/#{thread.id}")
end
end
context "when the chat message is not part of a thread" do
it "generates the correct targets" do
targets = described_class.calculate_publish_targets(channel, message)
expect(targets).to contain_exactly("/chat/#{channel.id}")
end
end
end
end end

View File

@ -0,0 +1,41 @@
# frozen_string_literal: true
RSpec.describe "Chat message", type: :system, js: true do
fab!(:current_user) { Fabricate(:user) }
fab!(:channel_1) { Fabricate(:chat_channel) }
fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel_1) }
let(:cdp) { PageObjects::CDP.new }
let(:chat) { PageObjects::Pages::Chat.new }
let(:channel) { PageObjects::Pages::ChatChannel.new }
before do
chat_system_bootstrap
channel_1.add(current_user)
sign_in(current_user)
end
context "when hovering a message" do
it "adds an active class" do
chat.visit_channel(channel_1)
channel.hover_message(message_1)
expect(page).to have_css(
".chat-live-pane[data-id='#{channel_1.id}'] [data-id='#{message_1.id}'] .chat-message.is-active",
)
end
end
context "when copying link to a message" do
before { cdp.allow_clipboard }
it "copies the link to the message" do
chat.visit_channel(channel_1)
channel.copy_link(message_1)
expect(cdp.read_clipboard).to include("/chat/c/-/#{channel_1.id}/#{message_1.id}")
end
end
end

View File

@ -0,0 +1,48 @@
# frozen_string_literal: true
RSpec.describe "Chat message - channel", type: :system, js: true do
fab!(:current_user) { Fabricate(:user) }
fab!(:other_user) { Fabricate(:user) }
fab!(:channel_1) { Fabricate(:chat_channel) }
fab!(:thread_1) do
chat_thread_chain_bootstrap(channel: channel_1, users: [current_user, other_user])
end
let(:cdp) { PageObjects::CDP.new }
let(:chat) { PageObjects::Pages::Chat.new }
let(:channel) { PageObjects::Pages::ChatChannel.new }
let(:message_1) { thread_1.chat_messages.first }
before do
chat_system_bootstrap
channel_1.update!(threading_enabled: true)
channel_1.add(current_user)
channel_1.add(other_user)
SiteSetting.enable_experimental_chat_threaded_discussions = true
sign_in(current_user)
end
context "when hovering a message" do
it "adds an active class" do
chat.visit_thread(thread_1)
channel.hover_message(message_1)
expect(page).to have_css(
".chat-thread[data-id='#{thread_1.id}'] [data-id='#{message_1.id}'] .chat-message.is-active",
)
end
end
context "when copying link to a message" do
before { cdp.allow_clipboard }
it "copies the link to the thread" do
chat.visit_thread(thread_1)
channel.copy_link(message_1)
expect(cdp.read_clipboard).to include("/chat/c/-/#{channel_1.id}/t/#{thread_1.id}")
end
end
end

View File

@ -1,26 +0,0 @@
# frozen_string_literal: true
RSpec.describe "Chat message", type: :system, js: true do
fab!(:current_user) { Fabricate(:user) }
fab!(:channel_1) { Fabricate(:chat_channel) }
fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel_1) }
let(:chat) { PageObjects::Pages::Chat.new }
let(:channel) { PageObjects::Pages::ChatChannel.new }
before { chat_system_bootstrap }
context "when hovering a message" do
before do
channel_1.add(current_user)
sign_in(current_user)
end
it "adds an active class" do
chat.visit_channel(channel_1)
channel.hover_message(message_1)
expect(page).to have_css("[data-id='#{message_1.id}'] .chat-message.is-active")
end
end
end

View File

@ -92,5 +92,20 @@ describe "Thread indicator for chat messages", type: :system, js: true do
new_thread = message_without_thread.reload.thread new_thread = message_without_thread.reload.thread
expect(page).not_to have_css(channel_page.message_by_id_selector(new_thread.replies.first)) expect(page).not_to have_css(channel_page.message_by_id_selector(new_thread.replies.first))
end end
it "increments the indicator when a new reply is sent in the thread" do
chat_page.visit_channel(channel)
expect(channel_page.message_thread_indicator(thread_1.original_message)).to have_css(
".chat-message-thread-indicator__replies-count",
text: I18n.t("js.chat.thread.replies", count: 3),
)
channel_page.message_thread_indicator(thread_1.original_message).click
expect(side_panel).to have_open_thread(thread_1)
open_thread.send_message(thread_1.id, "new thread message")
expect(channel_page.message_thread_indicator(thread_1.original_message)).to have_css(
".chat-message-thread-indicator__replies-count",
text: I18n.t("js.chat.thread.replies", count: 4),
)
end
end end
end end

View File

@ -23,6 +23,10 @@ module PageObjects
has_no_css?(".chat-skeleton") has_no_css?(".chat-skeleton")
end end
def visit_thread(thread)
visit(thread.url)
end
def visit_channel_settings(channel) def visit_channel_settings(channel)
visit(channel.url + "/info/settings") visit(channel.url + "/info/settings")
end end

View File

@ -22,7 +22,7 @@ module PageObjects
end end
def message_by_id_selector(id) def message_by_id_selector(id)
".chat-message-container[data-id=\"#{id}\"]" ".chat-live-pane .chat-messages-container .chat-message-container[data-id=\"#{id}\"]"
end end
def message_by_id(id) def message_by_id(id)
@ -71,6 +71,12 @@ module PageObjects
find("[data-value='flag']").click find("[data-value='flag']").click
end end
def copy_link(message)
hover_message(message)
click_more_button
find("[data-value='copyLink']").click
end
def flag_message(message) def flag_message(message)
hover_message(message) hover_message(message)
click_more_button click_more_button

View File

@ -50,7 +50,7 @@ module PageObjects
def has_message?(thread_id, text: nil, id: nil) def has_message?(thread_id, text: nil, id: nil)
if text if text
find(thread_selector_by_id(thread_id)).has_css?(".chat-message-text", text: text) find(thread_selector_by_id(thread_id)).has_css?(".chat-message-text", text: text, wait: 5)
elsif id elsif id
find(thread_selector_by_id(thread_id)).has_css?( find(thread_selector_by_id(thread_id)).has_css?(
".chat-message-container[data-id=\"#{id}\"]", ".chat-message-container[data-id=\"#{id}\"]",

View File

@ -89,6 +89,62 @@ describe "Single thread in side panel", type: :system, js: true do
expect(open_thread.omu).to have_content(thread.original_message_user.username) expect(open_thread.omu).to have_content(thread.original_message_user.username)
end end
describe "sending a message" do
it "shows the message in the thread pane and links it to the correct channel" do
chat_page.visit_channel(channel)
channel_page.message_thread_indicator(thread.original_message).click
expect(side_panel).to have_open_thread(thread)
open_thread.send_message(thread.id, "new thread message")
expect(open_thread).to have_message(thread.id, text: "new thread message")
thread_message = thread.replies.last
expect(thread_message.chat_channel_id).to eq(channel.id)
expect(thread_message.thread.channel_id).to eq(channel.id)
end
it "does not echo the message in the channel pane" do
chat_page.visit_channel(channel)
channel_page.message_thread_indicator(thread.original_message).click
expect(side_panel).to have_open_thread(thread)
open_thread.send_message(thread.id, "new thread message")
expect(open_thread).to have_message(thread.id, text: "new thread message")
thread_message = thread.reload.replies.last
expect(channel_page).not_to have_css(channel_page.message_by_id_selector(thread_message.id))
end
it "handles updates from multiple users sending messages in the thread" do
using_session(:tab_1) do
sign_in(current_user)
chat_page.visit_channel(channel)
channel_page.message_thread_indicator(thread.original_message).click
end
other_user = Fabricate(:user)
chat_system_user_bootstrap(user: other_user, channel: channel)
using_session(:tab_2) do
sign_in(other_user)
chat_page.visit_channel(channel)
channel_page.message_thread_indicator(thread.original_message).click
end
using_session(:tab_2) do
expect(side_panel).to have_open_thread(thread)
open_thread.send_message(thread.id, "the other user message")
expect(open_thread).to have_message(thread.id, text: "the other user message")
end
using_session(:tab_1) do
expect(side_panel).to have_open_thread(thread)
expect(open_thread).to have_message(thread.id, text: "the other user message")
open_thread.send_message(thread.id, "this is a test message")
expect(open_thread).to have_message(thread.id, text: "this is a test message")
end
using_session(:tab_2) do
expect(open_thread).to have_message(thread.id, text: "this is a test message")
end
end
end
context "when using mobile" do context "when using mobile" do
it "opens the side panel for a single thread using the indicator", mobile: true do it "opens the side panel for a single thread using the indicator", mobile: true do
chat_page.visit_channel(channel) chat_page.visit_channel(channel)

View File

@ -4,6 +4,7 @@ RSpec.describe "Quoting chat message transcripts", type: :system, js: true do
fab!(:current_user) { Fabricate(:user) } fab!(:current_user) { Fabricate(:user) }
fab!(:chat_channel_1) { Fabricate(:chat_channel) } fab!(:chat_channel_1) { Fabricate(:chat_channel) }
let(:cdp) { PageObjects::CDP.new }
let(:chat_page) { PageObjects::Pages::Chat.new } let(:chat_page) { PageObjects::Pages::Chat.new }
let(:chat_channel_page) { PageObjects::Pages::ChatChannel.new } let(:chat_channel_page) { PageObjects::Pages::ChatChannel.new }
let(:topic_page) { PageObjects::Pages::Topic.new } let(:topic_page) { PageObjects::Pages::Topic.new }
@ -25,30 +26,6 @@ RSpec.describe "Quoting chat message transcripts", type: :system, js: true do
end end
end end
def cdp_allow_clipboard_access!
cdp_params = {
origin: page.server_url,
permission: {
name: "clipboard-read",
},
setting: "granted",
}
page.driver.browser.execute_cdp("Browser.setPermission", **cdp_params)
cdp_params = {
origin: page.server_url,
permission: {
name: "clipboard-write",
},
setting: "granted",
}
page.driver.browser.execute_cdp("Browser.setPermission", **cdp_params)
end
def read_clipboard
page.evaluate_async_script("navigator.clipboard.readText().then(arguments[0])")
end
def click_selection_button(button) def click_selection_button(button)
selector = selector =
case button case button
@ -70,7 +47,7 @@ RSpec.describe "Quoting chat message transcripts", type: :system, js: true do
expect(chat_channel_page).to have_selection_management expect(chat_channel_page).to have_selection_management
click_selection_button("copy") click_selection_button("copy")
expect(page).to have_selector(".chat-copy-success") expect(page).to have_selector(".chat-copy-success")
clip_text = read_clipboard clip_text = cdp.read_clipboard
expect(clip_text.chomp).to eq(generate_transcript(messages, current_user)) expect(clip_text.chomp).to eq(generate_transcript(messages, current_user))
clip_text clip_text
end end
@ -84,7 +61,7 @@ RSpec.describe "Quoting chat message transcripts", type: :system, js: true do
end end
describe "copying quote transcripts with the clipboard" do describe "copying quote transcripts with the clipboard" do
before { cdp_allow_clipboard_access! } before { cdp.allow_clipboard }
context "when quoting a single message into a topic" do context "when quoting a single message into a topic" do
fab!(:post_1) { Fabricate(:post) } fab!(:post_1) { Fabricate(:post) }

View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
module PageObjects
class CDP
include Capybara::DSL
def allow_clipboard
cdp_params = {
origin: page.server_url,
permission: {
name: "clipboard-read",
},
setting: "granted",
}
page.driver.browser.execute_cdp("Browser.setPermission", **cdp_params)
cdp_params = {
origin: page.server_url,
permission: {
name: "clipboard-write",
},
setting: "granted",
}
page.driver.browser.execute_cdp("Browser.setPermission", **cdp_params)
end
def read_clipboard
page.evaluate_async_script("navigator.clipboard.readText().then(arguments[0])")
end
end
end