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:
parent
e52f322cb5
commit
bd5c5c4b5f
|
@ -312,11 +312,11 @@ module Chat
|
|||
end
|
||||
|
||||
def thread_reply?
|
||||
in_thread? && !is_thread_om?
|
||||
in_thread? && !thread_om?
|
||||
end
|
||||
|
||||
def is_thread_om?
|
||||
self.thread.original_message_id == self.id
|
||||
def thread_om?
|
||||
in_thread? && self.thread.original_message_id == self.id
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
@ -5,6 +5,22 @@ module Chat
|
|||
has_one :original_message_user, serializer: BasicUserWithStatusSerializer, 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
|
||||
|
|
|
@ -10,8 +10,27 @@ module Chat
|
|||
"/chat/#{chat_channel_id}"
|
||||
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)
|
||||
return if chat_message.thread_reply?
|
||||
message_bus_targets = calculate_publish_targets(chat_channel, chat_message)
|
||||
|
||||
content =
|
||||
Chat::MessageSerializer.new(
|
||||
|
@ -22,19 +41,38 @@ module Chat
|
|||
content[:staged_id] = staged_id
|
||||
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
|
||||
|
||||
MessageBus.publish(
|
||||
self.new_messages_message_bus_channel(chat_channel.id),
|
||||
{
|
||||
channel_id: chat_channel.id,
|
||||
message_id: chat_message.id,
|
||||
user_id: chat_message.user.id,
|
||||
username: chat_message.user.username,
|
||||
thread_id: chat_message.thread_id,
|
||||
},
|
||||
permissions,
|
||||
)
|
||||
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(
|
||||
self.new_messages_message_bus_channel(chat_channel.id),
|
||||
{
|
||||
channel_id: chat_channel.id,
|
||||
message_id: chat_message.id,
|
||||
user_id: chat_message.user.id,
|
||||
username: chat_message.user.username,
|
||||
thread_id: chat_message.thread_id,
|
||||
},
|
||||
permissions,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def self.publish_thread_created!(chat_channel, chat_message)
|
||||
|
@ -50,7 +88,7 @@ module Chat
|
|||
end
|
||||
|
||||
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
|
||||
content = {
|
||||
|
@ -60,15 +98,14 @@ module Chat
|
|||
cooked: chat_message.cooked,
|
||||
},
|
||||
}
|
||||
MessageBus.publish(
|
||||
root_message_bus_channel(chat_channel.id),
|
||||
content.as_json,
|
||||
permissions(chat_channel),
|
||||
)
|
||||
|
||||
message_bus_targets.each do |message_bus_channel|
|
||||
MessageBus.publish(message_bus_channel, content.as_json, permissions(chat_channel))
|
||||
end
|
||||
end
|
||||
|
||||
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 =
|
||||
Chat::MessageSerializer.new(
|
||||
|
@ -76,15 +113,14 @@ module Chat
|
|||
{ scope: anonymous_guardian, root: :chat_message },
|
||||
).as_json
|
||||
content[:type] = :edit
|
||||
MessageBus.publish(
|
||||
root_message_bus_channel(chat_channel.id),
|
||||
content.as_json,
|
||||
permissions(chat_channel),
|
||||
)
|
||||
|
||||
message_bus_targets.each do |message_bus_channel|
|
||||
MessageBus.publish(message_bus_channel, content.as_json, permissions(chat_channel))
|
||||
end
|
||||
end
|
||||
|
||||
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 =
|
||||
Chat::MessageSerializer.new(
|
||||
|
@ -92,15 +128,14 @@ module Chat
|
|||
{ scope: anonymous_guardian, root: :chat_message },
|
||||
).as_json
|
||||
content[:type] = :refresh
|
||||
MessageBus.publish(
|
||||
root_message_bus_channel(chat_channel.id),
|
||||
content.as_json,
|
||||
permissions(chat_channel),
|
||||
)
|
||||
|
||||
message_bus_targets.each do |message_bus_channel|
|
||||
MessageBus.publish(message_bus_channel, content.as_json, permissions(chat_channel))
|
||||
end
|
||||
end
|
||||
|
||||
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 = {
|
||||
action: action,
|
||||
|
@ -109,11 +144,10 @@ module Chat
|
|||
type: :reaction,
|
||||
chat_message_id: chat_message.id,
|
||||
}
|
||||
MessageBus.publish(
|
||||
root_message_bus_channel(chat_channel.id),
|
||||
content.as_json,
|
||||
permissions(chat_channel),
|
||||
)
|
||||
|
||||
message_bus_targets.each do |message_bus_channel|
|
||||
MessageBus.publish(message_bus_channel, content.as_json, permissions(chat_channel))
|
||||
end
|
||||
end
|
||||
|
||||
def self.publish_presence!(chat_channel, user, typ)
|
||||
|
@ -121,16 +155,20 @@ module Chat
|
|||
end
|
||||
|
||||
def self.publish_delete!(chat_channel, chat_message)
|
||||
return if chat_message.thread_reply?
|
||||
message_bus_targets = calculate_publish_targets(chat_channel, chat_message)
|
||||
|
||||
MessageBus.publish(
|
||||
root_message_bus_channel(chat_channel.id),
|
||||
{ type: "delete", deleted_id: chat_message.id, deleted_at: chat_message.deleted_at },
|
||||
permissions(chat_channel),
|
||||
)
|
||||
message_bus_targets.each do |message_bus_channel|
|
||||
MessageBus.publish(
|
||||
message_bus_channel,
|
||||
{ type: "delete", deleted_id: chat_message.id, deleted_at: chat_message.deleted_at },
|
||||
permissions(chat_channel),
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
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(
|
||||
root_message_bus_channel(chat_channel.id),
|
||||
{ typ: "bulk_delete", deleted_ids: deleted_message_ids, deleted_at: Time.zone.now },
|
||||
|
@ -139,7 +177,7 @@ module Chat
|
|||
end
|
||||
|
||||
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 =
|
||||
Chat::MessageSerializer.new(
|
||||
|
@ -147,33 +185,36 @@ module Chat
|
|||
{ scope: anonymous_guardian, root: :chat_message },
|
||||
).as_json
|
||||
content[:type] = :restore
|
||||
MessageBus.publish(
|
||||
root_message_bus_channel(chat_channel.id),
|
||||
content.as_json,
|
||||
permissions(chat_channel),
|
||||
)
|
||||
|
||||
message_bus_targets.each do |message_bus_channel|
|
||||
MessageBus.publish(message_bus_channel, content.as_json, permissions(chat_channel))
|
||||
end
|
||||
end
|
||||
|
||||
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)
|
||||
|
||||
# Publish to user who created flag
|
||||
MessageBus.publish(
|
||||
"/chat/#{chat_message.chat_channel_id}",
|
||||
{
|
||||
type: "self_flagged",
|
||||
user_flag_status: score.status_for_database,
|
||||
chat_message_id: chat_message.id,
|
||||
}.as_json,
|
||||
user_ids: [user.id],
|
||||
)
|
||||
message_bus_targets.each do |message_bus_channel|
|
||||
# Publish to user who created flag
|
||||
MessageBus.publish(
|
||||
message_bus_channel,
|
||||
{
|
||||
type: "self_flagged",
|
||||
user_flag_status: score.status_for_database,
|
||||
chat_message_id: chat_message.id,
|
||||
}.as_json,
|
||||
user_ids: [user.id],
|
||||
)
|
||||
end
|
||||
|
||||
# Publish flag with link to reviewable to staff
|
||||
MessageBus.publish(
|
||||
"/chat/#{chat_message.chat_channel_id}",
|
||||
{ type: "flag", chat_message_id: chat_message.id, reviewable_id: reviewable.id }.as_json,
|
||||
group_ids: [Group::AUTO_GROUPS[:staff]],
|
||||
)
|
||||
message_bus_targets.each do |message_bus_channel|
|
||||
# Publish flag with link to reviewable to staff
|
||||
MessageBus.publish(
|
||||
message_bus_channel,
|
||||
{ type: "flag", chat_message_id: chat_message.id, reviewable_id: reviewable.id }.as_json,
|
||||
group_ids: [Group::AUTO_GROUPS[:staff]],
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def self.user_tracking_state_message_bus_channel(user_id)
|
||||
|
|
|
@ -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 Component from "@glimmer/component";
|
||||
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 { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { cancel, schedule, throttle } from "@ember/runloop";
|
||||
|
@ -34,6 +37,7 @@ export default class ChatLivePane extends Component {
|
|||
@service chatStateManager;
|
||||
@service chatChannelComposer;
|
||||
@service chatChannelPane;
|
||||
@service chatChannelPaneSubscriptionsManager;
|
||||
@service chatApi;
|
||||
@service currentUser;
|
||||
@service appEvents;
|
||||
|
@ -108,7 +112,7 @@ export default class ChatLivePane extends Component {
|
|||
}
|
||||
|
||||
this.loadMessages();
|
||||
this._subscribeToUpdates(this.args.channel?.id);
|
||||
this._subscribeToUpdates(this.args.channel);
|
||||
}
|
||||
|
||||
@action
|
||||
|
@ -209,8 +213,8 @@ export default class ChatLivePane extends Component {
|
|||
const loadingMoreKey = `loadingMore${capitalize(direction)}`;
|
||||
|
||||
const canLoadMore = loadingPast
|
||||
? this.args.channel.messagesManager.canLoadMorePast
|
||||
: this.args.channel.messagesManager.canLoadMoreFuture;
|
||||
? this.#messagesManager.canLoadMorePast
|
||||
: this.#messagesManager.canLoadMoreFuture;
|
||||
|
||||
if (
|
||||
!canLoadMore ||
|
||||
|
@ -261,7 +265,7 @@ export default class ChatLivePane extends Component {
|
|||
}
|
||||
|
||||
this.args.channel.details = meta;
|
||||
this.args.channel.messagesManager.addMessages(messages);
|
||||
this.#messagesManager.addMessages(messages);
|
||||
|
||||
// Edge case for IOS to avoid blank screens
|
||||
// and/or scrolling to bottom losing track of scroll position
|
||||
|
@ -508,9 +512,9 @@ export default class ChatLivePane extends Component {
|
|||
}
|
||||
|
||||
removeMessage(msgData) {
|
||||
const message = this.args.channel.messagesManager.findMessage(msgData.id);
|
||||
const message = this.#messagesManager.findMessage(msgData.id);
|
||||
if (message) {
|
||||
this.args.channel.messagesManager.removeMessage(message);
|
||||
this.#messagesManager.removeMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -520,72 +524,6 @@ export default class ChatLivePane extends Component {
|
|||
case "sent":
|
||||
this.handleSentMessage(data);
|
||||
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) {
|
||||
const stagedMessage = this.args.channel.messagesManager.findStagedMessage(
|
||||
data.staged_id
|
||||
);
|
||||
const stagedMessage = handleStagedMessage(this.#messagesManager, data);
|
||||
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
|
||||
this.hasNewMessages = true;
|
||||
} else if (this.#isTowardsBottom()) {
|
||||
// If we are at the bottom, we append the message and scroll to it
|
||||
const message = ChatMessage.create(this.args.channel, data.chat_message);
|
||||
|
||||
this.args.channel.messagesManager.addMessages([message]);
|
||||
this.#messagesManager.addMessages([message]);
|
||||
this.scrollToLatestMessage();
|
||||
this.updateLastReadMessage();
|
||||
} else {
|
||||
// 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);
|
||||
this.args.channel.messagesManager.addMessages([message]);
|
||||
this.#messagesManager.addMessages([message]);
|
||||
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
|
||||
// livePanel.linkedComponent at the moment.
|
||||
get _selfDeleted() {
|
||||
|
@ -788,13 +617,13 @@ export default class ChatLivePane extends Component {
|
|||
|
||||
if (stagedMessage.inReplyTo) {
|
||||
if (!this.args.channel.threadingEnabled) {
|
||||
this.args.channel.messagesManager.addMessages([stagedMessage]);
|
||||
this.#messagesManager.addMessages([stagedMessage]);
|
||||
}
|
||||
} else {
|
||||
this.args.channel.messagesManager.addMessages([stagedMessage]);
|
||||
this.#messagesManager.addMessages([stagedMessage]);
|
||||
}
|
||||
|
||||
if (!this.args.channel.messagesManager.canLoadMoreFuture) {
|
||||
if (!this.#messagesManager.canLoadMoreFuture) {
|
||||
this.scrollToLatestMessage();
|
||||
}
|
||||
|
||||
|
@ -844,8 +673,7 @@ export default class ChatLivePane extends Component {
|
|||
}
|
||||
|
||||
_onSendError(id, error) {
|
||||
const stagedMessage =
|
||||
this.args.channel.messagesManager.findStagedMessage(id);
|
||||
const stagedMessage = this.#messagesManager.findStagedMessage(id);
|
||||
if (stagedMessage) {
|
||||
if (error.jqXHR?.responseJSON?.errors?.length) {
|
||||
// only network errors are retryable
|
||||
|
@ -910,20 +738,22 @@ export default class ChatLivePane extends Component {
|
|||
return;
|
||||
}
|
||||
|
||||
this.chatChannelPaneSubscriptionsManager.unsubscribe();
|
||||
this.messageBus.unsubscribe(`/chat/${channelId}`, this.onMessage);
|
||||
}
|
||||
|
||||
_subscribeToUpdates(channelId) {
|
||||
if (!channelId) {
|
||||
_subscribeToUpdates(channel) {
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._unsubscribeToUpdates(channelId);
|
||||
this._unsubscribeToUpdates(channel.id);
|
||||
this.messageBus.subscribe(
|
||||
`/chat/${channelId}`,
|
||||
`/chat/${channel.id}`,
|
||||
this.onMessage,
|
||||
this.args.channel.channelMessageBusLastId
|
||||
channel.channelMessageBusLastId
|
||||
);
|
||||
this.chatChannelPaneSubscriptionsManager.subscribe(channel);
|
||||
}
|
||||
|
||||
@bind
|
||||
|
|
|
@ -1,14 +1,17 @@
|
|||
<div
|
||||
class={{concat-class "chat-thread" (if this.loading "loading")}}
|
||||
data-id={{this.thread.id}}
|
||||
{{did-insert this.subscribeToUpdates}}
|
||||
{{did-insert this.loadMessages}}
|
||||
{{did-update this.subscribeToUpdates this.thread.id}}
|
||||
{{did-update this.loadMessages this.thread.id}}
|
||||
{{will-destroy this.unsubscribeFromUpdates}}
|
||||
>
|
||||
{{#if @includeHeader}}
|
||||
<div class="chat-thread__header">
|
||||
<span class="chat-thread__label">{{i18n "chat.thread.label"}}</span>
|
||||
<LinkTo
|
||||
class="chat-thread__close"
|
||||
class="chat-thread__close btn-flat btn btn-icon no-text"
|
||||
@route="chat.channel"
|
||||
@models={{this.chat.activeChannel.routeModels}}
|
||||
>
|
||||
|
|
|
@ -21,6 +21,7 @@ export default class ChatThreadPanel extends Component {
|
|||
@service chatComposerPresenceManager;
|
||||
@service chatChannelThreadComposer;
|
||||
@service chatChannelThreadPane;
|
||||
@service chatChannelThreadPaneSubscriptionsManager;
|
||||
@service appEvents;
|
||||
@service capabilities;
|
||||
|
||||
|
@ -37,6 +38,16 @@ export default class ChatThreadPanel extends Component {
|
|||
return this.chat.activeChannel;
|
||||
}
|
||||
|
||||
@action
|
||||
subscribeToUpdates() {
|
||||
this.chatChannelThreadPaneSubscriptionsManager.subscribe(this.thread);
|
||||
}
|
||||
|
||||
@action
|
||||
unsubscribeFromUpdates() {
|
||||
this.chatChannelThreadPaneSubscriptionsManager.unsubscribe();
|
||||
}
|
||||
|
||||
@action
|
||||
setScrollable(element) {
|
||||
this.scrollable = element;
|
||||
|
@ -189,7 +200,7 @@ export default class ChatThreadPanel extends Component {
|
|||
.sendMessage(this.channel.id, {
|
||||
message: stagedMessage.message,
|
||||
in_reply_to_id: stagedMessage.inReplyTo?.id,
|
||||
staged_id: stagedMessage.stagedId,
|
||||
staged_id: stagedMessage.id,
|
||||
upload_ids: stagedMessage.uploads.map((upload) => upload.id),
|
||||
thread_id: stagedMessage.threadId,
|
||||
})
|
||||
|
@ -197,7 +208,7 @@ export default class ChatThreadPanel extends Component {
|
|||
this.scrollToBottom();
|
||||
})
|
||||
.catch((error) => {
|
||||
this.#onSendError(stagedMessage.stagedId, error);
|
||||
this.#onSendError(stagedMessage.id, error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (this._selfDeleted) {
|
||||
|
|
|
@ -229,7 +229,16 @@ export default class ChatMessageInteractor {
|
|||
|
||||
copyLink() {
|
||||
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;
|
||||
clipboardCopy(url);
|
||||
}
|
||||
|
|
|
@ -47,6 +47,14 @@ export default class ChatThreadsManager {
|
|||
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;
|
||||
}
|
||||
|
||||
|
|
|
@ -30,12 +30,20 @@ export default function withChatChannel(extendedClass) {
|
|||
}
|
||||
|
||||
if (channelTitle && channelTitle !== model.slugifiedTitle) {
|
||||
const nearMessageParams = this.paramsFor("chat.channel.near-message");
|
||||
if (nearMessageParams.messageId) {
|
||||
messageId = this.paramsFor("chat.channel.near-message").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(
|
||||
"chat.channel.near-message",
|
||||
...model.routeModels,
|
||||
nearMessageParams.messageId
|
||||
messageId
|
||||
);
|
||||
} else {
|
||||
this.router.replaceWith("chat.channel", ...model.routeModels);
|
||||
|
|
|
@ -16,7 +16,7 @@ export default class ChatChannelComposer extends Service {
|
|||
this.replyToMsg = null;
|
||||
}
|
||||
|
||||
get #model() {
|
||||
get model() {
|
||||
return this.chat.activeChannel;
|
||||
}
|
||||
|
||||
|
@ -26,7 +26,7 @@ export default class ChatChannelComposer extends Service {
|
|||
|
||||
const message =
|
||||
typeof messageOrId === "number"
|
||||
? this.#model.messagesManager.findMessage(messageOrId)
|
||||
? this.model.messagesManager.findMessage(messageOrId)
|
||||
: messageOrId;
|
||||
this.replyToMsg = message;
|
||||
this.focusComposer();
|
||||
|
@ -38,7 +38,7 @@ export default class ChatChannelComposer extends Service {
|
|||
}
|
||||
|
||||
editButtonClicked(messageId) {
|
||||
const message = this.#model.messagesManager.findMessage(messageId);
|
||||
const message = this.model.messagesManager.findMessage(messageId);
|
||||
this.editingMessage = message;
|
||||
|
||||
// TODO (martin) Move scrollToLatestMessage to live panel.
|
||||
|
@ -53,13 +53,13 @@ export default class ChatChannelComposer extends Service {
|
|||
replyToMsg,
|
||||
inProgressUploadsCount,
|
||||
}) {
|
||||
if (!this.#model) {
|
||||
if (!this.model) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.editingMessage && !this.#model.isDraft) {
|
||||
if (typeof value !== "undefined") {
|
||||
this.#model.draft.message = value;
|
||||
if (!this.editingMessage && !this.model.isDraft) {
|
||||
if (typeof value !== "undefined" && this.model.draft) {
|
||||
this.model.draft.message = value;
|
||||
}
|
||||
|
||||
// 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 (
|
||||
typeof uploads !== "undefined" &&
|
||||
inProgressUploadsCount !== "undefined" &&
|
||||
inProgressUploadsCount === 0
|
||||
inProgressUploadsCount === 0 &&
|
||||
this.model.draft
|
||||
) {
|
||||
this.#model.draft.uploads = uploads;
|
||||
this.model.draft.uploads = uploads;
|
||||
}
|
||||
|
||||
if (typeof replyToMsg !== "undefined") {
|
||||
this.#model.draft.replyToMsg = replyToMsg;
|
||||
if (typeof replyToMsg !== "undefined" && this.model.draft) {
|
||||
this.model.draft.replyToMsg = replyToMsg;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.#model.isDraft) {
|
||||
if (!this.model.isDraft) {
|
||||
this.#reportReplyingPresence(value);
|
||||
}
|
||||
|
||||
|
@ -103,25 +104,25 @@ export default class ChatChannelComposer extends Service {
|
|||
return;
|
||||
}
|
||||
|
||||
if (this.#model.isDraft) {
|
||||
if (this.model.isDraft) {
|
||||
return;
|
||||
}
|
||||
|
||||
const replying = !this.editingMessage && !!composerValue;
|
||||
this.chatComposerPresenceManager.notifyState(this.#model.id, replying);
|
||||
this.chatComposerPresenceManager.notifyState(this.model.id, replying);
|
||||
}
|
||||
|
||||
@debounce(2000)
|
||||
_persistDraft() {
|
||||
if (this.#componentDeleted || !this.#model) {
|
||||
if (this.#componentDeleted || !this.model) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.#model.draft) {
|
||||
if (!this.model.draft) {
|
||||
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() {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import ChatChannelComposer from "./chat-channel-composer";
|
||||
|
||||
export default class extends ChatChannelComposer {
|
||||
get #model() {
|
||||
get model() {
|
||||
return this.chat.activeChannel.activeThread;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -12,7 +12,7 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-inline: 1.5rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
&__body {
|
||||
|
@ -27,12 +27,4 @@
|
|||
flex-direction: column-reverse;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
&__close {
|
||||
color: var(--primary-medium);
|
||||
|
||||
&:visited {
|
||||
color: var(--primary-medium);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,6 +20,13 @@ module ChatSystemHelpers
|
|||
Group.refresh_automatic_groups!
|
||||
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)
|
||||
last_user = nil
|
||||
last_message = nil
|
||||
|
|
|
@ -14,4 +14,42 @@ describe Chat::Publisher do
|
|||
expect(data["type"]).to eq("refresh")
|
||||
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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -92,5 +92,20 @@ describe "Thread indicator for chat messages", type: :system, js: true do
|
|||
new_thread = message_without_thread.reload.thread
|
||||
expect(page).not_to have_css(channel_page.message_by_id_selector(new_thread.replies.first))
|
||||
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
|
||||
|
|
|
@ -23,6 +23,10 @@ module PageObjects
|
|||
has_no_css?(".chat-skeleton")
|
||||
end
|
||||
|
||||
def visit_thread(thread)
|
||||
visit(thread.url)
|
||||
end
|
||||
|
||||
def visit_channel_settings(channel)
|
||||
visit(channel.url + "/info/settings")
|
||||
end
|
||||
|
|
|
@ -22,7 +22,7 @@ module PageObjects
|
|||
end
|
||||
|
||||
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
|
||||
|
||||
def message_by_id(id)
|
||||
|
@ -71,6 +71,12 @@ module PageObjects
|
|||
find("[data-value='flag']").click
|
||||
end
|
||||
|
||||
def copy_link(message)
|
||||
hover_message(message)
|
||||
click_more_button
|
||||
find("[data-value='copyLink']").click
|
||||
end
|
||||
|
||||
def flag_message(message)
|
||||
hover_message(message)
|
||||
click_more_button
|
||||
|
|
|
@ -50,7 +50,7 @@ module PageObjects
|
|||
|
||||
def has_message?(thread_id, text: nil, id: nil)
|
||||
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
|
||||
find(thread_selector_by_id(thread_id)).has_css?(
|
||||
".chat-message-container[data-id=\"#{id}\"]",
|
||||
|
|
|
@ -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)
|
||||
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
|
||||
it "opens the side panel for a single thread using the indicator", mobile: true do
|
||||
chat_page.visit_channel(channel)
|
||||
|
|
|
@ -4,6 +4,7 @@ RSpec.describe "Quoting chat message transcripts", type: :system, js: true do
|
|||
fab!(:current_user) { Fabricate(:user) }
|
||||
fab!(:chat_channel_1) { Fabricate(:chat_channel) }
|
||||
|
||||
let(:cdp) { PageObjects::CDP.new }
|
||||
let(:chat_page) { PageObjects::Pages::Chat.new }
|
||||
let(:chat_channel_page) { PageObjects::Pages::ChatChannel.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
|
||||
|
||||
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)
|
||||
selector =
|
||||
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
|
||||
click_selection_button("copy")
|
||||
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))
|
||||
clip_text
|
||||
end
|
||||
|
@ -84,7 +61,7 @@ RSpec.describe "Quoting chat message transcripts", type: :system, js: true do
|
|||
end
|
||||
|
||||
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
|
||||
fab!(:post_1) { Fabricate(:post) }
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue