UX: implements draft threads (#21361)

This commit implements all the necessary logic to create thread seamlessly. For this it relies on the same logic used for messages and generates a `staged-id`(using the format: `staged-thread-CHANNEL_ID-MESSAGE_ID` which is used to re-conciliate state client sides once the thread has been persisted on the backend.

Part of this change the client side is now always using real thread and channel objects instead of sometimes relying on a flat `threadId` or `channelId`.

This PR also brings three UX changes:
- thread starts from top
- number of buttons on message actions is dependent of the width of the enclosing container
- <kbd>shift + ArrowUp</kbd> will reply to the last message
This commit is contained in:
Joffrey JAFFEUX 2023-05-05 08:55:55 +02:00 committed by GitHub
parent fe10c61dfa
commit 187b59d376
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 1075 additions and 378 deletions

View File

@ -107,6 +107,7 @@ module Chat
staged_id: params[:staged_id], staged_id: params[:staged_id],
upload_ids: params[:upload_ids], upload_ids: params[:upload_ids],
thread_id: params[:thread_id], thread_id: params[:thread_id],
staged_thread_id: params[:staged_thread_id],
) )
return render_json_error(chat_message_creator.error) if chat_message_creator.failed? return render_json_error(chat_message_creator.error) if chat_message_creator.failed?

View File

@ -14,7 +14,7 @@ module Chat
"#{root_message_bus_channel(chat_channel_id)}/thread/#{thread_id}" "#{root_message_bus_channel(chat_channel_id)}/thread/#{thread_id}"
end end
def self.calculate_publish_targets(channel, message) def self.calculate_publish_targets(channel, message, staged_thread_id: nil)
return [root_message_bus_channel(channel.id)] if !allow_publish_to_thread?(channel) return [root_message_bus_channel(channel.id)] if !allow_publish_to_thread?(channel)
if message.thread_om? if message.thread_om?
@ -22,8 +22,10 @@ module Chat
root_message_bus_channel(channel.id), root_message_bus_channel(channel.id),
thread_message_bus_channel(channel.id, message.thread_id), thread_message_bus_channel(channel.id, message.thread_id),
] ]
elsif message.thread_reply? elsif staged_thread_id || message.thread_reply?
[thread_message_bus_channel(channel.id, message.thread_id)] targets = [thread_message_bus_channel(channel.id, message.thread_id)]
targets << thread_message_bus_channel(channel.id, staged_thread_id) if staged_thread_id
targets
else else
[root_message_bus_channel(channel.id)] [root_message_bus_channel(channel.id)]
end end
@ -33,12 +35,16 @@ module Chat
SiteSetting.enable_experimental_chat_threaded_discussions && channel.threading_enabled SiteSetting.enable_experimental_chat_threaded_discussions && channel.threading_enabled
end end
def self.publish_new!(chat_channel, chat_message, staged_id) def self.publish_new!(chat_channel, chat_message, staged_id, staged_thread_id: nil)
message_bus_targets = calculate_publish_targets(chat_channel, chat_message) message_bus_targets =
calculate_publish_targets(chat_channel, chat_message, staged_thread_id: staged_thread_id)
publish_to_targets!( publish_to_targets!(
message_bus_targets, message_bus_targets,
chat_channel, chat_channel,
serialize_message_with_type(chat_message, :sent).merge(staged_id: staged_id), serialize_message_with_type(chat_message, :sent).merge(
staged_id: staged_id,
staged_thread_id: staged_thread_id,
),
) )
# NOTE: This means that the read count is only updated in the client # NOTE: This means that the read count is only updated in the client
@ -70,8 +76,15 @@ module Chat
) )
end end
def self.publish_thread_created!(chat_channel, chat_message) def self.publish_thread_created!(chat_channel, chat_message, thread_id, staged_thread_id)
publish_to_channel!(chat_channel, serialize_message_with_type(chat_message, :thread_created)) publish_to_channel!(
chat_channel,
serialize_message_with_type(
chat_message,
:thread_created,
{ thread_id: thread_id, staged_thread_id: staged_thread_id },
),
)
end end
def self.publish_processed!(chat_message) def self.publish_processed!(chat_message)
@ -215,11 +228,12 @@ module Chat
end end
end end
def self.serialize_message_with_type(chat_message, type) def self.serialize_message_with_type(chat_message, type, options = {})
Chat::MessageSerializer Chat::MessageSerializer
.new(chat_message, { scope: anonymous_guardian, root: :chat_message }) .new(chat_message, { scope: anonymous_guardian, root: :chat_message })
.as_json .as_json
.merge(type: type) .merge(type: type)
.merge(options)
end end
def self.user_tracking_state_message_bus_channel(user_id) def self.user_tracking_state_message_bus_channel(user_id)

View File

@ -1,5 +1,6 @@
import { capitalize } from "@ember/string"; import { capitalize } from "@ember/string";
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
import ChatThread from "discourse/plugins/chat/discourse/models/chat-thread";
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 { action } from "@ember/object"; import { action } from "@ember/object";
@ -352,7 +353,13 @@ export default class ChatLivePane extends Component {
messageData.newest = true; messageData.newest = true;
} }
messages.push(ChatMessage.create(channel, messageData)); const message = ChatMessage.create(channel, messageData);
if (messageData.thread_id) {
message.thread = new ChatThread(channel, { id: messageData.thread_id });
}
messages.push(message);
}); });
return [messages, results.meta]; return [messages, results.meta];
@ -548,7 +555,11 @@ 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 = handleStagedMessage(this.#messagesManager, data); const stagedMessage = handleStagedMessage(
this.args.channel,
this.#messagesManager,
data
);
if (stagedMessage) { if (stagedMessage) {
return; return;
} }

View File

@ -25,7 +25,7 @@
{{did-update this.didUpdateMessage this.currentMessage}} {{did-update this.didUpdateMessage this.currentMessage}}
{{did-update this.didUpdateInReplyTo this.currentMessage.inReplyTo}} {{did-update this.didUpdateInReplyTo this.currentMessage.inReplyTo}}
{{did-insert this.setupAppEvents}} {{did-insert this.setupAppEvents}}
{{will-destroy this.teardownAppEvents}} {{will-destroy this.teardown}}
{{will-destroy this.cancelPersistDraft}} {{will-destroy this.cancelPersistDraft}}
> >
<div class="chat-composer__outer-container"> <div class="chat-composer__outer-container">

View File

@ -17,6 +17,8 @@ import I18n from "I18n";
import { translations } from "pretty-text/emoji/data"; import { translations } from "pretty-text/emoji/data";
import { setupHashtagAutocomplete } from "discourse/lib/hashtag-autocomplete"; import { setupHashtagAutocomplete } from "discourse/lib/hashtag-autocomplete";
import { isEmpty, isPresent } from "@ember/utils"; import { isEmpty, isPresent } from "@ember/utils";
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
import { Promise } from "rsvp";
export default class ChatComposer extends Component { export default class ChatComposer extends Component {
@service capabilities; @service capabilities;
@ -39,7 +41,10 @@ export default class ChatComposer extends Component {
} }
get shouldRenderMessageDetails() { get shouldRenderMessageDetails() {
return this.currentMessage?.editing || this.currentMessage?.inReplyTo; return (
this.currentMessage?.editing ||
(this.context === "channel" && this.currentMessage?.inReplyTo)
);
} }
get inlineButtons() { get inlineButtons() {
@ -70,6 +75,18 @@ export default class ChatComposer extends Component {
); );
} }
@action
sendMessage(raw) {
const message = ChatMessage.createDraftMessage(this.args.channel, {
user: this.currentUser,
message: raw,
});
this.args.onSendMessage(message);
return Promise.resolve();
}
@action @action
persistDraft() {} persistDraft() {}
@ -133,13 +150,14 @@ export default class ChatComposer extends Component {
} }
@action @action
teardownAppEvents() { teardown() {
this.appEvents.off("chat:modify-selection", this, "modifySelection"); this.appEvents.off("chat:modify-selection", this, "modifySelection");
this.appEvents.off( this.appEvents.off(
"chat:open-insert-link-modal", "chat:open-insert-link-modal",
this, this,
"openInsertLinkModal" "openInsertLinkModal"
); );
this.pane.sending = false;
} }
@action @action
@ -221,7 +239,7 @@ export default class ChatComposer extends Component {
} }
reportReplyingPresence() { reportReplyingPresence() {
if (!this.args.channel) { if (!this.args.channel || !this.currentMessage) {
return; return;
} }
@ -305,9 +323,13 @@ export default class ChatComposer extends Component {
!this.hasContent && !this.hasContent &&
!this.currentMessage.editing !this.currentMessage.editing
) { ) {
const editableMessage = this.pane?.lastCurrentUserMessage; if (event.shiftKey) {
if (editableMessage) { this.composer.replyTo(this.pane?.lastMessage);
this.composer.editMessage(editableMessage); } else {
const editableMessage = this.pane?.lastCurrentUserMessage;
if (editableMessage) {
this.composer.editMessage(editableMessage);
}
} }
} }

View File

@ -24,7 +24,7 @@
{{did-update this.fetchChannelAndThread @params.threadId}} {{did-update this.fetchChannelAndThread @params.threadId}}
> >
{{#if this.chat.activeChannel.activeThread}} {{#if this.chat.activeChannel.activeThread}}
<ChatThread /> <ChatThread @thread={{this.chat.activeChannel.activeThread}} />
{{/if}} {{/if}}
</div> </div>
{{/if}} {{/if}}

View File

@ -1,13 +1,16 @@
{{#if (and this.site.desktopView this.chat.activeMessage.model.id)}} {{#if (and this.site.desktopView this.chat.activeMessage.model.id)}}
<div <div
{{did-insert this.setupPopper}} {{did-insert this.setup}}
{{did-update this.setupPopper this.chat.activeMessage.model.id}} {{did-update this.setup this.chat.activeMessage.model.id}}
{{will-destroy this.teardownPopper}} {{will-destroy this.teardown}}
class="chat-message-actions-container" class={{concat-class
"chat-message-actions-container"
(concat "is-size-" this.size)
}}
data-id={{this.message.id}} data-id={{this.message.id}}
> >
<div class="chat-message-actions"> <div class="chat-message-actions">
{{#if this.chatStateManager.isFullPageActive}} {{#if this.shouldRenderFavoriteReactions}}
{{#each {{#each
this.messageInteractor.emojiReactions this.messageInteractor.emojiReactions
key="emoji" key="emoji"
@ -50,7 +53,12 @@
/> />
{{/if}} {{/if}}
{{#if this.messageInteractor.secondaryButtons.length}} {{#if
(and
this.messageInteractor.message
this.messageInteractor.secondaryButtons.length
)
}}
<DropdownSelectBox <DropdownSelectBox
@class="more-buttons" @class="more-buttons"
@options={{hash icon="ellipsis-v" placement="left"}} @options={{hash icon="ellipsis-v" placement="left"}}

View File

@ -6,15 +6,20 @@ import { schedule } from "@ember/runloop";
import { createPopper } from "@popperjs/core"; import { createPopper } from "@popperjs/core";
import chatMessageContainer from "discourse/plugins/chat/discourse/lib/chat-message-container"; import chatMessageContainer from "discourse/plugins/chat/discourse/lib/chat-message-container";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { tracked } from "@glimmer/tracking";
const MSG_ACTIONS_VERTICAL_PADDING = -10; const MSG_ACTIONS_VERTICAL_PADDING = -10;
const FULL = "full";
const REDUCED = "reduced";
const REDUCED_WIDTH_THRESHOLD = 500;
export default class ChatMessageActionsDesktop extends Component { export default class ChatMessageActionsDesktop extends Component {
@service chat; @service chat;
@service chatStateManager;
@service chatEmojiPickerManager; @service chatEmojiPickerManager;
@service site; @service site;
@tracked size = FULL;
popper = null; popper = null;
get message() { get message() {
@ -35,8 +40,12 @@ export default class ChatMessageActionsDesktop extends Component {
); );
} }
get shouldRenderFavoriteReactions() {
return this.size === FULL;
}
@action @action
setupPopper(element) { setup(element) {
this.popper?.destroy(); this.popper?.destroy();
schedule("afterRender", () => { schedule("afterRender", () => {
@ -45,6 +54,10 @@ export default class ChatMessageActionsDesktop extends Component {
this.context this.context
); );
const viewport = messageContainer.closest(".popper-viewport");
this.size =
viewport.clientWidth < REDUCED_WIDTH_THRESHOLD ? REDUCED : FULL;
if (!messageContainer) { if (!messageContainer) {
return; return;
} }
@ -57,7 +70,7 @@ export default class ChatMessageActionsDesktop extends Component {
name: "flip", name: "flip",
enabled: true, enabled: true,
options: { options: {
boundary: messageContainer.closest(".popper-viewport"), boundary: viewport,
fallbackPlacements: ["bottom-end"], fallbackPlacements: ["bottom-end"],
}, },
}, },
@ -73,7 +86,7 @@ export default class ChatMessageActionsDesktop extends Component {
} }
@action @action
teardownPopper() { teardown() {
this.popper?.destroy(); this.popper?.destroy();
} }
} }

View File

@ -64,6 +64,7 @@
@icon="discourse-emojis" @icon="discourse-emojis"
@title="chat.react" @title="chat.react"
@forwardEvent={{true}} @forwardEvent={{true}}
data-id="react"
/> />
{{/if}} {{/if}}
@ -71,6 +72,7 @@
<DButton <DButton
@class="btn-flat bookmark-btn" @class="btn-flat bookmark-btn"
@action={{action this.actAndCloseMenu "toggleBookmark"}} @action={{action this.actAndCloseMenu "toggleBookmark"}}
data-id="bookmark"
> >
<BookmarkIcon @bookmark={{this.message.bookmark}} /> <BookmarkIcon @bookmark={{this.message.bookmark}} />
</DButton> </DButton>
@ -82,6 +84,7 @@
@action={{action this.actAndCloseMenu "reply"}} @action={{action this.actAndCloseMenu "reply"}}
@icon="reply" @icon="reply"
@title="chat.reply" @title="chat.reply"
data-id="reply"
/> />
{{/if}} {{/if}}
</div> </div>

View File

@ -16,7 +16,7 @@ export default class ChatMessageInReplyToIndicator extends Component {
if (this.hasThread) { if (this.hasThread) {
return [ return [
...this.args.message.channel.routeModels, ...this.args.message.channel.routeModels,
this.args.message.threadId, this.args.message.thread.id,
]; ];
} else { } else {
return [ return [
@ -29,7 +29,7 @@ export default class ChatMessageInReplyToIndicator extends Component {
get hasThread() { get hasThread() {
return ( return (
this.args.message?.channel?.threadingEnabled && this.args.message?.channel?.threadingEnabled &&
this.args.message?.threadId this.args.message?.thread?.id
); );
} }
} }

View File

@ -1,6 +1,6 @@
<LinkTo <LinkTo
@route="chat.channel.thread" @route="chat.channel.thread"
@models={{@message.threadRouteModels}} @models={{@message.thread.routeModels}}
class="chat-message-thread-indicator" class="chat-message-thread-indicator"
> >
<span class="chat-message-thread-indicator__replies-count"> <span class="chat-message-thread-indicator__replies-count">

View File

@ -22,7 +22,7 @@
(if @message.highlighted "highlighted") (if @message.highlighted "highlighted")
}} }}
data-id={{@message.id}} data-id={{@message.id}}
data-thread-id={{@message.threadId}} data-thread-id={{@message.thread.id}}
{{chat/track-message {{chat/track-message
(hash (hash
didEnterViewport=(fn @messageDidEnterViewport @message) didEnterViewport=(fn @messageDidEnterViewport @message)

View File

@ -282,15 +282,15 @@ export default class ChatMessage extends Component {
} }
get threadingEnabled() { get threadingEnabled() {
return this.args.channel?.threadingEnabled && this.args.message?.threadId; return this.args.channel?.threadingEnabled && !!this.args.message?.thread;
} }
get showThreadIndicator() { get showThreadIndicator() {
return ( return (
this.args.context !== MESSAGE_CONTEXT_THREAD && this.args.context !== MESSAGE_CONTEXT_THREAD &&
this.threadingEnabled && this.threadingEnabled &&
this.args.message?.threadId !== this.args.message?.thread &&
this.args.message?.previousMessage?.threadId this.args.message?.threadReplyCount > 0
); );
} }
@ -352,7 +352,7 @@ export default class ChatMessage extends Component {
inviteMentioned() { inviteMentioned() {
const userIds = this.mentionWarning.without_membership.mapBy("id"); const userIds = this.mentionWarning.without_membership.mapBy("id");
ajax(`/chat/${this.args.message.channelId}/invite`, { ajax(`/chat/${this.args.message.channel.id}/invite`, {
method: "PUT", method: "PUT",
data: { user_ids: userIds, chat_message_id: this.args.message.id }, data: { user_ids: userIds, chat_message_id: this.args.message.id },
}).then(() => { }).then(() => {

View File

@ -3,9 +3,9 @@
data-id={{this.thread.id}} data-id={{this.thread.id}}
{{did-insert this.setUploadDropZone}} {{did-insert this.setUploadDropZone}}
{{did-insert this.subscribeToUpdates}} {{did-insert this.subscribeToUpdates}}
{{did-insert this.loadMessages}}
{{did-update this.subscribeToUpdates this.thread.id}} {{did-update this.subscribeToUpdates this.thread.id}}
{{did-update this.loadMessages this.thread.id}} {{did-insert this.loadMessages}}
{{did-update this.loadMessages this.thread}}
{{did-insert this.setupMessage}} {{did-insert this.setupMessage}}
{{will-destroy this.unsubscribeFromUpdates}} {{will-destroy this.unsubscribeFromUpdates}}
> >
@ -42,7 +42,7 @@
@context="thread" @context="thread"
/> />
{{/each}} {{/each}}
{{#if (or this.loading this.loadingMoreFuture)}} {{#if this.loading}}
<ChatSkeleton /> <ChatSkeleton />
{{/if}} {{/if}}
</div> </div>

View File

@ -8,6 +8,7 @@ import { bind, debounce } from "discourse-common/utils/decorators";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import { schedule } from "@ember/runloop"; import { schedule } from "@ember/runloop";
import discourseLater from "discourse-common/lib/later"; import discourseLater from "discourse-common/lib/later";
import { resetIdle } from "discourse/lib/desktop-notifications";
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
@ -25,22 +26,16 @@ export default class ChatThreadPanel extends Component {
@service capabilities; @service capabilities;
@tracked loading; @tracked loading;
@tracked loadingMorePast;
@tracked uploadDropZone; @tracked uploadDropZone;
scrollable = null; scrollable = null;
get thread() { get thread() {
return this.channel.activeThread; return this.args.thread;
} }
get channel() { get channel() {
return this.chat.activeChannel; return this.thread?.channel;
}
@action
subscribeToUpdates() {
this.chatChannelThreadPaneSubscriptionsManager.subscribe(this.thread);
} }
@action @action
@ -56,6 +51,11 @@ export default class ChatThreadPanel extends Component {
); );
} }
@action
subscribeToUpdates() {
this.chatChannelThreadPaneSubscriptionsManager.subscribe(this.thread);
}
@action @action
unsubscribeFromUpdates() { unsubscribeFromUpdates() {
this.chatChannelThreadPaneSubscriptionsManager.unsubscribe(); this.chatChannelThreadPaneSubscriptionsManager.unsubscribe();
@ -69,17 +69,7 @@ export default class ChatThreadPanel extends Component {
@action @action
loadMessages() { loadMessages() {
this.thread.messagesManager.clearMessages(); this.thread.messagesManager.clearMessages();
if (this.args.targetMessageId) {
this.requestedTargetMessageId = parseInt(this.args.targetMessageId, 10);
}
// TODO (martin) Loading/scrolling to selected message
// this.highlightOrFetchMessage(this.requestedTargetMessageId);
// if (this.requestedTargetMessageId) {
// } else {
this.fetchMessages(); this.fetchMessages();
// }
} }
@action @action
@ -97,21 +87,14 @@ export default class ChatThreadPanel extends Component {
return Promise.resolve(); return Promise.resolve();
} }
this.loadingMorePast = true; if (this.thread.staged) {
this.thread.messagesManager.addMessages([this.thread.originalMessage]);
return Promise.resolve();
}
this.loading = true; this.loading = true;
const findArgs = { pageSize: PAGE_SIZE }; const findArgs = { pageSize: PAGE_SIZE, threadId: this.thread.id };
// TODO (martin) Find arguments for last read etc.
// const fetchingFromLastRead = !options.fetchFromLastMessage;
// if (this.requestedTargetMessageId) {
// findArgs["targetMessageId"] = this.requestedTargetMessageId;
// } else if (fetchingFromLastRead) {
// findArgs["targetMessageId"] = this._getLastReadId();
// }
//
findArgs.threadId = this.thread.id;
return this.chatApi return this.chatApi
.messages(this.channel.id, findArgs) .messages(this.channel.id, findArgs)
.then((results) => { .then((results) => {
@ -123,23 +106,13 @@ export default class ChatThreadPanel extends Component {
); );
} }
const [messages, meta] = this.afterFetchCallback(this.channel, results); const [messages, meta] = this.afterFetchCallback(
this.channel,
this.thread,
results
);
this.thread.messagesManager.addMessages(messages); this.thread.messagesManager.addMessages(messages);
// TODO (martin) details needed for thread??
this.thread.details = meta; this.thread.details = meta;
// TODO (martin) Scrolling to particular messages
// if (this.requestedTargetMessageId) {
// this.scrollToMessage(findArgs["targetMessageId"], {
// highlight: true,
// });
// } else if (fetchingFromLastRead) {
// this.scrollToMessage(findArgs["targetMessageId"]);
// } else if (messages.length) {
// this.scrollToMessage(messages.lastObject.id);
// }
//
this.markThreadAsRead(); this.markThreadAsRead();
}) })
.catch(this.#handleErrors) .catch(this.#handleErrors)
@ -148,16 +121,12 @@ export default class ChatThreadPanel extends Component {
return; return;
} }
this.requestedTargetMessageId = null;
this.loading = false; this.loading = false;
this.loadingMorePast = false;
// this.fillPaneAttempt();
}); });
} }
@bind @bind
afterFetchCallback(channel, results) { afterFetchCallback(channel, thread, results) {
const messages = []; const messages = [];
results.chat_messages.forEach((messageData) => { results.chat_messages.forEach((messageData) => {
@ -170,13 +139,10 @@ export default class ChatThreadPanel extends Component {
); );
} }
if (this.requestedTargetMessageId === messageData.id) { messageData.expanded = !(messageData.hidden || messageData.deleted_at);
messageData.expanded = !messageData.hidden; const message = ChatMessage.create(channel, messageData);
} else { message.thread = thread;
messageData.expanded = !(messageData.hidden || messageData.deleted_at); messages.push(message);
}
messages.push(ChatMessage.create(channel, messageData));
}); });
return [messages, results.meta]; return [messages, results.meta];
@ -191,6 +157,8 @@ export default class ChatThreadPanel extends Component {
@action @action
onSendMessage(message) { onSendMessage(message) {
resetIdle();
if (message.editing) { if (message.editing) {
this.#sendEditMessage(message); this.#sendEditMessage(message);
} else { } else {
@ -200,7 +168,7 @@ export default class ChatThreadPanel extends Component {
@action @action
resetComposer() { resetComposer() {
this.chatChannelThreadComposer.reset(this.channel); this.chatChannelThreadComposer.reset(this.channel, this.thread);
} }
@action @action
@ -209,34 +177,27 @@ export default class ChatThreadPanel extends Component {
} }
#sendNewMessage(message) { #sendNewMessage(message) {
// TODO (martin) For desktop notifications message.thread = this.thread;
// resetIdle()
if (this.chatChannelThreadPane.sending) { if (this.chatChannelThreadPane.sending) {
return; return;
} }
this.chatChannelThreadPane.sending = true; this.chatChannelThreadPane.sending = true;
// TODO (martin) Handling case when channel is not followed???? IDK if we
// even let people send messages in threads without this, seems weird.
this.thread.stageMessage(message); this.thread.stageMessage(message);
const stagedMessage = message; const stagedMessage = message;
this.resetComposer(); this.resetComposer();
this.thread.messagesManager.addMessages([stagedMessage]); this.thread.messagesManager.addMessages([stagedMessage]);
// TODO (martin) Scrolling!!
// if (!this.channel.canLoadMoreFuture) {
// this.scrollToBottom();
// }
return this.chatApi return this.chatApi
.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.id, 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: this.thread.staged ? null : stagedMessage.thread.id,
staged_thread_id: this.thread.staged ? stagedMessage.thread.id : null,
}) })
.then(() => { .then(() => {
this.scrollToBottom(); this.scrollToBottom();
@ -264,7 +225,7 @@ export default class ChatThreadPanel extends Component {
this.resetComposer(); this.resetComposer();
return this.chatApi return this.chatApi
.editMessage(message.channelId, message.id, data) .editMessage(message.channel.id, message.id, data)
.catch(popupAjaxError) .catch(popupAjaxError)
.finally(() => { .finally(() => {
this.chatChannelThreadPane.sending = false; this.chatChannelThreadPane.sending = false;
@ -280,9 +241,9 @@ export default class ChatThreadPanel extends Component {
return; return;
} }
this.scrollable.scrollTop = -1; this.scrollable.scrollTop = this.scrollable.scrollHeight + 1;
this.forceRendering(() => { this.forceRendering(() => {
this.scrollable.scrollTop = 0; this.scrollable.scrollTop = this.scrollable.scrollHeight;
}); });
} }
@ -319,7 +280,6 @@ export default class ChatThreadPanel extends Component {
@action @action
resendStagedMessage() {} resendStagedMessage() {}
// resendStagedMessage(stagedMessage) {}
@action @action
messageDidEnterViewport(message) { messageDidEnterViewport(message) {

View File

@ -3,8 +3,6 @@ import { inject as service } from "@ember/service";
import I18n from "I18n"; import I18n from "I18n";
import discourseDebounce from "discourse-common/lib/debounce"; import discourseDebounce from "discourse-common/lib/debounce";
import { action } from "@ember/object"; import { action } from "@ember/object";
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
import { Promise } from "rsvp";
export default class ChatComposerChannel extends ChatComposer { export default class ChatComposerChannel extends ChatComposer {
@service("chat-channel-composer") composer; @service("chat-channel-composer") composer;
@ -14,18 +12,6 @@ export default class ChatComposerChannel extends ChatComposer {
composerId = "channel-composer"; composerId = "channel-composer";
@action
sendMessage(raw) {
const message = ChatMessage.createDraftMessage(this.args.channel, {
user: this.currentUser,
message: raw,
});
this.args.onSendMessage(message);
return Promise.resolve();
}
@action @action
persistDraft() { persistDraft() {
if (this.args.channel?.isDraft) { if (this.args.channel?.isDraft) {

View File

@ -1,32 +1,32 @@
import ChatComposer from "../../chat-composer"; import ChatComposer from "../../chat-composer";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import I18n from "I18n"; import I18n from "I18n";
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
import { Promise } from "rsvp";
import { action } from "@ember/object"; import { action } from "@ember/object";
export default class ChatComposerThread extends ChatComposer { export default class ChatComposerThread extends ChatComposer {
@service("chat-channel-thread-composer") composer; @service("chat-channel-thread-composer") composer;
@service("chat-channel-composer") channelComposer;
@service("chat-channel-thread-pane") pane; @service("chat-channel-thread-pane") pane;
@service router;
context = "thread"; context = "thread";
composerId = "thread-composer"; composerId = "thread-composer";
@action
sendMessage(raw) {
const message = ChatMessage.createDraftMessage(this.args.channel, {
user: this.currentUser,
message: raw,
thread_id: this.args.channel.activeThread.id,
});
this.args.onSendMessage(message);
return Promise.resolve();
}
get placeholder() { get placeholder() {
return I18n.t("chat.placeholder_thread"); return I18n.t("chat.placeholder_thread");
} }
@action
onKeyDown(event) {
if (event.key === "Escape") {
this.router.transitionTo(
"chat.channel",
...this.args.channel.routeModels
);
return;
}
super.onKeyDown(event);
}
} }

View File

@ -17,12 +17,12 @@ registerUnbound("format-chat-date", function (message, mode) {
if (message.staged) { if (message.staged) {
return htmlSafe( return htmlSafe(
`<span title='${title}' class='chat-time'>${display}</span>` `<span title='${title}' tabindex="-1" class='chat-time'>${display}</span>`
); );
} else { } else {
const url = getURL(`/chat/c/-/${message.channel.id}/${message.id}`); const url = getURL(`/chat/c/-/${message.channel.id}/${message.id}`);
return htmlSafe( return htmlSafe(
`<a title='${title}' class='chat-time' href='${url}'>${display}</a>` `<a title='${title}' tabindex="-1" class='chat-time' href='${url}'>${display}</a>`
); );
} }
}); });

View File

@ -229,8 +229,8 @@ export default class ChatMessageInteractor {
copyLink() { copyLink() {
const { protocol, host } = window.location; const { protocol, host } = window.location;
const channelId = this.message.channelId; const channelId = this.message.channel.id;
const threadId = this.message.threadId; const threadId = this.message.thread?.id;
let url; let url;
if (threadId) { if (threadId) {
@ -276,7 +276,7 @@ export default class ChatMessageInteractor {
return this.chatApi return this.chatApi
.publishReaction( .publishReaction(
this.message.channelId, this.message.channel.id,
this.message.id, this.message.id,
emoji, emoji,
reactAction reactAction
@ -329,21 +329,21 @@ export default class ChatMessageInteractor {
@action @action
delete() { delete() {
return this.chatApi return this.chatApi
.trashMessage(this.message.channelId, this.message.id) .trashMessage(this.message.channel.id, this.message.id)
.catch(popupAjaxError); .catch(popupAjaxError);
} }
@action @action
restore() { restore() {
return this.chatApi return this.chatApi
.restoreMessage(this.message.channelId, this.message.id) .restoreMessage(this.message.channel.id, this.message.id)
.catch(popupAjaxError); .catch(popupAjaxError);
} }
@action @action
rebake() { rebake() {
return this.chatApi return this.chatApi
.rebakeMessage(this.message.channelId, this.message.id) .rebakeMessage(this.message.channel.id, this.message.id)
.catch(popupAjaxError); .catch(popupAjaxError);
} }

View File

@ -4,7 +4,6 @@ import Promise from "rsvp";
import ChatThread from "discourse/plugins/chat/discourse/models/chat-thread"; import ChatThread from "discourse/plugins/chat/discourse/models/chat-thread";
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import { TrackedObject } from "@ember-compat/tracked-built-ins"; import { TrackedObject } from "@ember-compat/tracked-built-ins";
import { popupAjaxError } from "discourse/lib/ajax-error";
/* /*
The ChatThreadsManager is responsible for managing the loaded chat threads The ChatThreadsManager is responsible for managing the loaded chat threads
@ -39,11 +38,16 @@ export default class ChatThreadsManager {
return Object.values(this._cached); return Object.values(this._cached);
} }
store(threadObject) { store(channel, threadObject) {
let model = this.#findStale(threadObject.id); let model = this.#findStale(threadObject.id);
if (!model) { if (!model) {
model = new ChatThread(threadObject); if (threadObject instanceof ChatThread) {
model = threadObject;
} else {
model = new ChatThread(channel, threadObject);
}
this.#cache(model); this.#cache(model);
} }
@ -59,12 +63,7 @@ export default class ChatThreadsManager {
} }
async #find(channelId, threadId) { async #find(channelId, threadId) {
return this.chatApi return this.chatApi.thread(channelId, threadId);
.thread(channelId, threadId)
.catch(popupAjaxError)
.then((thread) => {
return thread;
});
} }
#cache(thread) { #cache(thread) {

View File

@ -9,6 +9,7 @@ import ChatThreadsManager from "discourse/plugins/chat/discourse/lib/chat-thread
import ChatMessagesManager from "discourse/plugins/chat/discourse/lib/chat-messages-manager"; import ChatMessagesManager from "discourse/plugins/chat/discourse/lib/chat-messages-manager";
import { getOwner } from "discourse-common/lib/get-owner"; import { getOwner } from "discourse-common/lib/get-owner";
import guid from "pretty-text/guid"; import guid from "pretty-text/guid";
import ChatThread from "discourse/plugins/chat/discourse/models/chat-thread";
export const CHATABLE_TYPES = { export const CHATABLE_TYPES = {
directMessageChannel: "DirectMessage", directMessageChannel: "DirectMessage",
@ -71,6 +72,10 @@ export default class ChatChannel extends RestModel {
return this.messages.findIndex((m) => m.id === message.id); return this.messages.findIndex((m) => m.id === message.id);
} }
findMessage(id) {
return this.messagesManager.findMessage(id);
}
get messages() { get messages() {
return this.messagesManager.messages; return this.messagesManager.messages;
} }
@ -155,6 +160,23 @@ export default class ChatChannel extends RestModel {
this.channelMessageBusLastId = details.channel_message_bus_last_id; this.channelMessageBusLastId = details.channel_message_bus_last_id;
} }
createStagedThread(message) {
const clonedMessage = message.duplicate();
const thread = new ChatThread(this, {
id: `staged-thread-${message.channel.id}-${message.id}`,
original_message: message,
staged: true,
created_at: moment.utc().format(),
});
clonedMessage.thread = thread;
this.threadsManager.store(this, thread);
thread.messagesManager.addMessages([clonedMessage]);
return thread;
}
stageMessage(message) { stageMessage(message) {
message.id = guid(); message.id = guid();
message.staged = true; message.staged = true;
@ -163,11 +185,6 @@ export default class ChatChannel extends RestModel {
message.cook(); message.cook();
if (message.inReplyTo) { if (message.inReplyTo) {
if (!message.inReplyTo.threadId) {
message.inReplyTo.threadId = guid();
message.inReplyTo.threadReplyCount = 1;
}
if (!this.threadingEnabled) { if (!this.threadingEnabled) {
this.messagesManager.addMessages([message]); this.messagesManager.addMessages([message]);
} }

View File

@ -31,8 +31,6 @@ export default class ChatMessage {
@tracked deletedAt; @tracked deletedAt;
@tracked uploads; @tracked uploads;
@tracked excerpt; @tracked excerpt;
@tracked threadId;
@tracked threadReplyCount;
@tracked reactions; @tracked reactions;
@tracked reviewableId; @tracked reviewableId;
@tracked user; @tracked user;
@ -50,11 +48,12 @@ export default class ChatMessage {
@tracked highlighted = false; @tracked highlighted = false;
@tracked firstOfResults = false; @tracked firstOfResults = false;
@tracked message; @tracked message;
@tracked thread;
@tracked threadReplyCount;
@tracked _cooked; @tracked _cooked;
constructor(channel, args = {}) { constructor(channel, args = {}) {
this.channel = channel; // when modifying constructor, be sure to update duplicate function accordingly
this.id = args.id; this.id = args.id;
this.newest = args.newest; this.newest = args.newest;
this.firstOfResults = args.firstOfResults; this.firstOfResults = args.firstOfResults;
@ -62,23 +61,23 @@ export default class ChatMessage {
this.edited = args.edited; this.edited = args.edited;
this.availableFlags = args.availableFlags || args.available_flags; this.availableFlags = args.availableFlags || args.available_flags;
this.hidden = args.hidden; this.hidden = args.hidden;
this.threadId = args.threadId || args.thread_id;
this.threadReplyCount = args.threadReplyCount || args.thread_reply_count; this.threadReplyCount = args.threadReplyCount || args.thread_reply_count;
this.channelId = args.channelId || args.chat_channel_id;
this.chatWebhookEvent = args.chatWebhookEvent || args.chat_webhook_event; this.chatWebhookEvent = args.chatWebhookEvent || args.chat_webhook_event;
this.createdAt = args.createdAt || args.created_at; this.createdAt = args.createdAt || args.created_at;
this.deletedAt = args.deletedAt || args.deleted_at; this.deletedAt = args.deletedAt || args.deleted_at;
this.excerpt = args.excerpt; this.excerpt = args.excerpt;
this.reviewableId = args.reviewableId || args.reviewable_id; this.reviewableId = args.reviewableId || args.reviewable_id;
this.userFlagStatus = args.userFlagStatus || args.user_flag_status; this.userFlagStatus = args.userFlagStatus || args.user_flag_status;
this.draft = args.draft;
this.message = args.message || "";
this._cooked = args.cooked || "";
this.thread = args.thread;
this.inReplyTo = this.inReplyTo =
args.inReplyTo || args.inReplyTo ||
(args.in_reply_to || args.replyToMsg (args.in_reply_to || args.replyToMsg
? ChatMessage.create(channel, args.in_reply_to || args.replyToMsg) ? ChatMessage.create(channel, args.in_reply_to || args.replyToMsg)
: null); : null);
this.draft = args.draft; this.channel = channel;
this.message = args.message || "";
this._cooked = args.cooked || "";
this.reactions = this.#initChatMessageReactionModel( this.reactions = this.#initChatMessageReactionModel(
args.id, args.id,
args.reactions args.reactions
@ -88,6 +87,39 @@ export default class ChatMessage {
this.bookmark = args.bookmark ? Bookmark.create(args.bookmark) : null; this.bookmark = args.bookmark ? Bookmark.create(args.bookmark) : null;
} }
duplicate() {
// This is important as a message can exist in the context of a channel or a thread
// The current strategy is to have a different message object in each cases to avoid
// side effects
const message = new ChatMessage(this.channel, {
id: this.id,
newest: this.newest,
staged: this.staged,
edited: this.edited,
availableFlags: this.availableFlags,
hidden: this.hidden,
threadReplyCount: this.threadReplyCount,
chatWebhookEvent: this.chatWebhookEvent,
createdAt: this.createdAt,
deletedAt: this.deletedAt,
excerpt: this.excerpt,
reviewableId: this.reviewableId,
userFlagStatus: this.userFlagStatus,
draft: this.draft,
message: this.message,
cooked: this.cooked,
});
message.thread = this.thread;
message.reactions = this.reactions;
message.user = this.user;
message.inReplyTo = this.inReplyTo;
message.bookmark = this.bookmark;
message.uploads = this.uploads;
return message;
}
get cooked() { get cooked() {
return this._cooked; return this._cooked;
} }
@ -131,10 +163,6 @@ export default class ChatMessage {
} }
} }
get threadRouteModels() {
return [...this.channel.routeModels, this.threadId];
}
get read() { get read() {
return this.channel.currentUserMembership?.last_read_message_id >= this.id; return this.channel.currentUserMembership?.last_read_message_id >= this.id;
} }

View File

@ -4,6 +4,7 @@ import User from "discourse/models/user";
import { escapeExpression } from "discourse/lib/utilities"; import { escapeExpression } from "discourse/lib/utilities";
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import guid from "pretty-text/guid"; import guid from "pretty-text/guid";
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
export const THREAD_STATUSES = { export const THREAD_STATUSES = {
open: "open", open: "open",
@ -13,20 +14,25 @@ export const THREAD_STATUSES = {
}; };
export default class ChatThread { export default class ChatThread {
@tracked id;
@tracked title; @tracked title;
@tracked status; @tracked status;
@tracked draft;
@tracked staged;
@tracked channel;
@tracked originalMessage;
@tracked threadMessageBusLastId;
messagesManager = new ChatMessagesManager(getOwner(this)); messagesManager = new ChatMessagesManager(getOwner(this));
constructor(args = {}) { constructor(channel, args = {}) {
this.title = args.title; this.title = args.title;
this.id = args.id; this.id = args.id;
this.channelId = args.channel_id; this.channel = channel;
this.status = args.status; this.status = args.status;
this.draft = args.draft;
this.originalMessageUser = this.#initUserModel(args.original_message_user); this.staged = args.staged;
this.originalMessage = args.original_message; this.originalMessage = ChatMessage.create(channel, args.original_message);
this.originalMessage.user = this.originalMessageUser;
} }
stageMessage(message) { stageMessage(message) {
@ -39,6 +45,10 @@ export default class ChatThread {
this.messagesManager.addMessages([message]); this.messagesManager.addMessages([message]);
} }
get routeModels() {
return [...this.channel.routeModels, this.id];
}
get messages() { get messages() {
return this.messagesManager.messages; return this.messagesManager.messages;
} }

View File

@ -5,24 +5,57 @@ export default class ChatChannelThread extends DiscourseRoute {
@service router; @service router;
@service chatStateManager; @service chatStateManager;
@service chat; @service chat;
@service chatStagedThreadMapping;
@service chatChannelThreadPane;
async model(params) { model(params, transition) {
const channel = this.modelFor("chat.channel"); const channel = this.modelFor("chat.channel");
return channel.threadsManager.find(channel.id, params.threadId);
return channel.threadsManager
.find(channel.id, params.threadId)
.catch(() => {
transition.abort();
this.router.transitionTo("chat.channel", ...channel.routeModels);
return;
});
} }
deactivate() { deactivate() {
this.#closeThread(); this.chatChannelThreadPane.close();
} }
#closeThread() { beforeModel(transition) {
this.chat.activeChannel.activeThread?.messagesManager?.clearMessages(); const channel = this.modelFor("chat.channel");
this.chat.activeChannel.activeThread = null;
this.chatStateManager.closeSidePanel(); if (!channel.threadingEnabled) {
transition.abort();
this.router.transitionTo("chat.channel", ...channel.routeModels);
return;
}
// This is a very special logic to attempt to reconciliate a staged thread id
// it happens after creating a new thread and having a temp ID in the URL
// if users presses reload at this moment, we would have a 404
// replacing the ID in the URL sooner would also cause a reload
const params = this.paramsFor("chat.channel.thread");
const threadId = params.threadId;
if (threadId?.startsWith("staged-thread-")) {
const mapping = this.chatStagedThreadMapping.getMapping();
if (mapping[threadId]) {
transition.abort();
this.router.transitionTo(
"chat.channel.thread",
...[...channel.routeModels, mapping[threadId]]
);
return;
}
}
} }
afterModel(model) { afterModel(model) {
this.chat.activeChannel.activeThread = model; this.chatChannelThreadPane.open(model);
this.chatStateManager.openSidePanel();
} }
} }

View File

@ -40,7 +40,11 @@ export default class ChatApi extends Service {
*/ */
thread(channelId, threadId) { thread(channelId, threadId) {
return this.#getRequest(`/channels/${channelId}/threads/${threadId}`).then( return this.#getRequest(`/channels/${channelId}/threads/${threadId}`).then(
(result) => this.chat.activeChannel.threadsManager.store(result.thread) (result) =>
this.chat.activeChannel.threadsManager.store(
this.chat.activeChannel,
result.thread
)
); );
} }

View File

@ -1,60 +1,41 @@
import { tracked } from "@glimmer/tracking"; import { inject as service } from "@ember/service";
import Service, { inject as service } from "@ember/service";
import { action } from "@ember/object"; import { action } from "@ember/object";
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; import ChatComposer from "./chat-composer";
import { next } from "@ember/runloop";
export default class ChatChannelComposer extends Service { export default class ChatChannelComposer extends ChatComposer {
@service chat; @service chat;
@service chatApi; @service chatChannelThreadComposer;
@service chatComposerPresenceManager; @service router;
@service currentUser;
@tracked _message;
@action
cancel() {
if (this.message.editing) {
this.reset();
} else if (this.message.inReplyTo) {
this.message.inReplyTo = null;
}
}
@action
reset(channel) {
this.message = ChatMessage.createDraftMessage(channel, {
user: this.currentUser,
});
}
@action
clear() {
this.message.message = "";
}
@action
editMessage(message) {
this.chat.activeMessage = null;
message.editing = true;
this.message = message;
}
@action
onCancelEditing() {
this.reset();
}
@action @action
replyTo(message) { replyTo(message) {
this.chat.activeMessage = null; this.chat.activeMessage = null;
this.message.inReplyTo = message; const channel = message.channel;
if (
this.siteSettings.enable_experimental_chat_threaded_discussions &&
channel.threadingEnabled
) {
let thread;
if (message.thread?.id) {
thread = message.thread;
} else {
thread = channel.createStagedThread(message);
message.thread = thread;
}
this.router
.transitionTo("chat.channel.thread", ...thread.routeModels)
.finally(() => this._setReplyToAfterTransition(message));
} else {
this.message.inReplyTo = message;
}
} }
get message() { _setReplyToAfterTransition(message) {
return this._message; next(() => {
} this.chatChannelThreadComposer.replyTo(message);
});
set message(message) {
this._message = message;
} }
} }

View File

@ -20,14 +20,6 @@ export default class ChatChannelPaneSubscriptionsManager extends ChatPaneBaseSub
return; return;
} }
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) { handleThreadOriginalMessageUpdate(data) {
const message = this.messagesManager.findMessage(data.original_message_id); const message = this.messagesManager.findMessage(data.original_message_id);
if (message) { if (message) {

View File

@ -55,4 +55,8 @@ export default class ChatChannelPane extends Service {
return lastCurrentUserMessage; return lastCurrentUserMessage;
} }
get lastMessage() {
return this.chat.activeChannel.messages.lastObject;
}
} }

View File

@ -1,13 +1,20 @@
import ChatChannelComposer from "./chat-channel-composer"; import ChatComposer from "./chat-composer";
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
import { action } from "@ember/object"; import { action } from "@ember/object";
export default class extends ChatChannelComposer { export default class ChatChannelThreadComposer extends ChatComposer {
@action @action
reset(channel) { reset(channel, thread) {
this.message = ChatMessage.createDraftMessage(channel, { this.message = ChatMessage.createDraftMessage(channel, {
user: this.currentUser, user: this.currentUser,
thread_id: channel.activeThread.id,
}); });
this.message.thread = thread;
}
@action
replyTo(message) {
this.chat.activeMessage = null;
this.message.thread = message.thread;
this.message.inReplyTo = message;
} }
} }

View File

@ -3,7 +3,7 @@ import ChatPaneBaseSubscriptionsManager from "./chat-pane-base-subscriptions-man
export default class ChatChannelThreadPaneSubscriptionsManager extends ChatPaneBaseSubscriptionsManager { export default class ChatChannelThreadPaneSubscriptionsManager extends ChatPaneBaseSubscriptionsManager {
get messageBusChannel() { get messageBusChannel() {
return `/chat/${this.model.channelId}/thread/${this.model.id}`; return `/chat/${this.model.channel.id}/thread/${this.model.id}`;
} }
get messageBusLastId() { get messageBusLastId() {
@ -12,7 +12,10 @@ export default class ChatChannelThreadPaneSubscriptionsManager extends ChatPaneB
handleSentMessage(data) { handleSentMessage(data) {
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.handleStagedMessageInternal(data); const stagedMessage = this.handleStagedMessageInternal(
this.model.channel,
data
);
if (stagedMessage) { if (stagedMessage) {
return; return;
} }
@ -23,15 +26,6 @@ export default class ChatChannelThreadPaneSubscriptionsManager extends ChatPaneB
data.chat_message data.chat_message
); );
this.messagesManager.addMessages([message]); this.messagesManager.addMessages([message]);
// TODO (martin) All the scrolling and new message indicator shenanigans,
// as well as handling marking the thread as read.
}
// 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 // NOTE: noop, there is nothing to do when a thread original message

View File

@ -3,6 +3,19 @@ import { inject as service } from "@ember/service";
export default class ChatChannelThreadPane extends ChatChannelPane { export default class ChatChannelThreadPane extends ChatChannelPane {
@service chatChannelThreadComposer; @service chatChannelThreadComposer;
@service chat;
@service chatStateManager;
close() {
this.chat.activeChannel.activeThread?.messagesManager?.clearMessages();
this.chat.activeChannel.activeThread = null;
this.chatStateManager.closeSidePanel();
}
open(thread) {
this.chat.activeChannel.activeThread = thread;
this.chatStateManager.openSidePanel();
}
get selectedMessageIds() { get selectedMessageIds() {
return this.chat.activeChannel.activeThread.selectedMessages.mapBy("id"); return this.chat.activeChannel.activeThread.selectedMessages.mapBy("id");

View File

@ -0,0 +1,52 @@
import { tracked } from "@glimmer/tracking";
import Service, { inject as service } from "@ember/service";
import { action } from "@ember/object";
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
export default class ChatComposer extends Service {
@service chat;
@service currentUser;
@tracked _message;
@action
cancel() {
if (this.message.editing) {
this.reset();
} else if (this.message.inReplyTo) {
this.message.inReplyTo = null;
}
}
@action
reset(channel) {
this.message = ChatMessage.createDraftMessage(channel, {
user: this.currentUser,
});
}
@action
clear() {
this.message.message = "";
}
@action
editMessage(message) {
this.chat.activeMessage = null;
message.editing = true;
this.message = message;
}
@action
onCancelEditing() {
this.reset();
}
get message() {
return this._message;
}
set message(message) {
this._message = message;
}
}

View File

@ -6,7 +6,7 @@ import { bind } from "discourse-common/utils/decorators";
// TODO (martin) This export can be removed once we move the handleSentMessage // TODO (martin) This export can be removed once we move the handleSentMessage
// code completely out of ChatLivePane // code completely out of ChatLivePane
export function handleStagedMessage(messagesManager, data) { export function handleStagedMessage(channel, messagesManager, data) {
const stagedMessage = messagesManager.findStagedMessage(data.staged_id); const stagedMessage = messagesManager.findStagedMessage(data.staged_id);
if (!stagedMessage) { if (!stagedMessage) {
@ -17,17 +17,8 @@ export function handleStagedMessage(messagesManager, data) {
stagedMessage.id = data.chat_message.id; stagedMessage.id = data.chat_message.id;
stagedMessage.staged = false; stagedMessage.staged = false;
stagedMessage.excerpt = data.chat_message.excerpt; stagedMessage.excerpt = data.chat_message.excerpt;
stagedMessage.threadId = data.chat_message.thread_id; stagedMessage.channel = channel;
stagedMessage.channelId = data.chat_message.chat_channel_id;
stagedMessage.createdAt = data.chat_message.created_at; 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;
}
stagedMessage.cooked = data.chat_message.cooked; stagedMessage.cooked = data.chat_message.cooked;
return stagedMessage; return stagedMessage;
@ -48,6 +39,7 @@ export function handleStagedMessage(messagesManager, data) {
export default class ChatPaneBaseSubscriptionsManager extends Service { export default class ChatPaneBaseSubscriptionsManager extends Service {
@service chat; @service chat;
@service currentUser; @service currentUser;
@service chatStagedThreadMapping;
get messageBusChannel() { get messageBusChannel() {
throw "not implemented"; throw "not implemented";
@ -75,14 +67,15 @@ export default class ChatPaneBaseSubscriptionsManager extends Service {
if (!this.model) { if (!this.model) {
return; return;
} }
this.messageBus.unsubscribe(this.messageBusChannel, this.onMessage); this.messageBus.unsubscribe(this.messageBusChannel, this.onMessage);
this.model = null; this.model = null;
} }
// TODO (martin) This can be removed once we move the handleSentMessage // TODO (martin) This can be removed once we move the handleSentMessage
// code completely out of ChatLivePane // code completely out of ChatLivePane
handleStagedMessageInternal(data) { handleStagedMessageInternal(channel, data) {
return handleStagedMessage(this.messagesManager, data); return handleStagedMessage(channel, this.messagesManager, data);
} }
@bind @bind
@ -122,7 +115,7 @@ export default class ChatPaneBaseSubscriptionsManager extends Service {
this.handleFlaggedMessage(busData); this.handleFlaggedMessage(busData);
break; break;
case "thread_created": case "thread_created":
this.handleThreadCreated(busData); this.handleNewThreadCreated(busData);
break; break;
case "update_thread_original_message": case "update_thread_original_message":
this.handleThreadOriginalMessageUpdate(busData); this.handleThreadOriginalMessageUpdate(busData);
@ -223,8 +216,34 @@ export default class ChatPaneBaseSubscriptionsManager extends Service {
} }
} }
handleThreadCreated() { handleNewThreadCreated(data) {
throw "not implemented"; this.model.threadsManager
.find(this.model.id, data.staged_thread_id, { fetchIfNotFound: false })
.then((stagedThread) => {
if (stagedThread) {
this.chatStagedThreadMapping.setMapping(
data.thread_id,
stagedThread.id
);
stagedThread.staged = false;
stagedThread.id = data.thread_id;
stagedThread.originalMessage.thread = stagedThread;
stagedThread.originalMessage.threadReplyCount ??= 1;
} else if (data.thread_id) {
this.model.threadsManager
.find(this.model.id, data.thread_id, { fetchIfNotFound: true })
.then((thread) => {
const channelOriginalMessage =
this.model.messagesManager.findMessage(
thread.originalMessage.id
);
if (channelOriginalMessage) {
channelOriginalMessage.thread = thread;
}
});
}
});
} }
handleThreadOriginalMessageUpdate() { handleThreadOriginalMessageUpdate() {

View File

@ -0,0 +1,34 @@
import KeyValueStore from "discourse/lib/key-value-store";
import Service from "@ember/service";
export default class ChatStagedThreadMapping extends Service {
STORE_NAMESPACE = "discourse_chat_";
KEY = "staged_thread";
store = new KeyValueStore(this.STORE_NAMESPACE);
constructor() {
super(...arguments);
if (!this.store.getObject(this.USER_EMOJIS_STORE_KEY)) {
this.storedFavorites = [];
}
}
getMapping() {
return JSON.parse(this.store.getObject(this.KEY) || "{}");
}
setMapping(id, stagedId) {
const mapping = {};
mapping[stagedId] = id;
this.store.setObject({
key: this.KEY,
value: JSON.stringify(mapping),
});
}
reset() {
this.store.setObject({ key: this.KEY, value: "{}" });
}
}

View File

@ -21,9 +21,10 @@ export function resetChatDrawerStateCallbacks() {
export default class ChatStateManager extends Service { export default class ChatStateManager extends Service {
@service chat; @service chat;
@service router; @service router;
isDrawerExpanded = false;
isDrawerActive = false; @tracked isSidePanelExpanded = false;
isSidePanelExpanded = false; @tracked isDrawerExpanded = false;
@tracked isDrawerActive = false;
@tracked _chatURL = null; @tracked _chatURL = null;
@tracked _appURL = null; @tracked _appURL = null;
@ -44,16 +45,16 @@ export default class ChatStateManager extends Service {
} }
openSidePanel() { openSidePanel() {
this.set("isSidePanelExpanded", true); this.isSidePanelExpanded = true;
} }
closeSidePanel() { closeSidePanel() {
this.set("isSidePanelExpanded", false); this.isSidePanelExpanded = false;
} }
didOpenDrawer(url = null) { didOpenDrawer(url = null) {
this.set("isDrawerActive", true); this.isDrawerActive = true;
this.set("isDrawerExpanded", true); this.isDrawerExpanded = true;
if (url) { if (url) {
this.storeChatURL(url); this.storeChatURL(url);
@ -64,27 +65,27 @@ export default class ChatStateManager extends Service {
} }
didCloseDrawer() { didCloseDrawer() {
this.set("isDrawerActive", false); this.isDrawerActive = false;
this.set("isDrawerExpanded", false); this.isDrawerExpanded = false;
this.chat.updatePresence(); this.chat.updatePresence();
this.#publishStateChange(); this.#publishStateChange();
} }
didExpandDrawer() { didExpandDrawer() {
this.set("isDrawerActive", true); this.isDrawerActive = true;
this.set("isDrawerExpanded", true); this.isDrawerExpanded = true;
this.chat.updatePresence(); this.chat.updatePresence();
} }
didCollapseDrawer() { didCollapseDrawer() {
this.set("isDrawerActive", true); this.isDrawerActive = true;
this.set("isDrawerExpanded", false); this.isDrawerExpanded = false;
this.#publishStateChange(); this.#publishStateChange();
} }
didToggleDrawer() { didToggleDrawer() {
this.set("isDrawerExpanded", !this.isDrawerExpanded); this.isDrawerExpanded = !this.isDrawerExpanded;
this.set("isDrawerActive", true); this.isDrawerActive = true;
this.#publishStateChange(); this.#publishStateChange();
} }

View File

@ -1,2 +1 @@
{{! ChatThreadList will go here later }} <ChatThread @thread={{this.model}} @includeHeader={{true}} />
<ChatThread @includeHeader={{true}} />

View File

@ -25,8 +25,7 @@
flex-grow: 1; flex-grow: 1;
overscroll-behavior: contain; overscroll-behavior: contain;
display: flex; display: flex;
flex-direction: column-reverse; flex-direction: column;
will-change: transform;
} }
.chat-composer__wrapper { .chat-composer__wrapper {

View File

@ -13,6 +13,7 @@ module Chat
chat_channel:, chat_channel:,
in_reply_to_id: nil, in_reply_to_id: nil,
thread_id: nil, thread_id: nil,
staged_thread_id: nil,
user:, user:,
content:, content:,
staged_id: nil, staged_id: nil,
@ -31,6 +32,7 @@ module Chat
@incoming_chat_webhook = incoming_chat_webhook @incoming_chat_webhook = incoming_chat_webhook
@upload_ids = upload_ids || [] @upload_ids = upload_ids || []
@thread_id = thread_id @thread_id = thread_id
@staged_thread_id = staged_thread_id
@error = nil @error = nil
@chat_message = @chat_message =
@ -57,7 +59,12 @@ module Chat
create_thread create_thread
@chat_message.attach_uploads(uploads) @chat_message.attach_uploads(uploads)
Chat::Draft.where(user_id: @user.id, chat_channel_id: @chat_channel.id).destroy_all Chat::Draft.where(user_id: @user.id, chat_channel_id: @chat_channel.id).destroy_all
Chat::Publisher.publish_new!(@chat_channel, @chat_message, @staged_id) Chat::Publisher.publish_new!(
@chat_channel,
@chat_message,
@staged_id,
staged_thread_id: @staged_thread_id,
)
resolved_thread&.increment_replies_count_cache resolved_thread&.increment_replies_count_cache
Jobs.enqueue(Jobs::Chat::ProcessMessage, { chat_message_id: @chat_message.id }) Jobs.enqueue(Jobs::Chat::ProcessMessage, { chat_message_id: @chat_message.id })
Chat::Notifier.notify_new(chat_message: @chat_message, timestamp: @chat_message.created_at) Chat::Notifier.notify_new(chat_message: @chat_message, timestamp: @chat_message.created_at)
@ -123,6 +130,8 @@ module Chat
end end
def validate_existing_thread! def validate_existing_thread!
return if @staged_thread_id.present? && @thread_id.blank?
return if @thread_id.blank? return if @thread_id.blank?
@existing_thread = Chat::Thread.find(@thread_id) @existing_thread = Chat::Thread.find(@thread_id)
@ -165,7 +174,7 @@ module Chat
def create_thread def create_thread
return if @in_reply_to_id.blank? return if @in_reply_to_id.blank?
return if @chat_message.in_thread? return if @chat_message.in_thread? && !@staged_thread_id.present?
if @original_message.thread if @original_message.thread
thread = @original_message.thread thread = @original_message.thread
@ -177,12 +186,15 @@ module Chat
channel: @chat_message.chat_channel, channel: @chat_message.chat_channel,
) )
@chat_message.in_reply_to.thread_id = thread.id @chat_message.in_reply_to.thread_id = thread.id
Chat::Publisher.publish_thread_created!(
@chat_message.chat_channel,
@chat_message.in_reply_to,
)
end end
Chat::Publisher.publish_thread_created!(
@chat_message.chat_channel,
@chat_message.in_reply_to,
thread.id,
@staged_thread_id,
)
@chat_message.thread_id = thread.id @chat_message.thread_id = thread.id
# NOTE: We intentionally do not try to correct thread IDs within the chain # NOTE: We intentionally do not try to correct thread IDs within the chain

View File

@ -451,6 +451,30 @@ describe Chat::MessageCreator do
expect(thread_created_message.channel).to eq("/chat/#{public_chat_channel.id}") expect(thread_created_message.channel).to eq("/chat/#{public_chat_channel.id}")
end end
context "when a staged_thread_id is provided" do
fab!(:existing_thread) { Fabricate(:chat_thread, channel: public_chat_channel) }
it "creates a thread and publishes with the staged id" do
messages =
MessageBus.track_publish do
described_class.create(
chat_channel: public_chat_channel,
user: user1,
content: "this is a message",
in_reply_to_id: reply_message.id,
staged_thread_id: "stagedthreadid",
).chat_message
end
thread_event = messages.find { |m| m.data["type"] == "thread_created" }
expect(thread_event.data["staged_thread_id"]).to eq("stagedthreadid")
expect(Chat::Thread.find(thread_event.data["thread_id"])).to be_persisted
send_event = messages.find { |m| m.data["type"] == "sent" }
expect(send_event.data["staged_thread_id"]).to eq("stagedthreadid")
end
end
context "when the thread_id is provided" do context "when the thread_id is provided" do
fab!(:existing_thread) { Fabricate(:chat_thread, channel: public_chat_channel) } fab!(:existing_thread) { Fabricate(:chat_thread, channel: public_chat_channel) }

View File

@ -385,6 +385,31 @@ RSpec.describe Chat::ChatController do
expect(messages.first.data["last_read_message_id"]).to eq(Chat::Message.last.id) expect(messages.first.data["last_read_message_id"]).to eq(Chat::Message.last.id)
end end
context "when sending a message in a staged thread" do
it "creates the thread and publishes with the staged id" do
sign_in(user)
messages =
MessageBus.track_publish do
post "/chat/#{chat_channel.id}.json",
params: {
message: message,
in_reply_to_id: message_1.id,
staged_thread_id: "stagedthreadid",
}
end
expect(response.status).to eq(200)
thread_event = messages.find { |m| m.data["type"] == "thread_created" }
expect(thread_event.data["staged_thread_id"]).to eq("stagedthreadid")
expect(Chat::Thread.find(thread_event.data["thread_id"])).to be_persisted
sent_event = messages.find { |m| m.data["type"] == "sent" }
expect(sent_event.data["staged_thread_id"]).to eq("stagedthreadid")
end
end
context "when sending a message in a thread" do context "when sending a message in a thread" do
fab!(:thread) do fab!(:thread) do
Fabricate(:chat_thread, channel: chat_channel, original_message: message_1) Fabricate(:chat_thread, channel: chat_channel, original_message: message_1)

View File

@ -111,6 +111,32 @@ describe Chat::Publisher do
end end
end end
context "when a staged thread has been provided" 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,
staged_thread_id: "stagedthreadid",
)
expect(targets).to contain_exactly(
"/chat/#{channel.id}/thread/#{thread.id}",
"/chat/#{channel.id}/thread/stagedthreadid",
)
end
end
context "when the message is a thread reply" do context "when the message is a thread reply" do
fab!(:thread) do fab!(:thread) do
Fabricate( Fabricate(

View File

@ -61,11 +61,12 @@ RSpec.describe "Archive channel", type: :system, js: true do
find("#split-topic-name").fill_in(with: "An interesting topic for cats") find("#split-topic-name").fill_in(with: "An interesting topic for cats")
click_button(I18n.t("js.chat.channel_archive.title")) click_button(I18n.t("js.chat.channel_archive.title"))
expect(page).to have_content(I18n.t("js.chat.channel_archive.process_started")) expect(page).to have_css(".chat-channel-archive-status", wait: 15)
expect(page).to have_css(".chat-channel-archive-status")
end end
it "shows an error when the topic is invalid" do it "shows an error when the topic is invalid" do
Jobs.run_immediately!
chat.visit_channel_settings(channel_1) chat.visit_channel_settings(channel_1)
click_button(I18n.t("js.chat.channel_settings.archive_channel")) click_button(I18n.t("js.chat.channel_settings.archive_channel"))
find("#split-topic-name").fill_in( find("#split-topic-name").fill_in(
@ -73,7 +74,7 @@ RSpec.describe "Archive channel", type: :system, js: true do
) )
click_button(I18n.t("js.chat.channel_archive.title")) click_button(I18n.t("js.chat.channel_archive.title"))
expect(page).not_to have_content(I18n.t("js.chat.channel_archive.process_started")) expect(page).to have_no_content(I18n.t("js.chat.channel_archive.process_started"))
expect(page).to have_content("Title can't have more than 1 emoji") expect(page).to have_content("Title can't have more than 1 emoji")
end end

View File

@ -79,7 +79,7 @@ RSpec.describe "Browse page", type: :system, js: true do
context "when results are found" do context "when results are found" do
it "lists expected results" do it "lists expected results" do
visit("/chat/browse") visit("/chat/browse")
find(".dc-filter-input").fill_in(with: category_channel_1.name) find(".chat-browse-view .dc-filter-input").fill_in(with: category_channel_1.name)
expect(browse_view).to have_content(category_channel_1.name) expect(browse_view).to have_content(category_channel_1.name)
expect(browse_view).to have_no_content(category_channel_2.name) expect(browse_view).to have_no_content(category_channel_2.name)
@ -89,14 +89,14 @@ RSpec.describe "Browse page", type: :system, js: true do
context "when results are not found" do context "when results are not found" do
it "displays the correct message" do it "displays the correct message" do
visit("/chat/browse") visit("/chat/browse")
find(".dc-filter-input").fill_in(with: "x") find(".chat-browse-view .dc-filter-input").fill_in(with: "x")
expect(browse_view).to have_content(I18n.t("js.chat.empty_state.title")) expect(browse_view).to have_content(I18n.t("js.chat.empty_state.title"))
end end
it "doesn’t display any channel" do it "doesn’t display any channel" do
visit("/chat/browse") visit("/chat/browse")
find(".dc-filter-input").fill_in(with: "x") find(".chat-browse-view .dc-filter-input").fill_in(with: "x")
expect(browse_view).to have_no_content(category_channel_1.name) expect(browse_view).to have_no_content(category_channel_1.name)
expect(browse_view).to have_no_content(category_channel_2.name) expect(browse_view).to have_no_content(category_channel_2.name)

View File

@ -69,8 +69,8 @@ describe "Channel thread message echoing", type: :system, js: true do
chat_page.visit_channel(channel) chat_page.visit_channel(channel)
channel_page.message_thread_indicator(thread.original_message).click channel_page.message_thread_indicator(thread.original_message).click
expect(side_panel).to have_open_thread(thread) expect(side_panel).to have_open_thread(thread)
open_thread.send_message(thread.id, "new thread message") open_thread.send_message("new thread message")
expect(open_thread).to have_message(thread.id, text: "new thread message") expect(open_thread).to have_message(thread_id: thread.id, text: "new thread message")
new_message = thread.reload.replies.last new_message = thread.reload.replies.last
expect(channel_page).not_to have_css(channel_page.message_by_id_selector(new_message.id)) expect(channel_page).not_to have_css(channel_page.message_by_id_selector(new_message.id))
end end

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
RSpec.describe "Chat message", type: :system, js: true do RSpec.describe "Chat message - channel", type: :system, js: true do
fab!(:current_user) { Fabricate(:user) } fab!(:current_user) { Fabricate(:user) }
fab!(:channel_1) { Fabricate(:chat_channel) } fab!(:channel_1) { Fabricate(:chat_channel) }
fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel_1) } fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel_1) }

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
RSpec.describe "Chat message - channel", type: :system, js: true do RSpec.describe "Chat message - thread", type: :system, js: true do
fab!(:current_user) { Fabricate(:user) } fab!(:current_user) { Fabricate(:user) }
fab!(:other_user) { Fabricate(:user) } fab!(:other_user) { Fabricate(:user) }
fab!(:channel_1) { Fabricate(:chat_channel) } fab!(:channel_1) { Fabricate(:chat_channel) }
@ -25,13 +25,13 @@ RSpec.describe "Chat message - channel", type: :system, js: true do
context "when hovering a message" do context "when hovering a message" do
it "adds an active class" do it "adds an active class" do
last_message = thread_1.chat_messages.last first_message = thread_1.chat_messages.first
chat.visit_thread(thread_1) chat.visit_thread(thread_1)
thread.hover_message(last_message) thread.hover_message(first_message)
expect(page).to have_css( expect(page).to have_css(
".chat-thread[data-id='#{thread_1.id}'] [data-id='#{last_message.id}'] .chat-message.is-active", ".chat-thread[data-id='#{thread_1.id}'] [data-id='#{first_message.id}'] .chat-message.is-active",
) )
end end
end end

View File

@ -46,6 +46,7 @@ RSpec.describe "Deleted message", type: :system, js: true do
channel_1.update!(threading_enabled: true) channel_1.update!(threading_enabled: true)
SiteSetting.enable_experimental_chat_threaded_discussions = true SiteSetting.enable_experimental_chat_threaded_discussions = true
chat_system_user_bootstrap(user: other_user, channel: channel_1) chat_system_user_bootstrap(user: other_user, channel: channel_1)
Chat::Thread.update_counts
end end
it "hides the deleted messages" do it "hides the deleted messages" do
@ -55,8 +56,8 @@ RSpec.describe "Deleted message", type: :system, js: true do
expect(channel_page).to have_message(id: message_1.id) expect(channel_page).to have_message(id: message_1.id)
expect(channel_page).to have_message(id: message_2.id) expect(channel_page).to have_message(id: message_2.id)
expect(open_thread).to have_message(thread.id, id: message_4.id) expect(open_thread).to have_message(thread_id: thread.id, id: message_4.id)
expect(open_thread).to have_message(thread.id, id: message_5.id) expect(open_thread).to have_message(thread_id: thread.id, id: message_5.id)
Chat::Publisher.publish_bulk_delete!( Chat::Publisher.publish_bulk_delete!(
channel_1, channel_1,
@ -65,8 +66,8 @@ RSpec.describe "Deleted message", type: :system, js: true do
expect(channel_page).to have_no_message(id: message_1.id) expect(channel_page).to have_no_message(id: message_1.id)
expect(channel_page).to have_no_message(id: message_2.id) expect(channel_page).to have_no_message(id: message_2.id)
expect(open_thread).to have_no_message(thread.id, id: message_4.id) expect(open_thread).to have_no_message(thread_id: thread.id, id: message_4.id)
expect(open_thread).to have_no_message(thread.id, id: message_5.id) expect(open_thread).to have_no_message(thread_id: thread.id, id: message_5.id)
end end
end end
end end

View File

@ -86,8 +86,8 @@ describe "Thread indicator for chat messages", type: :system, js: true do
message_without_thread = Fabricate(:chat_message, chat_channel: channel, user: other_user) message_without_thread = Fabricate(:chat_message, chat_channel: channel, user: other_user)
chat_page.visit_channel(channel) chat_page.visit_channel(channel)
channel_page.reply_to(message_without_thread) channel_page.reply_to(message_without_thread)
channel_page.fill_composer("this is a reply to make a new thread") open_thread.fill_composer("this is a reply to make a new thread")
channel_page.click_send_message open_thread.click_send_message
expect(channel_page).to have_thread_indicator(message_without_thread) expect(channel_page).to have_thread_indicator(message_without_thread)
@ -108,7 +108,7 @@ describe "Thread indicator for chat messages", type: :system, js: true do
) )
channel_page.message_thread_indicator(thread_1.original_message).click channel_page.message_thread_indicator(thread_1.original_message).click
expect(side_panel).to have_open_thread(thread_1) expect(side_panel).to have_open_thread(thread_1)
open_thread.send_message(thread_1.id, "new thread message") open_thread.send_message("new thread message")
expect(channel_page.message_thread_indicator(thread_1.original_message)).to have_css( expect(channel_page.message_thread_indicator(thread_1.original_message)).to have_css(
".chat-message-thread-indicator__replies-count", ".chat-message-thread-indicator__replies-count",
text: I18n.t("js.chat.thread.replies", count: 4), text: I18n.t("js.chat.thread.replies", count: 4),

View File

@ -25,6 +25,7 @@ module PageObjects
def visit_thread(thread) def visit_thread(thread)
visit(thread.url) visit(thread.url)
has_no_css?(".chat-skeleton")
end end
def visit_channel_settings(channel) def visit_channel_settings(channel)

View File

@ -3,6 +3,10 @@
module PageObjects module PageObjects
module Pages module Pages
class ChatChannel < PageObjects::Pages::Base class ChatChannel < PageObjects::Pages::Base
def replying_to?(message)
find(".chat-channel .chat-reply", text: message.message)
end
def type_in_composer(input) def type_in_composer(input)
find(".chat-channel .chat-composer__input").click # makes helper more reliable by ensuring focus is not lost find(".chat-channel .chat-composer__input").click # makes helper more reliable by ensuring focus is not lost
find(".chat-channel .chat-composer__input").send_keys(input) find(".chat-channel .chat-composer__input").send_keys(input)
@ -49,7 +53,7 @@ module PageObjects
def click_message_action_mobile(message, message_action) def click_message_action_mobile(message, message_action)
expand_message_actions_mobile(message, delay: 0.5) expand_message_actions_mobile(message, delay: 0.5)
wait_for_animation(find(".chat-message-actions"), timeout: 5) wait_for_animation(find(".chat-message-actions"), timeout: 5)
find(".chat-message-action-item[data-id=\"#{message_action}\"] button").click find(".chat-message-actions [data-id=\"#{message_action}\"]").click
end end
def hover_message(message) def hover_message(message)
@ -114,8 +118,12 @@ module PageObjects
end end
def reply_to(message) def reply_to(message)
hover_message(message) if page.has_css?("html.mobile-view", wait: 0)
find(".reply-btn").click click_message_action_mobile(message, "reply")
else
hover_message(message)
find(".reply-btn").click
end
end end
def has_bookmarked_message?(message) def has_bookmarked_message?(message)
@ -167,18 +175,22 @@ module PageObjects
def check_message_presence(exists: true, text: nil, id: nil) def check_message_presence(exists: true, text: nil, id: nil)
css_method = exists ? :has_css? : :has_no_css? css_method = exists ? :has_css? : :has_no_css?
if text if text
send(css_method, ".chat-message-text", text: text, wait: 5) find(".chat-channel").send(css_method, ".chat-message-text", text: text, wait: 5)
elsif id elsif id
send(css_method, ".chat-message-container[data-id=\"#{id}\"]", wait: 10) find(".chat-channel").send(
css_method,
".chat-message-container[data-id=\"#{id}\"]",
wait: 10,
)
end end
end end
def has_thread_indicator?(message) def has_thread_indicator?(message, text: nil)
has_css?(message_thread_indicator_selector(message)) has_css?(message_thread_indicator_selector(message), text: text)
end end
def has_no_thread_indicator?(message) def has_no_thread_indicator?(message, text: nil)
has_no_css?(message_thread_indicator_selector(message)) has_no_css?(message_thread_indicator_selector(message), text: text)
end end
def message_thread_indicator(message) def message_thread_indicator(message)

View File

@ -3,8 +3,12 @@
module PageObjects module PageObjects
module Pages module Pages
class ChatSidePanel < PageObjects::Pages::Base class ChatSidePanel < PageObjects::Pages::Base
def has_open_thread?(thread) def has_open_thread?(thread = nil)
has_css?(".chat-side-panel .chat-thread[data-id='#{thread.id}']") if thread
has_css?(".chat-side-panel .chat-thread[data-id='#{thread.id}']")
else
has_css?(".chat-side-panel .chat-thread")
end
end end
def has_no_open_thread? def has_no_open_thread?

View File

@ -19,10 +19,6 @@ module PageObjects
header.has_content?(content) header.has_content?(content)
end end
def thread_selector_by_id(id)
".chat-thread[data-id=\"#{id}\"]"
end
def has_no_loading_skeleton? def has_no_loading_skeleton?
has_no_css?(".chat-thread__messages .chat-skeleton") has_no_css?(".chat-thread__messages .chat-skeleton")
end end
@ -41,42 +37,32 @@ module PageObjects
find(".chat-thread .chat-composer__input").click # ensures autocomplete is closed and not masking anything find(".chat-thread .chat-composer__input").click # ensures autocomplete is closed and not masking anything
end end
def send_message(id, text = nil) def send_message(text = nil)
text = text.chomp if text.present? # having \n on the end of the string counts as an Enter keypress text = text.chomp if text.present? # having \n on the end of the string counts as an Enter keypress
fill_composer(text) fill_composer(text)
click_send_message(id) click_send_message
click_composer click_composer
end end
def click_send_message(id) def click_send_message
find(thread_selector_by_id(id)).find( find(".chat-thread .chat-composer.is-send-enabled .chat-composer__send-btn").click
".chat-composer.is-send-enabled .chat-composer__send-btn",
).click
end end
def has_message?(thread_id, text: nil, id: nil) def has_message?(text: nil, id: nil, thread_id: nil)
check_message_presence(thread_id, exists: true, text: text, id: id) check_message_presence(exists: true, text: text, id: id, thread_id: thread_id)
end end
def has_no_message?(thread_id, text: nil, id: nil) def has_no_message?(text: nil, id: nil, thread_id: nil)
check_message_presence(thread_id, exists: false, text: text, id: id) check_message_presence(exists: false, text: text, id: id, thread_id: thread_id)
end end
def check_message_presence(thread_id, exists: true, text: nil, id: nil) def check_message_presence(exists: true, text: nil, id: nil, thread_id: nil)
css_method = exists ? :has_css? : :has_no_css? css_method = exists ? :has_css? : :has_no_css?
selector = thread_id ? ".chat-thread[data-id=\"#{thread_id}\"]" : ".chat-thread"
if text if text
find(thread_selector_by_id(thread_id)).send( find(selector).send(css_method, ".chat-message-text", text: text, wait: 5)
css_method,
".chat-message-text",
text: text,
wait: 5,
)
elsif id elsif id
find(thread_selector_by_id(thread_id)).send( find(selector).send(css_method, ".chat-message-container[data-id=\"#{id}\"]", wait: 10)
css_method,
".chat-message-container[data-id=\"#{id}\"]",
wait: 10,
)
end end
end end

View File

@ -31,8 +31,12 @@ module PageObjects
find("#{VISIBLE_DRAWER} .chat-drawer-header__full-screen-btn").click find("#{VISIBLE_DRAWER} .chat-drawer-header__full-screen-btn").click
end end
def has_open_thread?(thread) def has_open_thread?(thread = nil)
has_css?("#{VISIBLE_DRAWER} .chat-thread[data-id='#{thread.id}']") if thread
has_css?("#{VISIBLE_DRAWER} .chat-thread[data-id='#{thread.id}']")
else
has_css?("#{VISIBLE_DRAWER} .chat-thread")
end
end end
def has_open_channel?(channel) def has_open_channel?(channel)

View File

@ -0,0 +1,99 @@
# frozen_string_literal: true
RSpec.describe "Reply to message - channel - drawer", type: :system, js: true do
let(:chat_page) { PageObjects::Pages::Chat.new }
let(:channel_page) { PageObjects::Pages::ChatChannel.new }
let(:thread_page) { PageObjects::Pages::ChatThread.new }
let(:drawer_page) { PageObjects::Pages::ChatDrawer.new }
fab!(:current_user) { Fabricate(:user) }
fab!(:channel_1) { Fabricate(:category_channel) }
fab!(:original_message) do
Fabricate(:chat_message, chat_channel: channel_1, user: Fabricate(:user))
end
before do
SiteSetting.enable_experimental_chat_threaded_discussions = true
chat_system_bootstrap
channel_1.update!(threading_enabled: true)
channel_1.add(current_user)
sign_in(current_user)
end
context "when the message has not current thread" do
it "starts a thread" do
visit("/")
chat_page.open_from_header
drawer_page.open_channel(channel_1)
channel_page.reply_to(original_message)
expect(drawer_page).to have_open_thread
thread_page.fill_composer("reply to message")
thread_page.click_send_message
expect(thread_page).to have_message(text: "reply to message")
drawer_page.back
expect(channel_page).to have_thread_indicator(original_message)
end
end
context "when the message has an existing thread" do
fab!(:message_1) do
creator =
Chat::MessageCreator.new(
chat_channel: channel_1,
in_reply_to_id: original_message.id,
user: Fabricate(:user),
content: Faker::Lorem.paragraph,
)
creator.create
creator.chat_message
end
it "replies to the existing thread" do
visit("/")
chat_page.open_from_header
drawer_page.open_channel(channel_1)
expect(channel_page).to have_thread_indicator(original_message, text: "1")
channel_page.reply_to(original_message)
expect(drawer_page).to have_open_thread
thread_page.fill_composer("reply to message")
thread_page.click_send_message
expect(thread_page).to have_message(text: message_1.message)
expect(thread_page).to have_message(text: "reply to message")
drawer_page.back
expect(channel_page).to have_thread_indicator(original_message, text: "2")
expect(channel_page).to have_no_message(text: "reply to message")
end
end
context "with threading disabled" do
before { channel_1.update!(threading_enabled: false) }
it "makes a reply in the channel" do
visit("/")
chat_page.open_from_header
drawer_page.open_channel(channel_1)
channel_page.reply_to(original_message)
expect(page).to have_selector(
".chat-channel .chat-reply__excerpt",
text: original_message.message,
)
channel_page.fill_composer("reply to message")
channel_page.click_send_message
expect(channel_page).to have_message(text: "reply to message")
end
end
end

View File

@ -0,0 +1,103 @@
# frozen_string_literal: true
RSpec.describe "Reply to message - channel - full page", type: :system, js: true do
let(:chat_page) { PageObjects::Pages::Chat.new }
let(:channel_page) { PageObjects::Pages::ChatChannel.new }
let(:thread_page) { PageObjects::Pages::ChatThread.new }
let(:side_panel_page) { PageObjects::Pages::ChatSidePanel.new }
fab!(:current_user) { Fabricate(:user) }
fab!(:channel_1) { Fabricate(:category_channel) }
fab!(:original_message) do
Fabricate(:chat_message, chat_channel: channel_1, user: Fabricate(:user))
end
before do
SiteSetting.enable_experimental_chat_threaded_discussions = true
chat_system_bootstrap
channel_1.add(current_user)
sign_in(current_user)
channel_1.update!(threading_enabled: true)
end
context "when the message has not current thread" do
it "starts a thread" do
chat_page.visit_channel(channel_1)
channel_page.reply_to(original_message)
expect(side_panel_page).to have_open_thread
thread_page.fill_composer("reply to message")
thread_page.click_send_message
expect(thread_page).to have_message(text: "reply to message")
expect(channel_page).to have_thread_indicator(original_message)
end
context "when reloading after creating thread" do
it "correctly loads the thread" do
chat_page.visit_channel(channel_1)
channel_page.reply_to(original_message)
thread_page.fill_composer("reply to message")
thread_page.click_send_message
expect(thread_page).to have_message(text: "reply to message")
refresh
expect(thread_page).to have_message(text: "reply to message")
end
end
end
context "when the message has an existing thread" do
fab!(:message_1) do
creator =
Chat::MessageCreator.new(
chat_channel: channel_1,
in_reply_to_id: original_message.id,
user: Fabricate(:user),
content: Faker::Lorem.paragraph,
)
creator.create
creator.chat_message
end
it "replies to the existing thread" do
chat_page.visit_channel(channel_1)
expect(channel_page).to have_thread_indicator(original_message, text: "1")
channel_page.reply_to(original_message)
expect(side_panel_page).to have_open_thread
thread_page.fill_composer("reply to message")
thread_page.click_send_message
expect(thread_page).to have_message(text: message_1.message)
expect(thread_page).to have_message(text: "reply to message")
expect(channel_page).to have_thread_indicator(original_message, text: "2")
expect(channel_page).to have_no_message(text: "reply to message")
end
end
context "with threading disabled" do
before { channel_1.update!(threading_enabled: false) }
it "makes a reply in the channel" do
chat_page.visit_channel(channel_1)
channel_page.reply_to(original_message)
expect(page).to have_selector(
".chat-channel .chat-reply__excerpt",
text: original_message.message,
)
channel_page.fill_composer("reply to message")
channel_page.click_send_message
expect(channel_page).to have_message(text: "reply to message")
end
end
end

View File

@ -0,0 +1,109 @@
# frozen_string_literal: true
RSpec.describe "Reply to message - channel - mobile", type: :system, js: true, mobile: true do
let(:chat_page) { PageObjects::Pages::Chat.new }
let(:channel_page) { PageObjects::Pages::ChatChannel.new }
let(:thread_page) { PageObjects::Pages::ChatThread.new }
let(:side_panel_page) { PageObjects::Pages::ChatSidePanel.new }
fab!(:current_user) { Fabricate(:user) }
fab!(:channel_1) { Fabricate(:category_channel) }
fab!(:original_message) do
Fabricate(:chat_message, chat_channel: channel_1, user: Fabricate(:user))
end
before do
SiteSetting.enable_experimental_chat_threaded_discussions = true
chat_system_bootstrap
channel_1.update!(threading_enabled: true)
channel_1.add(current_user)
sign_in(current_user)
end
context "when the message has not current thread" do
it "starts a thread" do
chat_page.visit_channel(channel_1)
channel_page.reply_to(original_message)
expect(side_panel_page).to have_open_thread
thread_page.fill_composer("reply to message")
thread_page.click_send_message
expect(thread_page).to have_message(text: "reply to message")
thread_page.close
expect(channel_page).to have_thread_indicator(original_message)
end
context "when reloading after creating thread" do
it "correctly loads the thread" do
chat_page.visit_channel(channel_1)
channel_page.reply_to(original_message)
thread_page.fill_composer("reply to message")
thread_page.click_send_message
expect(thread_page).to have_message(text: "reply to message")
refresh
expect(thread_page).to have_message(text: "reply to message")
end
end
end
context "when the message has an existing thread" do
fab!(:message_1) do
creator =
Chat::MessageCreator.new(
chat_channel: channel_1,
in_reply_to_id: original_message.id,
user: Fabricate(:user),
content: Faker::Lorem.paragraph,
)
creator.create
creator.chat_message
end
it "replies to the existing thread" do
chat_page.visit_channel(channel_1)
expect(channel_page).to have_thread_indicator(original_message, text: "1")
channel_page.reply_to(original_message)
expect(side_panel_page).to have_open_thread
thread_page.fill_composer("reply to message")
thread_page.click_send_message
expect(thread_page).to have_message(text: message_1.message)
expect(thread_page).to have_message(text: "reply to message")
thread_page.close
expect(channel_page).to have_thread_indicator(original_message, text: "2")
expect(channel_page).to have_no_message(text: "reply to message")
end
end
context "with threading disabled" do
before { channel_1.update!(threading_enabled: false) }
it "makes a reply in the channel" do
chat_page.visit_channel(channel_1)
channel_page.reply_to(original_message)
expect(page).to have_selector(
".chat-channel .chat-reply__excerpt",
text: original_message.message,
)
channel_page.fill_composer("reply to message")
channel_page.click_send_message
expect(channel_page).to have_message(text: "reply to message")
end
end
end

View File

@ -0,0 +1,77 @@
# frozen_string_literal: true
RSpec.describe "Reply to message - smoke", type: :system, js: true do
let(:chat_page) { PageObjects::Pages::Chat.new }
let(:channel_page) { PageObjects::Pages::ChatChannel.new }
let(:thread_page) { PageObjects::Pages::ChatThread.new }
fab!(:user_1) { Fabricate(:user) }
fab!(:user_2) { Fabricate(:user) }
fab!(:channel_1) { Fabricate(:category_channel) }
fab!(:original_message) { Fabricate(:chat_message, chat_channel: channel_1) }
before do
SiteSetting.enable_experimental_chat_threaded_discussions = true
chat_system_bootstrap
channel_1.add(user_1)
channel_1.add(user_2)
channel_1.update!(threading_enabled: true)
end
context "when two users create a thread on the same message" do
it "works" do
using_session(:user_1) do
sign_in(user_1)
chat_page.visit_channel(channel_1)
channel_page.reply_to(original_message)
end
using_session(:user_2) do
sign_in(user_2)
chat_page.visit_channel(channel_1)
channel_page.reply_to(original_message)
end
using_session(:user_1) do
thread_page.fill_composer("user1reply")
thread_page.click_send_message
expect(channel_page).to have_thread_indicator(original_message, text: 1)
expect(thread_page).to have_message(text: "user1reply")
end
using_session(:user_2) do |session|
expect(thread_page).to have_message(text: "user1reply")
expect(channel_page).to have_thread_indicator(original_message, text: 1)
thread_page.fill_composer("user2reply")
thread_page.click_send_message
expect(thread_page).to have_message(text: "user2reply")
expect(channel_page).to have_thread_indicator(original_message, text: 2)
refresh
expect(thread_page).to have_message(text: "user1reply")
expect(thread_page).to have_message(text: "user2reply")
expect(channel_page).to have_thread_indicator(original_message, text: 2)
session.quit
end
using_session(:user_1) do |session|
expect(thread_page).to have_message(text: "user2reply")
expect(channel_page).to have_thread_indicator(original_message, text: 2)
refresh
expect(thread_page).to have_message(text: "user1reply")
expect(thread_page).to have_message(text: "user2reply")
expect(channel_page).to have_thread_indicator(original_message, text: 2)
session.quit
end
end
end
end

View File

@ -92,11 +92,10 @@ RSpec.describe "Shortcuts | chat composer", type: :system, js: true do
fab!(:message_1) do fab!(:message_1) do
Fabricate(:chat_message, message: "message 1", chat_channel: channel_1, user: current_user) Fabricate(:chat_message, message: "message 1", chat_channel: channel_1, user: current_user)
end end
before { Fabricate(:chat_message, message: "message 2", chat_channel: channel_1) } fab!(:message_2) { Fabricate(:chat_message, message: "message 2", chat_channel: channel_1) }
it "edits last editable message" do it "edits last editable message" do
chat.visit_channel(channel_1) chat.visit_channel(channel_1)
expect(channel_page).to have_message(id: message_1.id)
find(".chat-composer__input").send_keys(:arrow_up) find(".chat-composer__input").send_keys(:arrow_up)
@ -116,5 +115,15 @@ RSpec.describe "Shortcuts | chat composer", type: :system, js: true do
page.driver.browser.network_conditions = { offline: false } page.driver.browser.network_conditions = { offline: false }
end end
end end
context "with shift" do
it "starts replying to the last message" do
chat.visit_channel(channel_1)
find(".chat-composer__input").send_keys(%i[shift arrow_up])
expect(channel_page).to be_replying_to(message_2)
end
end
end end
end end

View File

@ -123,8 +123,8 @@ describe "Single thread in side panel", type: :system, js: true do
chat_page.visit_channel(channel) chat_page.visit_channel(channel)
channel_page.message_thread_indicator(thread.original_message).click channel_page.message_thread_indicator(thread.original_message).click
expect(side_panel).to have_open_thread(thread) expect(side_panel).to have_open_thread(thread)
thread_page.send_message(thread.id, "new thread message") thread_page.send_message("new thread message")
expect(thread_page).to have_message(thread.id, text: "new thread message") expect(thread_page).to have_message(thread_id: thread.id, text: "new thread message")
thread_message = thread.replies.last thread_message = thread.replies.last
expect(thread_message.chat_channel_id).to eq(channel.id) expect(thread_message.chat_channel_id).to eq(channel.id)
expect(thread_message.thread.channel_id).to eq(channel.id) expect(thread_message.thread.channel_id).to eq(channel.id)
@ -134,8 +134,8 @@ describe "Single thread in side panel", type: :system, js: true do
chat_page.visit_channel(channel) chat_page.visit_channel(channel)
channel_page.message_thread_indicator(thread.original_message).click channel_page.message_thread_indicator(thread.original_message).click
expect(side_panel).to have_open_thread(thread) expect(side_panel).to have_open_thread(thread)
thread_page.send_message(thread.id, "new thread message") thread_page.send_message("new thread message")
expect(thread_page).to have_message(thread.id, text: "new thread message") expect(thread_page).to have_message(thread_id: thread.id, text: "new thread message")
thread_message = thread.reload.replies.last thread_message = thread.reload.replies.last
expect(channel_page).not_to have_css(channel_page.message_by_id_selector(thread_message.id)) expect(channel_page).not_to have_css(channel_page.message_by_id_selector(thread_message.id))
end end
@ -157,19 +157,19 @@ describe "Single thread in side panel", type: :system, js: true do
using_session(:tab_2) do using_session(:tab_2) do
expect(side_panel).to have_open_thread(thread) expect(side_panel).to have_open_thread(thread)
thread_page.send_message(thread.id, "the other user message") thread_page.send_message("the other user message")
expect(thread_page).to have_message(thread.id, text: "the other user message") expect(thread_page).to have_message(thread_id: thread.id, text: "the other user message")
end end
using_session(:tab_1) do using_session(:tab_1) do
expect(side_panel).to have_open_thread(thread) expect(side_panel).to have_open_thread(thread)
expect(thread_page).to have_message(thread.id, text: "the other user message") expect(thread_page).to have_message(thread_id: thread.id, text: "the other user message")
thread_page.send_message(thread.id, "this is a test message") thread_page.send_message("this is a test message")
expect(thread_page).to have_message(thread.id, text: "this is a test message") expect(thread_page).to have_message(thread_id: thread.id, text: "this is a test message")
end end
using_session(:tab_2) do using_session(:tab_2) do
expect(thread_page).to have_message(thread.id, text: "this is a test message") expect(thread_page).to have_message(thread_id: thread.id, text: "this is a test message")
end end
end end