REFACTOR: composer/thread (#21910)

This commit contains multiple changes to improve the composer behavior especially in the context of a thread:

- Generally rename anything of the form `chatChannelThread...` to `chatThread...``
- Moves the textarea interactor instance inside the composer server
- Improves the focus state and closing of panel related to the use of the Escape shortcut
- Creates `Chat::ThreadList` as a component instead of having `Chat::Thread::ListItem` and others which could imply they were children of a the `Chat::Thread` component
This commit is contained in:
Joffrey JAFFEUX 2023-06-07 21:49:15 +02:00 committed by GitHub
parent 5fc1586abf
commit e6c6c342d9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 730 additions and 508 deletions

View File

@ -2,7 +2,7 @@
class={{concat-class
"chat-channel"
(if this.loading "loading")
(if this.chatChannelPane.sending "chat-channel--sending")
(if this.pane.sending "chat-channel--sending")
(unless this.loadedOnce "chat-channel--not-loaded-once")
}}
{{did-insert this.setUploadDropZone}}
@ -67,12 +67,12 @@
@channel={{@channel}}
/>
{{#if this.chatChannelPane.selectingMessages}}
{{#if this.pane.selectingMessages}}
<ChatSelectionManager
@selectedMessageIds={{this.chatChannelPane.selectedMessageIds}}
@selectedMessageIds={{this.pane.selectedMessageIds}}
@chatChannel={{@channel}}
@cancelSelecting={{action
this.chatChannelPane.cancelSelecting
this.pane.cancelSelecting
@channel.selectedMessages
}}
@context="channel"

View File

@ -36,8 +36,8 @@ export default class ChatLivePane extends Component {
@service chatEmojiPickerManager;
@service chatComposerPresenceManager;
@service chatStateManager;
@service chatChannelComposer;
@service chatChannelPane;
@service("chat-channel-composer") composer;
@service("chat-channel-pane") pane;
@service chatChannelPaneSubscriptionsManager;
@service chatApi;
@service currentUser;
@ -121,7 +121,7 @@ export default class ChatLivePane extends Component {
if (this._loadedChannelId !== this.args.channel.id) {
this.unsubscribeToUpdates(this._loadedChannelId);
this.chatChannelPane.selectingMessages = false;
this.pane.selectingMessages = false;
this._loadedChannelId = this.args.channel.id;
}
@ -129,9 +129,9 @@ export default class ChatLivePane extends Component {
channelId: this.args.channel.id,
});
if (existingDraft) {
this.chatChannelComposer.message = existingDraft;
this.composer.message = existingDraft;
} else {
this.resetComposer();
this.resetComposerMessage();
}
this.loadMessages();
@ -358,7 +358,6 @@ export default class ChatLivePane extends Component {
}
const firstMessage = this.args.channel?.messages?.firstObject;
if (!firstMessage?.visible) {
return;
}
@ -656,20 +655,20 @@ export default class ChatLivePane extends Component {
}
@action
resetComposer() {
this.chatChannelComposer.reset(this.args.channel);
resetComposerMessage() {
this.composer.reset(this.args.channel);
}
async #sendEditMessage(message) {
await message.cook();
this.chatChannelPane.sending = true;
this.pane.sending = true;
const data = {
new_message: message.message,
upload_ids: message.uploads.map((upload) => upload.id),
};
this.resetComposer();
this.resetComposerMessage();
try {
return await this.chatApi.editMessage(
@ -681,12 +680,12 @@ export default class ChatLivePane extends Component {
popupAjaxError(e);
} finally {
this.chatDraftsManager.remove({ channelId: this.args.channel.id });
this.chatChannelPane.sending = false;
this.pane.sending = false;
}
}
async #sendNewMessage(message) {
this.chatChannelPane.sending = true;
this.pane.sending = true;
resetIdle();
@ -704,21 +703,21 @@ export default class ChatLivePane extends Component {
upload_ids: message.uploads.map((upload) => upload.id),
};
this.resetComposer();
this.resetComposerMessage();
return this._upsertChannelWithMessage(this.args.channel, data).finally(
() => {
if (this._selfDeleted) {
return;
}
this.chatChannelPane.sending = false;
this.pane.sending = false;
this.scrollToLatestMessage();
}
);
}
await this.args.channel.stageMessage(message);
this.resetComposer();
this.resetComposerMessage();
if (!this.args.channel.canLoadMoreFuture) {
this.scrollToLatestMessage();
@ -739,7 +738,7 @@ export default class ChatLivePane extends Component {
} finally {
if (!this._selfDeleted) {
this.chatDraftsManager.remove({ channelId: this.args.channel.id });
this.chatChannelPane.sending = false;
this.pane.sending = false;
}
}
}
@ -758,7 +757,7 @@ export default class ChatLivePane extends Component {
type: "POST",
data,
}).then(() => {
this.chatChannelPane.sending = false;
this.pane.sending = false;
this.router.transitionTo("chat.channel", "-", c.id);
})
);
@ -778,12 +777,12 @@ export default class ChatLivePane extends Component {
}
}
this.resetComposer();
this.resetComposerMessage();
}
@action
resendStagedMessage(stagedMessage) {
this.chatChannelPane.sending = true;
this.pane.sending = true;
stagedMessage.error = null;
@ -806,7 +805,7 @@ export default class ChatLivePane extends Component {
if (this._selfDeleted) {
return;
}
this.chatChannelPane.sending = false;
this.pane.sending = false;
});
}
@ -931,9 +930,9 @@ export default class ChatLivePane extends Component {
return;
}
const composer = document.querySelector(".chat-composer__input");
if (composer && !this.args.channel.isDraft) {
composer.focus();
if (!this.args.channel.isDraft) {
event.preventDefault();
this.composer.focus({ addText: event.key });
return;
}

View File

@ -9,7 +9,7 @@
this.currentMessage
this.currentMessage.inReplyTo
}}
@cancelAction={{this.onCancel}}
@cancelAction={{this.composer.cancel}}
/>
{{/if}}
@ -46,7 +46,7 @@
<div
class="chat-composer__input-container"
{{on "click" this.focusTextarea}}
{{on "click" this.composer.focus}}
>
<DTextarea
id={{this.composerId}}

View File

@ -16,7 +16,7 @@ import { SKIP } from "discourse/lib/autocomplete";
import I18n from "I18n";
import { translations } from "pretty-text/emoji/data";
import { setupHashtagAutocomplete } from "discourse/lib/hashtag-autocomplete";
import { isEmpty, isPresent } from "@ember/utils";
import { isPresent } from "@ember/utils";
import { Promise } from "rsvp";
import User from "discourse/models/user";
import ChatMessageInteractor from "discourse/plugins/chat/discourse/lib/chat-message-interactor";
@ -69,15 +69,6 @@ export default class ChatComposer extends Component {
);
}
get disabled() {
return (
(this.args.channel.isDraft &&
isEmpty(this.args.channel?.chatable?.users)) ||
!this.chat.userCanInteractWithChat ||
!this.args.channel.canModifyMessages(this.currentUser)
);
}
@action
persistDraft() {}
@ -91,21 +82,23 @@ export default class ChatComposer extends Component {
@action
setupTextareaInteractor(textarea) {
this.textareaInteractor = new TextareaInteractor(getOwner(this), textarea);
this.composer.textarea = new TextareaInteractor(getOwner(this), textarea);
if (this.site.desktopView) {
this.composer.focus({ ensureAtEnd: true, refreshHeight: true });
}
}
@action
didUpdateMessage() {
this.cancelPersistDraft();
this.textareaInteractor.value = this.currentMessage.message || "";
this.textareaInteractor.focus({ refreshHeight: true });
this.composer.value = this.currentMessage.message;
this.persistDraft();
}
@action
didUpdateInReplyTo() {
this.cancelPersistDraft();
this.textareaInteractor.focus({ ensureAtEnd: true, refreshHeight: true });
this.persistDraft();
}
@ -166,20 +159,15 @@ export default class ChatComposer extends Component {
insertDiscourseLocalDate() {
showModal("discourse-local-dates-create-modal").setProperties({
insertDate: (markup) => {
this.textareaInteractor.addText(
this.textareaInteractor.getSelected(),
this.composer.textarea.addText(
this.composer.textarea.getSelected(),
markup
);
this.textareaInteractor.focus();
this.composer.focus();
},
});
}
@action
focusTextarea() {
this.textareaInteractor.focus();
}
@action
uploadClicked() {
document.querySelector(`#${this.fileUploadElementId}`).click();
@ -196,7 +184,7 @@ export default class ChatComposer extends Component {
onInput(event) {
this.currentMessage.draftSaved = false;
this.currentMessage.message = event.target.value;
this.textareaInteractor.refreshHeight();
this.composer.textarea.refreshHeight();
this.reportReplyingPresence();
this.persistDraft();
this.captureMentions();
@ -204,10 +192,6 @@ export default class ChatComposer extends Component {
@action
onUploadChanged(uploads, { inProgressUploadsCount }) {
if (!this.args.channel) {
return;
}
this.currentMessage.draftSaved = false;
this.inProgressUploadsCount = inProgressUploadsCount || 0;
@ -221,13 +205,13 @@ export default class ChatComposer extends Component {
this.currentMessage.uploads = cloneJSON(uploads);
}
this.textareaInteractor?.focus();
this.composer.textarea?.focus();
this.reportReplyingPresence();
this.persistDraft();
}
@action
onSend() {
async onSend() {
if (!this.sendEnabled) {
return;
}
@ -249,16 +233,11 @@ export default class ChatComposer extends Component {
// prevents to hide the keyboard after sending a message
// we use direct DOM manipulation here because textareaInteractor.focus()
// is using the runloop which is too late
this.textareaInteractor.textarea.focus();
this.composer.textarea.textarea.focus();
}
this.args.onSendMessage(this.currentMessage);
this.textareaInteractor.focus({ refreshHeight: true });
}
@action
onCancel() {
this.composer.cancel();
await this.args.onSendMessage(this.currentMessage);
this.composer.focus({ refreshHeight: true });
}
reportReplyingPresence() {
@ -282,13 +261,13 @@ export default class ChatComposer extends Component {
return;
}
const sel = this.textareaInteractor.getSelected("", { lineVal: true });
const sel = this.composer.textarea.getSelected("", { lineVal: true });
if (options.type === "bold") {
this.textareaInteractor.applySurround(sel, "**", "**", "bold_text");
this.composer.textarea.applySurround(sel, "**", "**", "bold_text");
} else if (options.type === "italic") {
this.textareaInteractor.applySurround(sel, "_", "_", "italic_text");
this.composer.textarea.applySurround(sel, "_", "_", "italic_text");
} else if (options.type === "code") {
this.textareaInteractor.applySurround(sel, "`", "`", "code_text");
this.composer.textarea.applySurround(sel, "`", "`", "code_text");
}
}
@ -321,6 +300,10 @@ export default class ChatComposer extends Component {
return;
}
if (event.key === "Escape" && !event.shiftKey) {
return this.handleEscape(event);
}
if (event.key === "Enter") {
if (event.shiftKey) {
// Shift+Enter: insert newline
@ -330,8 +313,8 @@ export default class ChatComposer extends Component {
// Ctrl+Enter, plain Enter: send
if (!event.ctrlKey) {
// if we are inside a code block just insert newline
const { pre } = this.textareaInteractor.getSelected({ lineVal: true });
if (this.textareaInteractor.isInside(pre, /(^|\n)```/g)) {
const { pre } = this.composer.textarea.getSelected({ lineVal: true });
if (this.composer.textarea.isInside(pre, /(^|\n)```/g)) {
return;
}
}
@ -351,27 +334,10 @@ export default class ChatComposer extends Component {
} else {
const editableMessage = this.lastUserMessage(this.currentUser);
if (editableMessage?.editable) {
this.composer.editMessage(editableMessage);
this.composer.edit(editableMessage);
}
}
}
if (event.key === "Escape" && this.isFocused) {
event.stopPropagation();
if (this.currentMessage?.inReplyTo) {
this.reset();
} else if (this.currentMessage?.editing) {
this.composer.cancel();
} else {
event.target.blur();
}
}
}
@action
reset() {
this.composer.reset(this.args.channel, this.args.thread);
}
@action
@ -380,12 +346,12 @@ export default class ChatComposer extends Component {
return;
}
const selected = this.textareaInteractor.getSelected("", { lineVal: true });
const selected = this.composer.textarea.getSelected("", { lineVal: true });
const linkText = selected?.value;
showModal("insert-hyperlink").setProperties({
linkText,
toolbarEvent: {
addText: (text) => this.textareaInteractor.addText(selected, text),
addText: (text) => this.composer.textarea.addText(selected, text),
},
});
}
@ -394,13 +360,10 @@ export default class ChatComposer extends Component {
onSelectEmoji(emoji) {
const code = `:${emoji}:`;
this.chatEmojiReactionStore.track(code);
this.textareaInteractor.addText(
this.textareaInteractor.getSelected(),
code
);
this.composer.textarea.addText(this.composer.textarea.getSelected(), code);
if (this.site.desktopView) {
this.textareaInteractor.focus();
this.composer.focus();
} else {
this.chatEmojiPickerManager.close();
}
@ -454,8 +417,8 @@ export default class ChatComposer extends Component {
},
afterComplete: (text, event) => {
event.preventDefault();
this.textareaInteractor.value = text;
this.textareaInteractor.focus();
this.composer.value = text;
this.composer.focus();
this.captureMentions();
},
});
@ -470,8 +433,8 @@ export default class ChatComposer extends Component {
treatAsTextarea: true,
afterComplete: (text, event) => {
event.preventDefault();
this.textareaInteractor.value = text;
this.textareaInteractor.focus();
this.composer.value = text;
this.composer.focus();
},
}
);
@ -487,8 +450,8 @@ export default class ChatComposer extends Component {
key: ":",
afterComplete: (text, event) => {
event.preventDefault();
this.textareaInteractor.value = text;
this.textareaInteractor.focus();
this.composer.value = text;
this.composer.focus();
},
treatAsTextarea: true,
onKeyUp: (text, cp) => {

View File

@ -19,7 +19,7 @@
{{#if this.chatStateManager.isDrawerExpanded}}
<div class="chat-drawer-content" {{did-insert this.fetchChannel}}>
{{#if this.chat.activeChannel}}
<Chat::Thread::List
<Chat::ThreadList
@channel={{this.chat.activeChannel}}
@includeHeader={{false}}
/>

View File

@ -38,7 +38,7 @@ export default class ChatMessage extends Component {
@service chatEmojiReactionStore;
@service chatEmojiPickerManager;
@service chatChannelPane;
@service chatChannelThreadPane;
@service chatThreadPane;
@service chatChannelsManager;
@service router;
@ -51,7 +51,7 @@ export default class ChatMessage extends Component {
get pane() {
return this.args.context === MESSAGE_CONTEXT_THREAD
? this.chatChannelThreadPane
? this.chatThreadPane
: this.chatChannelPane;
}

View File

@ -2,16 +2,16 @@
class={{concat-class
"chat-thread"
(if this.loading "loading")
(if this.thread.staged "staged")
(if @thread.staged "staged")
}}
data-id={{this.thread.id}}
data-id={{@thread.id}}
{{did-insert this.setUploadDropZone}}
{{did-insert this.didUpdateThread}}
{{did-update this.didUpdateThread this.thread.id}}
{{did-update this.didUpdateThread @thread.id}}
{{will-destroy this.unsubscribeFromUpdates}}
>
{{#if @includeHeader}}
<Chat::Thread::Header @thread={{this.thread}} @channel={{this.channel}} />
<Chat::Thread::Header @channel={{@thread.channel}} @thread={{@thread}} />
{{/if}}
<div
@ -23,7 +23,7 @@
class="chat-thread__messages chat-messages-scroll chat-messages-container"
{{chat/on-resize this.didResizePane (hash delay=10)}}
>
{{#each this.thread.messages key="id" as |message|}}
{{#each @thread.messages key="id" as |message|}}
<ChatMessage
@message={{message}}
@resendStagedMessage={{this.resendStagedMessage}}
@ -38,24 +38,24 @@
</div>
</div>
{{#if this.chatChannelThreadPane.selectingMessages}}
{{#if this.chatThreadPane.selectingMessages}}
<ChatSelectionManager
@selectedMessageIds={{this.chatChannelThreadPane.selectedMessageIds}}
@chatChannel={{this.channel}}
@selectedMessageIds={{this.chatThreadPane.selectedMessageIds}}
@chatChannel={{@channel}}
@cancelSelecting={{action
this.chatChannelThreadPane.cancelSelecting
this.channel.selectedMessages
this.chatThreadPane.cancelSelecting
@channel.selectedMessages
}}
@context="thread"
/>
{{else}}
<Chat::Composer::Thread
@channel={{this.channel}}
@thread={{this.thread}}
@channel={{@channel}}
@thread={{@thread}}
@onSendMessage={{this.onSendMessage}}
@uploadDropZone={{this.uploadDropZone}}
/>
{{/if}}
<ChatUploadDropZone @model={{this.thread}} />
<ChatUploadDropZone @model={{@thread}} />
</div>

View File

@ -20,9 +20,9 @@ export default class ChatThreadPanel extends Component {
@service router;
@service chatApi;
@service chatComposerPresenceManager;
@service chatChannelThreadComposer;
@service chatChannelThreadPane;
@service chatChannelThreadPaneSubscriptionsManager;
@service chatThreadComposer;
@service chatThreadPane;
@service chatThreadPaneSubscriptionsManager;
@service appEvents;
@service capabilities;
@ -31,19 +31,21 @@ export default class ChatThreadPanel extends Component {
scrollable = null;
get thread() {
return this.args.thread;
}
get channel() {
return this.thread?.channel;
@action
handleKeydown(event) {
if (event.key === "Escape") {
return this.router.transitionTo(
"chat.channel",
...this.args.thread.channel.routeModels
);
}
}
@action
didUpdateThread() {
this.subscribeToUpdates();
this.loadMessages();
this.resetComposer();
this.resetComposerMessage();
}
@action
@ -53,12 +55,12 @@ export default class ChatThreadPanel extends Component {
@action
subscribeToUpdates() {
this.chatChannelThreadPaneSubscriptionsManager.subscribe(this.thread);
this.chatThreadPaneSubscriptionsManager.subscribe(this.args.thread);
}
@action
unsubscribeFromUpdates() {
this.chatChannelThreadPaneSubscriptionsManager.unsubscribe();
this.chatThreadPaneSubscriptionsManager.unsubscribe();
}
// TODO (martin) This needs to have the extended scroll/message visibility/
@ -71,7 +73,7 @@ export default class ChatThreadPanel extends Component {
return;
}
this.resetActiveMessage();
this.chat.activeMessage = null;
if (this.#isAtBottom()) {
this.updateLastReadMessage();
@ -128,7 +130,7 @@ export default class ChatThreadPanel extends Component {
@action
loadMessages() {
this.thread.messagesManager.clearMessages();
this.args.thread.messagesManager.clearMessages();
this.fetchMessages();
}
@ -147,8 +149,10 @@ export default class ChatThreadPanel extends Component {
return Promise.resolve();
}
if (this.thread.staged) {
this.thread.messagesManager.addMessages([this.thread.originalMessage]);
if (this.args.thread.staged) {
const message = this.args.thread.originalMessage;
message.thread = this.args.thread;
this.args.thread.messagesManager.addMessages([message]);
return Promise.resolve();
}
@ -156,23 +160,25 @@ export default class ChatThreadPanel extends Component {
const findArgs = {
pageSize: PAGE_SIZE,
threadId: this.thread.id,
threadId: this.args.thread.id,
includeMessages: true,
};
return this.chatApi
.channel(this.channel.id, findArgs)
.channel(this.args.thread.channel.id, findArgs)
.then((result) => {
if (this._selfDeleted || this.channel.id !== result.meta.channel_id) {
if (
this._selfDeleted ||
this.args.thread.channel.id !== result.meta.channel_id
) {
this.router.transitionTo("chat.channel", "-", result.meta.channel_id);
}
const [messages, meta] = this.afterFetchCallback(
this.channel,
this.thread,
this.args.thread,
result
);
this.thread.messagesManager.addMessages(messages);
this.thread.details = meta;
this.args.thread.messagesManager.addMessages(messages);
this.args.thread.details = meta;
this.markThreadAsRead();
})
.catch(this.#handleErrors)
@ -186,7 +192,7 @@ export default class ChatThreadPanel extends Component {
}
@bind
afterFetchCallback(channel, thread, result) {
afterFetchCallback(thread, result) {
const messages = [];
result.chat_messages.forEach((messageData) => {
@ -200,7 +206,7 @@ export default class ChatThreadPanel extends Component {
}
messageData.expanded = !(messageData.hidden || messageData.deleted_at);
const message = ChatMessage.create(channel, messageData);
const message = ChatMessage.create(thread.channel, messageData);
message.thread = thread;
messages.push(message);
});
@ -212,7 +218,10 @@ export default class ChatThreadPanel extends Component {
// and scrolling; for now it's enough to do it when the thread panel
// opens/messages are loaded since we have no pagination for threads.
markThreadAsRead() {
return this.chatApi.markThreadAsRead(this.channel.id, this.thread.id);
return this.chatApi.markThreadAsRead(
this.args.thread.channel.id,
this.args.thread.id
);
}
@action
@ -227,59 +236,60 @@ export default class ChatThreadPanel extends Component {
}
@action
resetComposer() {
this.chatChannelThreadComposer.reset(this.channel, this.thread);
}
@action
resetActiveMessage() {
this.chat.activeMessage = null;
resetComposerMessage() {
this.chatThreadComposer.reset(this.args.thread);
}
async #sendNewMessage(message) {
message.thread = this.thread;
if (this.chatChannelThreadPane.sending) {
if (this.chatThreadPane.sending) {
return;
}
this.chatChannelThreadPane.sending = true;
await this.thread.stageMessage(message);
this.resetComposer();
this.chatThreadPane.sending = true;
await this.args.thread.stageMessage(message);
this.resetComposerMessage();
this.scrollToBottom();
try {
await this.chatApi.sendMessage(this.channel.id, {
message: message.message,
in_reply_to_id: message.thread.staged
? message.thread.originalMessage.id
: null,
staged_id: message.id,
upload_ids: message.uploads.map((upload) => upload.id),
thread_id: message.thread.staged ? null : message.thread.id,
staged_thread_id: message.thread.staged ? message.thread.id : null,
});
await this.chatApi
.sendMessage(this.args.thread.channel.id, {
message: message.message,
in_reply_to_id: message.thread.staged
? message.thread.originalMessage.id
: null,
staged_id: message.id,
upload_ids: message.uploads.map((upload) => upload.id),
thread_id: message.thread.staged ? null : message.thread.id,
staged_thread_id: message.thread.staged ? message.thread.id : null,
})
.catch((error) => {
this.#onSendError(message.id, error);
})
.finally(() => {
if (this._selfDeleted) {
return;
}
this.chatThreadPane.sending = false;
});
} catch (error) {
this.#onSendError(message.id, error);
} finally {
if (!this._selfDeleted) {
this.chatChannelThreadPane.sending = false;
this.chatThreadPane.sending = false;
}
}
}
async #sendEditMessage(message) {
await message.cook();
this.chatChannelThreadPane.sending = true;
this.chatThreadPane.sending = true;
const data = {
new_message: message.message,
upload_ids: message.uploads.map((upload) => upload.id),
};
this.resetComposer();
this.resetComposerMessage();
try {
return await this.chatApi.editMessage(
@ -290,7 +300,7 @@ export default class ChatThreadPanel extends Component {
} catch (e) {
popupAjaxError(e);
} finally {
this.chatChannelThreadPane.sending = false;
this.chatThreadPane.sending = false;
}
}
@ -370,7 +380,7 @@ export default class ChatThreadPanel extends Component {
#onSendError(stagedId, error) {
const stagedMessage =
this.thread.messagesManager.findStagedMessage(stagedId);
this.args.thread.messagesManager.findStagedMessage(stagedId);
if (stagedMessage) {
if (error.jqXHR?.responseJSON?.errors?.length) {
stagedMessage.error = error.jqXHR.responseJSON.errors[0];
@ -380,6 +390,6 @@ export default class ChatThreadPanel extends Component {
}
}
this.resetComposer();
this.resetComposerMessage();
}
}

View File

@ -3,11 +3,13 @@ import { inject as service } from "@ember/service";
import I18n from "I18n";
import discourseDebounce from "discourse-common/lib/debounce";
import { action } from "@ember/object";
import { isEmpty } from "@ember/utils";
export default class ChatComposerChannel extends ChatComposer {
@service("chat-channel-composer") composer;
@service("chat-channel-pane") pane;
@service chatDraftsManager;
@service currentUser;
context = "channel";
@ -18,6 +20,20 @@ export default class ChatComposerChannel extends ChatComposer {
return `/chat-reply/${channel.id}`;
}
get disabled() {
return (
(this.args.channel.isDraft &&
isEmpty(this.args.channel?.chatable?.users)) ||
!this.chat.userCanInteractWithChat ||
!this.args.channel.canModifyMessages(this.currentUser)
);
}
@action
reset() {
this.composer.reset(this.args.channel);
}
@action
persistDraft() {
if (this.args.channel?.isDraft) {
@ -78,6 +94,18 @@ export default class ChatComposerChannel extends ChatComposer {
}
}
handleEscape(event) {
event.stopPropagation();
if (this.currentMessage?.inReplyTo) {
this.reset();
} else if (this.currentMessage?.editing) {
this.composer.cancel(this.args.channel);
} else {
event.target.blur();
}
}
#messageRecipients(channel) {
if (channel.isDirectMessageChannel) {
const directMessageRecipients = channel.chatable.users;

View File

@ -4,17 +4,30 @@ import I18n from "I18n";
import { action } from "@ember/object";
export default class ChatComposerThread extends ChatComposer {
@service("chat-channel-thread-composer") composer;
@service("chat-channel-composer") channelComposer;
@service("chat-channel-thread-pane") pane;
@service router;
@service("chat-thread-composer") composer;
@service("chat-thread-pane") pane;
@service currentUser;
context = "thread";
composerId = "thread-composer";
@action
reset() {
this.composer.reset(this.args.thread);
}
get disabled() {
return (
!this.chat.userCanInteractWithChat ||
!this.args.thread.channel.canModifyMessages(this.currentUser)
);
}
get presenceChannelName() {
return `/chat-reply/${this.args.channel.id}/thread/${this.args.thread.id}`;
const thread = this.args.thread;
return `/chat-reply/${thread.channel.id}/thread/${thread.id}`;
}
get placeholder() {
@ -29,16 +42,20 @@ export default class ChatComposerThread extends ChatComposer {
return this.args.thread.lastUserMessage(user);
}
@action
onKeyDown(event) {
if (event.key === "Escape") {
this.router.transitionTo(
"chat.channel",
...this.args.channel.routeModels
);
handleEscape(event) {
if (this.currentMessage.editing) {
event.stopPropagation();
this.composer.cancel(this.args.thread);
return;
}
super.onKeyDown(event);
if (this.isFocused) {
event.stopPropagation();
this.composer.blur();
} else {
this.pane.close().then(() => {
this.channelComposer.focus();
});
}
}
}

View File

@ -0,0 +1,26 @@
{{#if this.shouldRender}}
<div
class="chat-thread-list"
{{did-insert this.loadThreads}}
{{did-update this.loadThreads @channel}}
{{will-destroy this.teardown}}
>
{{#if @includeHeader}}
<Chat::ThreadList::Header @channel={{@channel}} />
{{/if}}
<div class="chat-thread-list__items">
{{#if this.loading}}
{{loading-spinner size="medium"}}
{{else}}
{{#each this.threads as |thread|}}
<Chat::ThreadList::Item @thread={{thread}} />
{{else}}
<div class="chat-thread-list__no-threads">
{{i18n "chat.threads.none"}}
</div>
{{/each}}
{{/if}}
</div>
</div>
{{/if}}

View File

@ -9,12 +9,12 @@ export default class ChatThreadList extends Component {
@tracked threads;
@tracked loading = true;
get shouldRender() {
return !!this.args.channel;
}
@action
loadThreads() {
if (!this.args.channel) {
return;
}
this.loading = true;
this.args.channel.threadsManager
.index(this.args.channel.id)

View File

@ -0,0 +1,16 @@
<div class="chat-thread-header">
<span class="chat-thread-header__label">
{{replace-emoji (i18n "chat.threads.list")}}
</span>
<div class="chat-thread-header__buttons">
<LinkTo
class="chat-thread__close btn-flat btn btn-icon no-text"
@route="chat.channel"
@models={{@channel.routeModels}}
title={{i18n "chat.thread.close"}}
>
{{d-icon "times"}}
</LinkTo>
</div>
</div>

View File

@ -20,7 +20,7 @@
{{replace-emoji this.title}}
</div>
<div class="chat-thread-list-item__unread-indicator">
<Chat::Thread::ListItemUnreadIndicator @thread={{@thread}} />
<Chat::ThreadList::Item::UnreadIndicator @thread={{@thread}} />
</div>
</div>

View File

@ -3,7 +3,6 @@ import { inject as service } from "@ember/service";
import { action } from "@ember/object";
export default class ChatThreadListItem extends Component {
@service currentUser;
@service router;
get title() {

View File

@ -0,0 +1,7 @@
{{#if this.showUnreadIndicator}}
<div class="chat-thread-list-item-unread-indicator">
<div class="chat-thread-list-item-unread-indicator__number">
{{this.unreadCountLabel}}
</div>
</div>
{{/if}}

View File

@ -4,10 +4,9 @@
<LinkTo
class="chat-thread__back-to-list btn-flat btn btn-icon no-text"
@route="chat.channel.threads"
@models={{@channel.routeModels}}
title={{i18n "chat.return_to_threads_list"}}
>
<Chat::Thread::HeaderUnreadIndicator @channel={{@channel}} />
<Chat::Thread::HeaderUnreadIndicator @channel={{@thread.channel}} />
{{d-icon "chevron-left"}}
</LinkTo>
{{/if}}
@ -20,17 +19,16 @@
<div class="chat-thread-header__buttons">
{{#if this.canChangeThreadSettings}}
<DButton
@action={{action this.openThreadSettings}}
@action={{this.openThreadSettings}}
@class="btn-flat chat-thread-header__settings"
@icon="cog"
@title="chat.thread.settings"
/>
{{/if}}
<LinkTo
class="chat-thread__close btn-flat btn btn-icon no-text"
@route="chat.channel"
@models={{@channel.routeModels}}
@models={{@thread.channel.routeModels}}
title={{i18n "chat.thread.close"}}
>
{{d-icon "times"}}

View File

@ -1,5 +1,4 @@
import Component from "@glimmer/component";
import I18n from "I18n";
import showModal from "discourse/lib/show-modal";
import { inject as service } from "@ember/service";
import { action } from "@ember/object";
@ -9,11 +8,7 @@ export default class ChatThreadHeader extends Component {
@service router;
get label() {
if (this.args.thread) {
return this.args.thread.escapedTitle;
} else {
return I18n.t("chat.threads.list");
}
return this.args.thread.escapedTitle;
}
get canChangeThreadSettings() {

View File

@ -1,7 +0,0 @@
{{#if this.showUnreadIndicator}}
<div class="chat-thread-list-item-unread-indicator">
<div
class="chat-thread-list-item-unread-indicator__number"
>{{this.unreadCountLabel}}</div>
</div>
{{/if}}

View File

@ -1,24 +0,0 @@
<div
class="chat-thread-list"
{{did-insert this.loadThreads}}
{{did-update this.loadThreads @channel}}
{{will-destroy this.teardown}}
>
{{#if @includeHeader}}
<Chat::Thread::Header @channel={{@channel}} />
{{/if}}
<div class="chat-thread-list__items">
{{#if this.loading}}
{{loading-spinner size="medium"}}
{{else}}
{{#each this.threads as |thread|}}
<Chat::Thread::ListItem @thread={{thread}} />
{{else}}
<div class="chat-thread-list__no-threads">
{{i18n "chat.threads.none"}}
</div>
{{/each}}
{{/if}}
</div>
</div>

View File

@ -1,5 +1,5 @@
<StyleguideExample @title="<Chat::Thread::ListItem>">
<StyleguideExample @title="<Chat::ThreadList::Item>">
<Styleguide::Component>
<Chat::Thread::ListItem @thread={{this.thread}} />
<Chat::ThreadList::Item @thread={{this.thread}} />
</Styleguide::Component>
</StyleguideExample>

View File

@ -17,6 +17,10 @@ export default {
const router = container.lookup("service:router");
const appEvents = container.lookup("service:app-events");
const chatStateManager = container.lookup("service:chat-state-manager");
const chatThreadPane = container.lookup("service:chat-thread-pane");
const chatThreadListPane = container.lookup(
"service:chat-thread-list-pane"
);
const chatChannelsManager = container.lookup(
"service:chat-channels-manager"
);
@ -87,14 +91,27 @@ export default {
router.transitionTo(chatStateManager.lastKnownChatURL || "chat");
};
const closeChatDrawer = (event) => {
if (!chatStateManager.isDrawerActive) {
const closeChat = (event) => {
if (chatStateManager.isDrawerActive) {
event.preventDefault();
event.stopPropagation();
appEvents.trigger("chat:toggle-close", event);
return;
}
event.preventDefault();
event.stopPropagation();
appEvents.trigger("chat:toggle-close", event);
if (chatThreadPane.isOpened) {
event.preventDefault();
event.stopPropagation();
chatThreadPane.close();
return;
}
if (chatThreadListPane.isOpened) {
event.preventDefault();
event.stopPropagation();
chatThreadListPane.close();
return;
}
};
const markAllChannelsRead = (event) => {
@ -205,7 +222,7 @@ export default {
},
},
});
api.addKeyboardShortcut("esc", (event) => closeChatDrawer(event), {
api.addKeyboardShortcut("esc", (event) => closeChat(event), {
global: true,
help: {
category: "chat",

View File

@ -25,9 +25,9 @@ export default class ChatMessageInteractor {
@service chatEmojiReactionStore;
@service chatEmojiPickerManager;
@service chatChannelComposer;
@service chatChannelThreadComposer;
@service chatThreadComposer;
@service chatChannelPane;
@service chatChannelThreadPane;
@service chatThreadPane;
@service chatApi;
@service currentUser;
@service site;
@ -52,7 +52,7 @@ export default class ChatMessageInteractor {
get pane() {
return this.context === MESSAGE_CONTEXT_THREAD
? this.chatChannelThreadPane
? this.chatThreadPane
: this.chatChannelPane;
}
@ -143,7 +143,7 @@ export default class ChatMessageInteractor {
get composer() {
return this.context === MESSAGE_CONTEXT_THREAD
? this.chatChannelThreadComposer
? this.chatThreadComposer
: this.chatChannelComposer;
}
@ -354,7 +354,7 @@ export default class ChatMessageInteractor {
@action
edit() {
this.composer.editMessage(this.message);
this.composer.edit(this.message);
}
@action

View File

@ -45,21 +45,35 @@ export default class TextareaInteractor extends EmberObject.extend(
this._textarea.dispatchEvent(event);
}
focus(opts = { ensureAtEnd: false, refreshHeight: true }) {
blur() {
next(() => {
if (opts.refreshHeight) {
this.refreshHeight();
}
schedule("afterRender", () => {
this._textarea.blur();
});
});
}
if (opts.ensureAtEnd) {
this.ensureCaretAtEnd();
}
focus(opts = { ensureAtEnd: false, refreshHeight: true, addText: null }) {
next(() => {
schedule("afterRender", () => {
if (opts.refreshHeight) {
this.refreshHeight();
}
if (this.capabilities.isIpadOS || this.site.mobileView) {
return;
}
if (opts.ensureAtEnd) {
this.ensureCaretAtEnd();
}
this.focusTextArea();
if (this.capabilities.isIpadOS || this.site.mobileView) {
return;
}
if (opts.addText) {
this.addText(this.getSelected(), opts.addText);
}
this.focusTextArea();
});
});
}

View File

@ -291,6 +291,7 @@ export default class ChatChannel {
message.staged = true;
message.draft = false;
message.createdAt ??= moment.utc().format();
message.channel = this;
await message.cook();
if (message.inReplyTo) {

View File

@ -68,6 +68,7 @@ export default class ChatThread {
message.staged = true;
message.draft = false;
message.createdAt ??= moment.utc().format();
message.thread = this;
await message.cook();
this.messagesManager.addMessages([message]);

View File

@ -7,7 +7,7 @@ export default class ChatChannelThread extends DiscourseRoute {
@service chatStateManager;
@service chat;
@service chatStagedThreadMapping;
@service chatChannelThreadPane;
@service chatThreadPane;
model(params, transition) {
const channel = this.modelFor("chat.channel");
@ -16,18 +16,22 @@ export default class ChatChannelThread extends DiscourseRoute {
.find(channel.id, params.threadId)
.catch(() => {
transition.abort();
this.chatStateManager.closeSidePanel();
this.router.transitionTo("chat.channel", ...channel.routeModels);
return;
});
}
afterModel(model) {
this.chatChannelThreadPane.open(model);
this.chat.activeChannel.activeThread = model;
this.chatThreadPane.open(model);
}
@action
willTransition() {
this.chatChannelThreadPane.close();
willTransition(transition) {
if (transition.targetName === "chat.channel.index") {
this.chatStateManager.closeSidePanel();
}
}
beforeModel(transition) {
@ -51,12 +55,10 @@ export default class ChatChannelThread extends DiscourseRoute {
if (mapping[threadId]) {
transition.abort();
this.router.transitionTo(
return this.router.transitionTo(
"chat.channel.thread",
...[...channel.routeModels, mapping[threadId]]
);
return;
}
}

View File

@ -4,7 +4,7 @@ import { action } from "@ember/object";
export default class ChatChannelThreads extends DiscourseRoute {
@service router;
@service chatChannelThreadListPane;
@service chatThreadListPane;
@service chatStateManager;
beforeModel(transition) {
@ -21,12 +21,12 @@ export default class ChatChannelThreads extends DiscourseRoute {
@action
willTransition(transition) {
if (transition.targetName !== "chat.channel.thread") {
this.chatChannelThreadListPane.close();
if (transition.targetName === "chat.channel.index") {
this.chatStateManager.closeSidePanel();
}
}
activate() {
this.chatChannelThreadListPane.open();
this.chatThreadListPane.open();
}
}

View File

@ -1,15 +1,26 @@
import { inject as service } from "@ember/service";
import Service, { inject as service } from "@ember/service";
import { action } from "@ember/object";
import ChatComposer from "./chat-composer";
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
import { tracked } from "@glimmer/tracking";
export default class ChatChannelComposer extends ChatComposer {
export default class ChatChannelComposer extends Service {
@service chat;
@service currentUser;
@service router;
@service siteSettings;
@service("chat-thread-composer") threadComposer;
@tracked message;
@tracked textarea;
@action
cancelEditing() {
this.reset(this.message.channel);
focus(options = {}) {
this.textarea.focus(options);
}
@action
blur() {
this.textarea.blur();
}
@action
@ -20,26 +31,47 @@ export default class ChatChannelComposer extends ChatComposer {
}
@action
replyTo(message) {
cancel() {
if (this.message.editing) {
this.reset(this.message.channel);
} else if (this.message.inReplyTo) {
this.message.inReplyTo = null;
}
this.focus({ ensureAtEnd: true, refreshHeight: true });
}
@action
edit(message) {
this.chat.activeMessage = null;
message.editing = true;
this.message = message;
this.focus({ refreshHeight: true, ensureAtEnd: true });
}
@action
async replyTo(message) {
this.chat.activeMessage = null;
const channel = message.channel;
if (
this.siteSettings.enable_experimental_chat_threaded_discussions &&
channel.threadingEnabled
message.channel.threadingEnabled
) {
let thread;
if (message.thread?.id) {
thread = message.thread;
} else {
thread = channel.createStagedThread(message);
message.thread = thread;
if (!message.thread?.id) {
message.thread = message.channel.createStagedThread(message);
}
this.reset(channel);
this.router.transitionTo("chat.channel.thread", ...thread.routeModels);
this.reset(message.channel);
await this.router.transitionTo(
"chat.channel.thread",
...message.thread.routeModels
);
this.threadComposer.focus({ ensureAtEnd: true, refreshHeight: true });
} else {
this.message.inReplyTo = message;
this.focus({ ensureAtEnd: true, refreshHeight: true });
}
}
}

View File

@ -3,11 +3,7 @@ import { action } from "@ember/object";
import Service, { inject as service } from "@ember/service";
export default class ChatChannelPane extends Service {
@service appEvents;
@service chat;
@service chatChannelComposer;
@service chatApi;
@service chatComposerPresenceManager;
@tracked reacting = false;
@tracked selectingMessages = false;
@ -18,10 +14,6 @@ export default class ChatChannelPane extends Service {
return this.chat.activeChannel?.selectedMessages?.mapBy("id") || [];
}
get composerService() {
return this.chatChannelComposer;
}
get channel() {
return this.chat.activeChannel;
}
@ -35,6 +27,7 @@ export default class ChatChannelPane extends Service {
});
}
@action
onSelectMessage(message) {
this.lastSelectedMessage = message;
this.selectingMessages = true;

View File

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

View File

@ -1,15 +0,0 @@
import Service, { inject as service } from "@ember/service";
export default class ChatChannelThreadListPane extends Service {
@service chat;
@service chatStateManager;
close() {
this.chatStateManager.closeSidePanel();
}
open() {
this.chat.activeMessage = null;
this.chatStateManager.openSidePanel();
}
}

View File

@ -1,27 +0,0 @@
import ChatChannelPane from "./chat-channel-pane";
import { inject as service } from "@ember/service";
export default class ChatChannelThreadPane extends ChatChannelPane {
@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() {
return this.chat.activeChannel.activeThread.selectedMessages.mapBy("id");
}
get composerService() {
return this.chatChannelThreadComposer;
}
}

View File

@ -1,36 +0,0 @@
import { tracked } from "@glimmer/tracking";
import Service, { inject as service } from "@ember/service";
import { action } from "@ember/object";
export default class ChatComposer extends Service {
@service chat;
@service currentUser;
@tracked message;
@action
cancel() {
if (this.message.editing) {
this.cancelEditing();
} else if (this.message.inReplyTo) {
this.cancelReply();
}
}
@action
cancelReply() {
this.message.inReplyTo = null;
}
@action
clear() {
this.message.message = "";
}
@action
editMessage(message) {
this.chat.activeMessage = null;
message.editing = true;
this.message = message;
}
}

View File

@ -27,7 +27,7 @@ export function handleStagedMessage(channel, messagesManager, data) {
/**
* Handles subscriptions for MessageBus messages sent from Chat::Publisher
* to the channel and thread panes. There are individual services for
* each (ChatChannelPaneSubscriptionsManager and ChatChannelThreadPaneSubscriptionsManager)
* each (ChatChannelPaneSubscriptionsManager and ChatThreadPaneSubscriptionsManager)
* that implement their own logic where necessary. Functions which will
* always be different between the two raise a "not implemented" error in
* the base class, and the child class must define the associated function,

View File

@ -0,0 +1,51 @@
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
import { action } from "@ember/object";
import Service, { inject as service } from "@ember/service";
import { tracked } from "@glimmer/tracking";
export default class ChatThreadComposer extends Service {
@service chat;
@tracked message;
@tracked textarea;
@action
focus(options = {}) {
this.textarea?.focus(options);
}
@action
blur() {
this.textarea?.blur();
}
@action
reset(thread) {
this.message = ChatMessage.createDraftMessage(thread.channel, {
user: this.currentUser,
thread,
});
}
@action
cancel() {
if (this.message.editing) {
this.reset(this.message.thread);
} else if (this.message.inReplyTo) {
this.message.inReplyTo = null;
}
}
@action
edit(message) {
this.chat.activeMessage = null;
message.editing = true;
this.message = message;
this.focus({ refreshHeight: true, ensureAtEnd: true });
}
@action
replyTo() {
this.chat.activeMessage = null;
}
}

View File

@ -0,0 +1,24 @@
import Service, { inject as service } from "@ember/service";
export default class ChatThreadListPane extends Service {
@service chat;
@service router;
get isOpened() {
return this.router.currentRoute.name === "chat.channel.threads";
}
async close() {
await this.router.transitionTo(
"chat.channel",
...this.chat.activeChannel.routeModels
);
}
async open() {
await this.router.transitionTo(
"chat.channel.threads",
...this.chat.activeChannel.routeModels
);
}
}

View File

@ -1,7 +1,7 @@
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
import ChatPaneBaseSubscriptionsManager from "./chat-pane-base-subscriptions-manager";
export default class ChatChannelThreadPaneSubscriptionsManager extends ChatPaneBaseSubscriptionsManager {
export default class ChatThreadPaneSubscriptionsManager extends ChatPaneBaseSubscriptionsManager {
get messageBusChannel() {
return `/chat/${this.model.channel.id}/thread/${this.model.id}`;
}
@ -21,10 +21,8 @@ export default class ChatChannelThreadPaneSubscriptionsManager extends ChatPaneB
}
}
const message = ChatMessage.create(
this.chat.activeChannel,
data.chat_message
);
const message = ChatMessage.create(this.model.channel, data.chat_message);
message.thread = this.model;
this.messagesManager.addMessages([message]);
}

View File

@ -0,0 +1,29 @@
import ChatChannelPane from "./chat-channel-pane";
import { inject as service } from "@ember/service";
export default class ChatThreadPane extends ChatChannelPane {
@service chat;
@service router;
get isOpened() {
return this.router.currentRoute.name === "chat.channel.thread";
}
async close() {
await this.router.transitionTo(
"chat.channel",
...this.chat.activeChannel.routeModels
);
}
async open(thread) {
await this.router.transitionTo(
"chat.channel.thread",
...thread.routeModels
);
}
get selectedMessageIds() {
return this.chat.activeChannel.activeThread.selectedMessages.mapBy("id");
}
}

View File

@ -31,10 +31,7 @@ export default class Chat extends Service {
@service presence;
@service router;
@service site;
@service chatChannelsManager;
@service chatChannelPane;
@service chatChannelThreadPane;
@service chatTrackingStateManager;
cook = null;

View File

@ -1 +1 @@
<Chat::Thread::List @channel={{this.model}} @includeHeader={{true}} />
<Chat::ThreadList @channel={{this.model}} @includeHeader={{true}} />

View File

@ -0,0 +1,15 @@
.chat-thread-list-header {
height: var(--chat-header-offset);
min-height: var(--chat-header-offset);
border-bottom: 1px solid var(--primary-low);
border-top: 1px solid var(--primary-low);
box-sizing: border-box;
display: flex;
align-items: center;
padding-inline: 0.5rem;
&__buttons {
display: flex;
margin-left: auto;
}
}

View File

@ -53,4 +53,5 @@
@import "chat-composer-separator";
@import "chat-thread-header-buttons";
@import "chat-thread-header";
@import "chat-thread-list-header";
@import "chat-thread-unread-indicator";

View File

@ -547,7 +547,7 @@ en:
thread:
title: "Title"
view_thread: View thread
default_title: "Thread #%{thread_id}"
default_title: "Thread"
replies:
one: "%{count} reply"
other: "%{count} replies"

View File

@ -0,0 +1,99 @@
# frozen_string_literal: true
RSpec.describe "Chat | composer | channel", type: :system, js: true do
fab!(:channel_1) { Fabricate(:chat_channel) }
fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel_1) }
fab!(:current_user) { Fabricate(:admin) }
let(:chat_page) { PageObjects::Pages::Chat.new }
let(:channel_page) { PageObjects::Pages::ChatChannel.new }
let(:thread_page) { PageObjects::Pages::ChatThread.new }
before do
chat_system_bootstrap
channel_1.add(current_user)
sign_in(current_user)
end
describe "reply to message" do
it "renders text in the details" do
message_1.update!(message: "<mark>not marked</mark>")
chat_page.visit_channel(channel_1)
channel_page.reply_to(message_1)
expect(channel_page.composer.message_details).to have_message(
id: message_1.id,
exact_text: "not marked",
)
end
context "when threading is disabled" do
it "replies to the message" do
chat_page.visit_channel(channel_1)
channel_page.reply_to(message_1)
expect(channel_page.composer.message_details).to be_replying_to(message_1)
end
end
context "when threading is enabled" do
before do
SiteSetting.enable_experimental_chat_threaded_discussions = true
channel_1.update!(threading_enabled: true)
end
it "replies in the thread" do
chat_page.visit_channel(channel_1)
channel_page.reply_to(message_1)
expect(thread_page.composer).to be_focused
end
end
end
describe "edit message" do
fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel_1, user: current_user) }
it "adds the edit indicator" do
chat_page.visit_channel(channel_1)
channel_page.edit_message(message_1)
expect(channel_page.composer).to be_editing_message(message_1)
end
it "updates the message instantly" do
chat_page.visit_channel(channel_1)
page.driver.browser.network_conditions = { offline: true }
channel_page.edit_message(message_1, "instant")
expect(channel_page.messages).to have_message(
text: message_1.message + "instant",
persisted: false,
)
ensure
page.driver.browser.network_conditions = { offline: false }
end
context "when pressing escape" do
it "cancels editing" do
chat_page.visit_channel(channel_1)
channel_page.edit_message(message_1)
channel_page.composer.cancel_shortcut
expect(channel_page.composer).to be_editing_no_message
expect(channel_page.composer.value).to eq("")
end
end
context "when closing edited message" do
it "cancels editing" do
chat_page.visit_channel(channel_1)
channel_page.edit_message(message_1)
channel_page.composer.cancel_editing
expect(channel_page.composer).to be_editing_no_message
expect(channel_page.composer.value).to eq("")
end
end
end
end

View File

@ -4,9 +4,11 @@ RSpec.describe "Chat | composer | shortcuts | thread", type: :system do
fab!(:channel_1) { Fabricate(:chat_channel, threading_enabled: true) }
fab!(:current_user) { Fabricate(:admin) }
fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel_1) }
fab!(:thread_1) { Fabricate(:chat_message, user: current_user, in_reply_to: message_1).thread }
let(:chat_page) { PageObjects::Pages::Chat.new }
let(:thread_page) { PageObjects::Pages::ChatThread.new }
let(:side_panel_page) { PageObjects::Pages::ChatSidePanel.new }
before do
SiteSetting.enable_experimental_chat_threaded_discussions = true
@ -15,8 +17,19 @@ RSpec.describe "Chat | composer | shortcuts | thread", type: :system do
sign_in(current_user)
end
describe "Escape" do
context "when composer is focused" do
it "blurs the composer" do
chat_page.visit_thread(thread_1)
thread_page.composer.focus
thread_page.composer.cancel_shortcut
expect(side_panel_page).to have_open_thread
end
end
end
describe "ArrowUp" do
fab!(:thread_1) { Fabricate(:chat_message, user: current_user, in_reply_to: message_1).thread }
let(:last_thread_message) { thread_1.replies.last }
context "when there are editable messages" do

View File

@ -0,0 +1,65 @@
# frozen_string_literal: true
RSpec.describe "Chat | composer | thread", type: :system, js: true do
fab!(:channel_1) { Fabricate(:chat_channel, threading_enabled: true) }
fab!(:current_user) { Fabricate(:admin) }
fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel_1, user: current_user) }
fab!(:message_2) do
Fabricate(:chat_message, chat_channel: channel_1, user: current_user, in_reply_to: message_1)
end
let(:chat_page) { PageObjects::Pages::Chat.new }
let(:channel_page) { PageObjects::Pages::ChatChannel.new }
let(:thread_page) { PageObjects::Pages::ChatThread.new }
before do
SiteSetting.enable_experimental_chat_threaded_discussions = true
chat_system_bootstrap
channel_1.add(current_user)
sign_in(current_user)
end
describe "edit message" do
it "adds the edit indicator" do
chat_page.visit_thread(message_2.thread)
thread_page.edit_message(message_2)
expect(thread_page.composer).to be_editing_message(message_2)
end
it "updates the message instantly" do
chat_page.visit_thread(message_2.thread)
page.driver.browser.network_conditions = { offline: true }
thread_page.edit_message(message_2, "instant")
expect(thread_page.messages).to have_message(
text: message_2.message + "instant",
persisted: false,
)
ensure
page.driver.browser.network_conditions = { offline: false }
end
context "when pressing escape" do
it "cancels editing" do
chat_page.visit_thread(message_2.thread)
thread_page.edit_message(message_2)
thread_page.composer.cancel_shortcut
expect(thread_page.composer).to be_editing_no_message
expect(thread_page.composer).to be_blank
end
end
context "when closing edited message" do
it "cancels editing" do
chat_page.visit_thread(message_2.thread)
thread_page.edit_message(message_2)
thread_page.composer.cancel_editing
expect(thread_page.composer).to be_editing_no_message
expect(thread_page.composer.value).to be_blank
end
end
end
end

View File

@ -14,80 +14,6 @@ RSpec.describe "Chat composer", type: :system do
sign_in(current_user)
end
context "when replying to a message" do
it "adds the reply indicator to the composer" do
chat_page.visit_channel(channel_1)
channel_page.reply_to(message_1)
expect(page).to have_selector(
".chat-composer-message-details .chat-reply__username",
text: message_1.user.username,
)
end
context "with HTML tags" do
before { message_1.update!(message: "<mark>not marked</mark>") }
it "renders text in the details" do
chat_page.visit_channel(channel_1)
channel_page.reply_to(message_1)
expect(
find(".chat-composer-message-details .chat-reply__excerpt")["innerHTML"].strip,
).to eq("not marked")
end
end
end
context "when editing a message" do
fab!(:message_2) { Fabricate(:chat_message, chat_channel: channel_1, user: current_user) }
it "adds the edit indicator" do
chat_page.visit_channel(channel_1)
channel_page.edit_message(message_2)
expect(page).to have_selector(
".chat-composer-message-details .chat-reply__username",
text: current_user.username,
)
expect(channel_page.composer.value).to eq(message_2.message)
end
it "updates the message instantly" do
chat_page.visit_channel(channel_1)
page.driver.browser.network_conditions = { offline: true }
channel_page.edit_message(message_2)
find(".chat-composer__input").send_keys("instant")
channel_page.click_send_message
expect(channel_page).to have_message(text: message_2.message + "instant")
page.driver.browser.network_conditions = { offline: false }
end
context "when pressing escape" do
it "cancels editing" do
chat_page.visit_channel(channel_1)
channel_page.edit_message(message_2)
find(".chat-composer__input").send_keys(:escape)
expect(page).to have_no_selector(".chat-composer-message-details .chat-reply__username")
expect(channel_page.composer.value).to eq("")
end
end
context "when closing edited message" do
it "cancels editing" do
chat_page.visit_channel(channel_1)
channel_page.edit_message(message_2)
find(".cancel-message-action").click
expect(page).to have_no_selector(".chat-composer-message-details .chat-reply__username")
expect(channel_page.composer.value).to eq("")
end
end
end
context "when adding an emoji through the picker" do
xit "adds the emoji to the composer" do
chat_page.visit_channel(channel_1)
@ -169,32 +95,6 @@ RSpec.describe "Chat composer", type: :system do
end
end
context "when pasting link over selected text" do
it "outputs a markdown link" do
modifier = /darwin/i =~ RbConfig::CONFIG["host_os"] ? :command : :control
select_text = <<-JS
const element = document.querySelector(arguments[0]);
element.focus();
element.setSelectionRange(0, element.value.length)
JS
chat_page.visit_channel(channel_1)
find("body").send_keys("https://www.discourse.org")
page.execute_script(select_text, ".chat-composer__input")
page.send_keys [modifier, "c"]
page.send_keys [:backspace]
find("body").send_keys("discourse")
page.execute_script(select_text, ".chat-composer__input")
page.send_keys [modifier, "v"]
expect(channel_page.composer.value).to eq("[discourse](https://www.discourse.org)")
end
end
context "when editing a message with no length" do
it "deletes the message" do
chat_page.visit_channel(channel_1)
@ -211,10 +111,9 @@ RSpec.describe "Chat composer", type: :system do
it "works" do
chat_page.visit_channel(channel_1)
find("body").send_keys("1")
channel_page.click_send_message
channel_page.send_message("1")
expect(channel_page).to have_message(text: "1")
expect(channel_page.messages).to have_message(text: "1")
end
end

View File

@ -122,7 +122,7 @@ module PageObjects
def edit_message(message, text = nil)
open_edit_message(message)
send_message(text) if text
send_message(message.message + text) if text
end
def send_message(text = nil)

View File

@ -122,6 +122,17 @@ module PageObjects
text: I18n.t("js.chat.deleted", count: count),
)
end
def open_edit_message(message)
hover_message(message)
click_more_button
find("[data-value='edit']").click
end
def edit_message(message, text = nil)
open_edit_message(message)
send_message(message.message + text) if text
end
end
end
end

View File

@ -58,6 +58,10 @@ module PageObjects
input.send_keys([MODIFIER, "i"])
end
def cancel_shortcut
input.send_keys(:escape)
end
def indented_text_shortcut
input.send_keys([MODIFIER, "e"])
end
@ -70,9 +74,25 @@ module PageObjects
find(context).find(SELECTOR).find(".chat-composer-button.-emoji").click
end
def cancel_editing
component.click_button(class: "cancel-message-action")
end
def editing_message?(message)
value == message.message && message_details.editing?(message)
end
def editing_no_message?
value == "" && message_details.has_no_message?
end
def focus
component.click
end
def focused?
component.has_css?(".chat-composer.is-focused")
end
end
end
end

View File

@ -21,8 +21,17 @@ module PageObjects
selectors += "[data-id=\"#{args[:id]}\"]" if args[:id]
selectors += "[data-action=\"#{args[:action]}\"]" if args[:action]
selector_method = args[:does_not_exist] ? :has_no_selector? : :has_selector?
predicate = component.send(selector_method, selectors)
component.send(selector_method, selectors)
text_options = {}
text_options[:text] = args[:text] if args[:text]
text_options[:exact_text] = args[:exact_text] if args[:exact_text]
if text_options.present?
predicate &&=
component.send(selector_method, "#{selectors} .chat-reply__excerpt", **text_options)
end
predicate
end
def has_no_message?(**args)