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:
parent
5fc1586abf
commit
e6c6c342d9
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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}}
|
||||
/>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}}
|
|
@ -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)
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
@ -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() {
|
|
@ -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}}
|
|
@ -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"}}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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}}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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]);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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]);
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -1 +1 @@
|
|||
<Chat::Thread::List @channel={{this.model}} @includeHeader={{true}} />
|
||||
<Chat::ThreadList @channel={{this.model}} @includeHeader={{true}} />
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue