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
53 changed files with 730 additions and 508 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,17 +4,30 @@ import I18n from "I18n";
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-composer") channelComposer; @service("chat-channel-composer") channelComposer;
@service("chat-channel-thread-pane") pane; @service("chat-thread-composer") composer;
@service router; @service("chat-thread-pane") pane;
@service currentUser;
context = "thread"; context = "thread";
composerId = "thread-composer"; 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() { 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() { get placeholder() {
@ -29,16 +42,20 @@ export default class ChatComposerThread extends ChatComposer {
return this.args.thread.lastUserMessage(user); return this.args.thread.lastUserMessage(user);
} }
@action handleEscape(event) {
onKeyDown(event) { if (this.currentMessage.editing) {
if (event.key === "Escape") { event.stopPropagation();
this.router.transitionTo( this.composer.cancel(this.args.thread);
"chat.channel",
...this.args.channel.routeModels
);
return; 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 threads;
@tracked loading = true; @tracked loading = true;
get shouldRender() {
return !!this.args.channel;
}
@action @action
loadThreads() { loadThreads() {
if (!this.args.channel) {
return;
}
this.loading = true; this.loading = true;
this.args.channel.threadsManager this.args.channel.threadsManager
.index(this.args.channel.id) .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}} {{replace-emoji this.title}}
</div> </div>
<div class="chat-thread-list-item__unread-indicator"> <div class="chat-thread-list-item__unread-indicator">
<Chat::Thread::ListItemUnreadIndicator @thread={{@thread}} /> <Chat::ThreadList::Item::UnreadIndicator @thread={{@thread}} />
</div> </div>
</div> </div>

View File

@ -3,7 +3,6 @@ import { inject as service } from "@ember/service";
import { action } from "@ember/object"; import { action } from "@ember/object";
export default class ChatThreadListItem extends Component { export default class ChatThreadListItem extends Component {
@service currentUser;
@service router; @service router;
get title() { 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 <LinkTo
class="chat-thread__back-to-list btn-flat btn btn-icon no-text" class="chat-thread__back-to-list btn-flat btn btn-icon no-text"
@route="chat.channel.threads" @route="chat.channel.threads"
@models={{@channel.routeModels}}
title={{i18n "chat.return_to_threads_list"}} title={{i18n "chat.return_to_threads_list"}}
> >
<Chat::Thread::HeaderUnreadIndicator @channel={{@channel}} /> <Chat::Thread::HeaderUnreadIndicator @channel={{@thread.channel}} />
{{d-icon "chevron-left"}} {{d-icon "chevron-left"}}
</LinkTo> </LinkTo>
{{/if}} {{/if}}
@ -20,17 +19,16 @@
<div class="chat-thread-header__buttons"> <div class="chat-thread-header__buttons">
{{#if this.canChangeThreadSettings}} {{#if this.canChangeThreadSettings}}
<DButton <DButton
@action={{action this.openThreadSettings}} @action={{this.openThreadSettings}}
@class="btn-flat chat-thread-header__settings" @class="btn-flat chat-thread-header__settings"
@icon="cog" @icon="cog"
@title="chat.thread.settings" @title="chat.thread.settings"
/> />
{{/if}} {{/if}}
<LinkTo <LinkTo
class="chat-thread__close btn-flat btn btn-icon no-text" class="chat-thread__close btn-flat btn btn-icon no-text"
@route="chat.channel" @route="chat.channel"
@models={{@channel.routeModels}} @models={{@thread.channel.routeModels}}
title={{i18n "chat.thread.close"}} title={{i18n "chat.thread.close"}}
> >
{{d-icon "times"}} {{d-icon "times"}}

View File

@ -1,5 +1,4 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import I18n from "I18n";
import showModal from "discourse/lib/show-modal"; import showModal from "discourse/lib/show-modal";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import { action } from "@ember/object"; import { action } from "@ember/object";
@ -9,11 +8,7 @@ export default class ChatThreadHeader extends Component {
@service router; @service router;
get label() { get label() {
if (this.args.thread) { return this.args.thread.escapedTitle;
return this.args.thread.escapedTitle;
} else {
return I18n.t("chat.threads.list");
}
} }
get canChangeThreadSettings() { 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> <Styleguide::Component>
<Chat::Thread::ListItem @thread={{this.thread}} /> <Chat::ThreadList::Item @thread={{this.thread}} />
</Styleguide::Component> </Styleguide::Component>
</StyleguideExample> </StyleguideExample>

View File

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

View File

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

View File

@ -45,21 +45,35 @@ export default class TextareaInteractor extends EmberObject.extend(
this._textarea.dispatchEvent(event); this._textarea.dispatchEvent(event);
} }
focus(opts = { ensureAtEnd: false, refreshHeight: true }) { blur() {
next(() => { next(() => {
if (opts.refreshHeight) { schedule("afterRender", () => {
this.refreshHeight(); this._textarea.blur();
} });
});
}
if (opts.ensureAtEnd) { focus(opts = { ensureAtEnd: false, refreshHeight: true, addText: null }) {
this.ensureCaretAtEnd(); next(() => {
} schedule("afterRender", () => {
if (opts.refreshHeight) {
this.refreshHeight();
}
if (this.capabilities.isIpadOS || this.site.mobileView) { if (opts.ensureAtEnd) {
return; 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.staged = true;
message.draft = false; message.draft = false;
message.createdAt ??= moment.utc().format(); message.createdAt ??= moment.utc().format();
message.channel = this;
await message.cook(); await message.cook();
if (message.inReplyTo) { if (message.inReplyTo) {

View File

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

View File

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

View File

@ -4,7 +4,7 @@ import { action } from "@ember/object";
export default class ChatChannelThreads extends DiscourseRoute { export default class ChatChannelThreads extends DiscourseRoute {
@service router; @service router;
@service chatChannelThreadListPane; @service chatThreadListPane;
@service chatStateManager; @service chatStateManager;
beforeModel(transition) { beforeModel(transition) {
@ -21,12 +21,12 @@ export default class ChatChannelThreads extends DiscourseRoute {
@action @action
willTransition(transition) { willTransition(transition) {
if (transition.targetName !== "chat.channel.thread") { if (transition.targetName === "chat.channel.index") {
this.chatChannelThreadListPane.close(); this.chatStateManager.closeSidePanel();
} }
} }
activate() { 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 { action } from "@ember/object";
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 { tracked } from "@glimmer/tracking";
export default class ChatChannelComposer extends ChatComposer { export default class ChatChannelComposer extends Service {
@service chat; @service chat;
@service currentUser;
@service router; @service router;
@service siteSettings;
@service("chat-thread-composer") threadComposer;
@tracked message;
@tracked textarea;
@action @action
cancelEditing() { focus(options = {}) {
this.reset(this.message.channel); this.textarea.focus(options);
}
@action
blur() {
this.textarea.blur();
} }
@action @action
@ -20,26 +31,47 @@ export default class ChatChannelComposer extends ChatComposer {
} }
@action @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; this.chat.activeMessage = null;
const channel = message.channel;
if ( if (
this.siteSettings.enable_experimental_chat_threaded_discussions && this.siteSettings.enable_experimental_chat_threaded_discussions &&
channel.threadingEnabled message.channel.threadingEnabled
) { ) {
let thread; if (!message.thread?.id) {
if (message.thread?.id) { message.thread = message.channel.createStagedThread(message);
thread = message.thread;
} else {
thread = channel.createStagedThread(message);
message.thread = thread;
} }
this.reset(channel); this.reset(message.channel);
this.router.transitionTo("chat.channel.thread", ...thread.routeModels);
await this.router.transitionTo(
"chat.channel.thread",
...message.thread.routeModels
);
this.threadComposer.focus({ ensureAtEnd: true, refreshHeight: true });
} else { } else {
this.message.inReplyTo = message; 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"; import Service, { inject as service } from "@ember/service";
export default class ChatChannelPane extends Service { export default class ChatChannelPane extends Service {
@service appEvents;
@service chat; @service chat;
@service chatChannelComposer;
@service chatApi;
@service chatComposerPresenceManager;
@tracked reacting = false; @tracked reacting = false;
@tracked selectingMessages = false; @tracked selectingMessages = false;
@ -18,10 +14,6 @@ export default class ChatChannelPane extends Service {
return this.chat.activeChannel?.selectedMessages?.mapBy("id") || []; return this.chat.activeChannel?.selectedMessages?.mapBy("id") || [];
} }
get composerService() {
return this.chatChannelComposer;
}
get channel() { get channel() {
return this.chat.activeChannel; return this.chat.activeChannel;
} }
@ -35,6 +27,7 @@ export default class ChatChannelPane extends Service {
}); });
} }
@action
onSelectMessage(message) { onSelectMessage(message) {
this.lastSelectedMessage = message; this.lastSelectedMessage = message;
this.selectingMessages = true; 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 * Handles subscriptions for MessageBus messages sent from Chat::Publisher
* to the channel and thread panes. There are individual services for * 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 * that implement their own logic where necessary. Functions which will
* always be different between the two raise a "not implemented" error in * always be different between the two raise a "not implemented" error in
* the base class, and the child class must define the associated function, * 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 ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
import ChatPaneBaseSubscriptionsManager from "./chat-pane-base-subscriptions-manager"; import ChatPaneBaseSubscriptionsManager from "./chat-pane-base-subscriptions-manager";
export default class ChatChannelThreadPaneSubscriptionsManager extends ChatPaneBaseSubscriptionsManager { export default class ChatThreadPaneSubscriptionsManager extends ChatPaneBaseSubscriptionsManager {
get messageBusChannel() { get messageBusChannel() {
return `/chat/${this.model.channel.id}/thread/${this.model.id}`; return `/chat/${this.model.channel.id}/thread/${this.model.id}`;
} }
@ -21,10 +21,8 @@ export default class ChatChannelThreadPaneSubscriptionsManager extends ChatPaneB
} }
} }
const message = ChatMessage.create( const message = ChatMessage.create(this.model.channel, data.chat_message);
this.chat.activeChannel, message.thread = this.model;
data.chat_message
);
this.messagesManager.addMessages([message]); 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 presence;
@service router; @service router;
@service site; @service site;
@service chatChannelsManager; @service chatChannelsManager;
@service chatChannelPane;
@service chatChannelThreadPane;
@service chatTrackingStateManager; @service chatTrackingStateManager;
cook = null; 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-composer-separator";
@import "chat-thread-header-buttons"; @import "chat-thread-header-buttons";
@import "chat-thread-header"; @import "chat-thread-header";
@import "chat-thread-list-header";
@import "chat-thread-unread-indicator"; @import "chat-thread-unread-indicator";

View File

@ -547,7 +547,7 @@ en:
thread: thread:
title: "Title" title: "Title"
view_thread: View thread view_thread: View thread
default_title: "Thread #%{thread_id}" default_title: "Thread"
replies: replies:
one: "%{count} reply" one: "%{count} reply"
other: "%{count} replies" 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!(:channel_1) { Fabricate(:chat_channel, threading_enabled: true) }
fab!(:current_user) { Fabricate(:admin) } fab!(:current_user) { Fabricate(:admin) }
fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel_1) } 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(:chat_page) { PageObjects::Pages::Chat.new }
let(:thread_page) { PageObjects::Pages::ChatThread.new } let(:thread_page) { PageObjects::Pages::ChatThread.new }
let(:side_panel_page) { PageObjects::Pages::ChatSidePanel.new }
before do before do
SiteSetting.enable_experimental_chat_threaded_discussions = true SiteSetting.enable_experimental_chat_threaded_discussions = true
@ -15,8 +17,19 @@ RSpec.describe "Chat | composer | shortcuts | thread", type: :system do
sign_in(current_user) sign_in(current_user)
end 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 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 } let(:last_thread_message) { thread_1.replies.last }
context "when there are editable messages" do 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) sign_in(current_user)
end 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 context "when adding an emoji through the picker" do
xit "adds the emoji to the composer" do xit "adds the emoji to the composer" do
chat_page.visit_channel(channel_1) chat_page.visit_channel(channel_1)
@ -169,32 +95,6 @@ RSpec.describe "Chat composer", type: :system do
end end
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 context "when editing a message with no length" do
it "deletes the message" do it "deletes the message" do
chat_page.visit_channel(channel_1) chat_page.visit_channel(channel_1)
@ -211,10 +111,9 @@ RSpec.describe "Chat composer", type: :system do
it "works" do it "works" do
chat_page.visit_channel(channel_1) chat_page.visit_channel(channel_1)
find("body").send_keys("1") channel_page.send_message("1")
channel_page.click_send_message
expect(channel_page).to have_message(text: "1") expect(channel_page.messages).to have_message(text: "1")
end end
end end

View File

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

View File

@ -122,6 +122,17 @@ module PageObjects
text: I18n.t("js.chat.deleted", count: count), text: I18n.t("js.chat.deleted", count: count),
) )
end 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 end
end end

View File

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

View File

@ -21,8 +21,17 @@ module PageObjects
selectors += "[data-id=\"#{args[:id]}\"]" if args[:id] selectors += "[data-id=\"#{args[:id]}\"]" if args[:id]
selectors += "[data-action=\"#{args[:action]}\"]" if args[:action] selectors += "[data-action=\"#{args[:action]}\"]" if args[:action]
selector_method = args[:does_not_exist] ? :has_no_selector? : :has_selector? 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 end
def has_no_message?(**args) def has_no_message?(**args)