diff --git a/app/assets/javascripts/discourse/app/components/emoji-picker.js b/app/assets/javascripts/discourse/app/components/emoji-picker.js index f1da2a1dd17..14657f390dc 100644 --- a/app/assets/javascripts/discourse/app/components/emoji-picker.js +++ b/app/assets/javascripts/discourse/app/components/emoji-picker.js @@ -277,7 +277,7 @@ export default Component.extend({ if (fromTopicComposer) { document.querySelector(".d-editor-input")?.focus(); } else if (fromChatComposer) { - document.querySelector(".chat-composer-input")?.focus(); + document.querySelector(".chat-composer__input")?.focus(); } else { document.querySelector("textarea")?.focus(); } diff --git a/app/assets/javascripts/discourse/app/lib/autocomplete.js b/app/assets/javascripts/discourse/app/lib/autocomplete.js index 6c4bfc1a2be..dbd999a65e2 100644 --- a/app/assets/javascripts/discourse/app/lib/autocomplete.js +++ b/app/assets/javascripts/discourse/app/lib/autocomplete.js @@ -216,7 +216,7 @@ export default function (options) { }); } - let completeTerm = async function (term) { + let completeTerm = async function (term, event) { let completeEnd = null; if (term) { @@ -228,7 +228,7 @@ export default function (options) { addInputSelectedItem(term, true); } else { if (options.transformComplete) { - term = await options.transformComplete(term); + term = await options.transformComplete(term, event); } if (term) { @@ -272,7 +272,7 @@ export default function (options) { setCaretPosition(me[0], newCaretPos); if (options && options.afterComplete) { - options.afterComplete(text); + options.afterComplete(text, event); } } } @@ -371,7 +371,7 @@ export default function (options) { } else { selectedOption = -1; } - ul.find("li").click(function () { + ul.find("li").click(function ({ originalEvent }) { selectedOption = ul.find("li").index(this); // hack for Gboard, see meta.discourse.org/t/-/187009/24 if (autocompleteOptions == null) { @@ -379,13 +379,13 @@ export default function (options) { const forcedAutocompleteOptions = dataSource(prevTerm, opts); forcedAutocompleteOptions?.then((data) => { updateAutoComplete(data); - completeTerm(autocompleteOptions[selectedOption]); + completeTerm(autocompleteOptions[selectedOption], originalEvent); if (!options.single) { me.focus(); } }); } else { - completeTerm(autocompleteOptions[selectedOption]); + completeTerm(autocompleteOptions[selectedOption], originalEvent); if (!options.single) { me.focus(); } @@ -710,7 +710,7 @@ export default function (options) { selectedOption >= 0 && (userToComplete = autocompleteOptions[selectedOption]) ) { - completeTerm(userToComplete); + completeTerm(userToComplete, e); } else { // We're cancelling it, really. return true; diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel.hbs similarity index 84% rename from plugins/chat/assets/javascripts/discourse/components/chat-live-pane.hbs rename to plugins/chat/assets/javascripts/discourse/components/chat-channel.hbs index 5681ea0b925..083ce749674 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel.hbs @@ -1,10 +1,11 @@
{{#if this.loadedOnce}} {{#each @channel.messages key="id" as |message|}} @@ -78,15 +79,15 @@ /> {{else}} {{#if (or @channel.isDraft @channel.isFollowing)}} - {{else}} {{/if}} {{/if}} + +
\ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel.js similarity index 91% rename from plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js rename to plugins/chat/assets/javascripts/discourse/components/chat-channel.js index 37b91e116b9..521ca5c12e8 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel.js @@ -1,7 +1,5 @@ import { capitalize } from "@ember/string"; -import { cloneJSON } from "discourse-common/lib/object"; import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; -import ChatMessageDraft from "discourse/plugins/chat/discourse/models/chat-message-draft"; import Component from "@glimmer/component"; import { bind, debounce } from "discourse-common/utils/decorators"; import { action } from "@ember/object"; @@ -47,12 +45,13 @@ export default class ChatLivePane extends Component { @tracked loading = false; @tracked loadingMorePast = false; @tracked loadingMoreFuture = false; - @tracked sendingLoading = false; + @tracked sending = false; @tracked showChatQuoteSuccess = false; @tracked includeHeader = true; @tracked hasNewMessages = false; @tracked needsArrow = false; @tracked loadedOnce = false; + @tracked uploadDropZone; scrollable = null; _loadedChannelId = null; @@ -60,6 +59,11 @@ export default class ChatLivePane extends Component { _unreachableGroupMentions = []; _overMembersLimitGroupMentions = []; + @action + setUploadDropZone(element) { + this.uploadDropZone = element; + } + @action setScrollable(element) { this.scrollable = element; @@ -107,7 +111,12 @@ export default class ChatLivePane extends Component { if (this._loadedChannelId !== this.args.channel?.id) { this._unsubscribeToUpdates(this._loadedChannelId); this.chatChannelPane.selectingMessages = false; - this.chatChannelComposer.cancelEditing(); + this.chatChannelComposer.message = + this.args.channel.draft || + ChatMessage.createDraftMessage(this.args.channel, { + user: this.currentUser, + }); + this._loadedChannelId = this.args.channel?.id; } @@ -568,15 +577,41 @@ export default class ChatLivePane extends Component { } @action - sendMessage(message, uploads = []) { - resetIdle(); - - if (this.chatChannelPane.sendingLoading) { - return; + onSendMessage(message) { + if (message.editing) { + this.#sendEditMessage(message); + } else { + this.#sendNewMessage(message); } + } - this.chatChannelPane.sendingLoading = true; - this.args.channel.draft = ChatMessageDraft.create(); + @action + resetComposer() { + this.chatChannelComposer.reset(this.args.channel); + } + + #sendEditMessage(message) { + this.chatChannelPane.sending = true; + + const data = { + new_message: message.message, + upload_ids: message.uploads.map((upload) => upload.id), + }; + + this.resetComposer(); + + return this.chatApi + .editMessage(this.args.channel.id, message.id, data) + .catch(popupAjaxError) + .finally(() => { + this.chatChannelPane.sending = false; + }); + } + + #sendNewMessage(message) { + this.chatChannelPane.sending = true; + + resetIdle(); // TODO: all send message logic is due for massive refactoring // This is all the possible case Im currently aware of @@ -587,52 +622,38 @@ export default class ChatLivePane extends Component { // - message to a public channel you were tracking (preview = false, not draft) // - message to a channel when we haven't loaded all future messages yet. if (!this.args.channel.isFollowing || this.args.channel.isDraft) { - this.loading = true; + const data = { + message: message.message, + upload_ids: message.uploads.map((upload) => upload.id), + }; - return this._upsertChannelWithMessage( - this.args.channel, - message, - uploads - ).finally(() => { - if (this._selfDeleted) { - return; + this.resetComposer(); + + return this._upsertChannelWithMessage(this.args.channel, data).finally( + () => { + if (this._selfDeleted) { + return; + } + this.chatChannelPane.sending = false; + this.scrollToLatestMessage(); } - this.loading = false; - this.chatChannelPane.sendingLoading = false; - this.chatChannelPane.resetAfterSend(); - this.scrollToLatestMessage(); - }); + ); } - const stagedMessage = ChatMessage.createStagedMessage(this.args.channel, { - message, - created_at: moment.utc().format(), - uploads: cloneJSON(uploads), - user: this.currentUser, - }); + this.args.channel.stageMessage(message); + const stagedMessage = message; + this.resetComposer(); - if (this.chatChannelComposer.replyToMsg) { - stagedMessage.inReplyTo = this.chatChannelComposer.replyToMsg; - } - - if (stagedMessage.inReplyTo) { - if (!this.args.channel.threadingEnabled) { - this.#messagesManager.addMessages([stagedMessage]); - } - } else { - this.#messagesManager.addMessages([stagedMessage]); - } - - if (!this.#messagesManager.canLoadMoreFuture) { + if (!this.args.channel.canLoadMoreFuture) { this.scrollToLatestMessage(); } return this.chatApi .sendMessage(this.args.channel.id, { - message: stagedMessage.message, - in_reply_to_id: stagedMessage.inReplyTo?.id, - staged_id: stagedMessage.id, - upload_ids: stagedMessage.uploads.map((upload) => upload.id), + message: message.message, + in_reply_to_id: message.inReplyTo?.id, + staged_id: message.id, + upload_ids: message.uploads.map((upload) => upload.id), }) .then(() => { this.scrollToLatestMessage(); @@ -645,12 +666,13 @@ export default class ChatLivePane extends Component { if (this._selfDeleted) { return; } - this.chatChannelPane.sendingLoading = false; - this.chatChannelPane.resetAfterSend(); + + this.args.channel.draft = null; + this.chatChannelPane.sending = false; }); } - async _upsertChannelWithMessage(channel, message, uploads) { + async _upsertChannelWithMessage(channel, data) { let promise = Promise.resolve(channel); if (channel.isDirectMessageChannel || channel.isDraft) { @@ -662,11 +684,9 @@ export default class ChatLivePane extends Component { return promise.then((c) => ajax(`/chat/${c.id}.json`, { type: "POST", - data: { - message, - upload_ids: (uploads || []).mapBy("id"), - }, + data, }).then(() => { + this.chatChannelPane.sending = false; this.router.transitionTo("chat.channel", "-", c.id); }) ); @@ -686,12 +706,12 @@ export default class ChatLivePane extends Component { } } - this.chatChannelPane.resetAfterSend(); + this.resetComposer(); } @action resendStagedMessage(stagedMessage) { - this.chatChannelPane.sendingLoading = true; + this.chatChannelPane.sending = true; stagedMessage.error = null; @@ -714,7 +734,7 @@ export default class ChatLivePane extends Component { if (this._selfDeleted) { return; } - this.chatChannelPane.sendingLoading = false; + this.chatChannelPane.sending = false; }); } @@ -824,7 +844,7 @@ export default class ChatLivePane extends Component { return; } - const composer = document.querySelector(".chat-composer-input"); + const composer = document.querySelector(".chat-composer__input"); if (composer && !this.args.channel.isDraft) { composer.focus(); return; @@ -836,7 +856,7 @@ export default class ChatLivePane extends Component { @action computeDatesSeparators() { - throttle(this, this._computeDatesSeparators, 50, false); + throttle(this, this._computeDatesSeparators, 50, true); } // A more consistent way to scroll to the bottom when we are sure this is our goal diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-message-details.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-composer-message-details.hbs index 37763906d32..52e76b40c5d 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer-message-details.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-message-details.hbs @@ -1,16 +1,16 @@
- {{d-icon this.icon}} - - {{this.message.user.username}} + {{d-icon @icon}} + + {{@message.user.username}} - {{replace-emoji this.message.excerpt}} + {{replace-emoji @message.excerpt}}
- diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-message-details.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer-message-details.js index 44494409ab5..dc169dc4936 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer-message-details.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-message-details.js @@ -1,5 +1,3 @@ -import Component from "@ember/component"; +import Component from "@glimmer/component"; -export default Component.extend({ - tagName: "", -}); +export default class ChatComposerMessageDetails extends Component {} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-upload.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-composer-upload.hbs index bf1f84e5c6e..b5bbfb7130b 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer-upload.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-upload.hbs @@ -1,44 +1,57 @@ - - - {{#if (eq this.type this.IMAGE_TYPE)}} - {{#if this.isDone}} - - {{else}} - {{d-icon "far-image"}} - {{/if}} - {{else}} - {{d-icon "file-alt"}} - {{/if}} - - - -
- {{this.fileName}} - -
- -
- {{#if this.isDone}} - {{this.upload.extension}} - {{else}} - {{#if this.upload.processing}} - {{i18n "processing"}} +{{#if @upload}} +
+
+ {{#if this.isImage}} + {{#if @isDone}} + {{else}} - {{i18n "uploading"}} + {{d-icon "far-image"}} {{/if}} - - + {{else}} + {{d-icon "file-alt"}} {{/if}}
- - \ No newline at end of file + + + {{#unless this.isImage}} +
+ {{this.fileName}} +
+ {{/unless}} + +
+ {{#if @isDone}} + {{#unless this.isImage}} + {{@upload.extension}} + {{/unless}} + {{else}} + {{#if @upload.processing}} + {{i18n "processing"}} + {{else}} + {{i18n "uploading"}} + {{/if}} + + + {{/if}} +
+
+ + +
+{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-upload.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer-upload.js index a2a89a2119c..ca2280cf67a 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer-upload.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-upload.js @@ -1,25 +1,16 @@ -import Component from "@ember/component"; -import discourseComputed from "discourse-common/utils/decorators"; +import Component from "@glimmer/component"; import { isImage } from "discourse/lib/uploads"; -export default Component.extend({ - IMAGE_TYPE: "image", +export default class ChatComposerUpload extends Component { + get isImage() { + return isImage( + this.args.upload.original_filename || this.args.upload.fileName + ); + } - tagName: "", - classNames: "chat-upload", - isDone: false, - upload: null, - onCancel: null, - - @discourseComputed("upload.{original_filename,fileName}") - type(upload) { - if (isImage(upload.original_filename || upload.fileName)) { - return this.IMAGE_TYPE; - } - }, - - @discourseComputed("isDone", "upload.{original_filename,fileName}") - fileName(isDone, upload) { - return isDone ? upload.original_filename : upload.fileName; - }, -}); + get fileName() { + return this.args.isDone + ? this.args.upload.original_filename + : this.args.upload.fileName; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.hbs index 8bb20bf6d23..2f07ecc996f 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.hbs @@ -21,19 +21,4 @@ @allowMultiple={{true}} @fileInputId={{this.fileUploadElementId}} @fileInputClass="hidden-upload-field" -/> - -
-
-
- {{d-icon "file-audio"}} - {{d-icon "file-video"}} - {{d-icon "file-image"}} -
- -

- {{d-icon "upload"}} - Drop a file to upload it. -

-
-
\ No newline at end of file +/> \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.js index a6b7f6568fc..6ecd680cbb0 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-uploads.js @@ -16,6 +16,7 @@ export default Component.extend(UppyUploadMixin, { existingUploads: null, uploads: null, useMultipartUploadsIfAvailable: true, + uploadDropZone: null, init() { this._super(...arguments); @@ -38,7 +39,7 @@ export default Component.extend(UppyUploadMixin, { didInsertElement() { this._super(...arguments); - this.composerInputEl = document.querySelector(".chat-composer-input"); + this.composerInputEl = document.querySelector(".chat-composer__input"); this.composerInputEl?.addEventListener("paste", this._pasteEventListener); }, @@ -77,19 +78,8 @@ export default Component.extend(UppyUploadMixin, { }, _uploadDropTargetOptions() { - let targetEl; - if (this.chatStateManager.isFullPageActive) { - targetEl = document.querySelector(".full-page-chat"); - } else { - targetEl = document.querySelector(".chat-drawer.is-expanded"); - } - - if (!targetEl) { - return this._super(); - } - return { - target: targetEl, + target: this.uploadDropZone || document.body, }; }, diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-composer.hbs index fe10dcbcec8..effa6885436 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer.hbs @@ -1,93 +1,105 @@ -{{#if this.composerService.replyToMsg}} - -{{/if}} - -{{#if this.composerService.editingMessage}} - -{{/if}} - -
- - - - - - {{#if this.isNetworkUnreliable}} - - {{d-icon "exclamation-circle"}} - +{{! template-lint-disable no-down-event-binding }} +
+ {{#if this.shouldRenderMessageDetails}} + {{/if}} - - - {{#unless this.disableComposer}} - - {{/unless}} -
- -{{#if this.canAttachUploads}} - -{{/if}} + {{did-update this.didUpdateMessage this.currentMessage}} + {{did-update this.didUpdateInReplyTo this.currentMessage.inReplyTo}} + {{did-insert this.setupAppEvents}} + {{will-destroy this.teardownAppEvents}} + > +
+
+ -{{#unless this.chatChannel.isDraft}} -
- +
+ +
+ + + + {{#unless this.disabled}} + + {{/unless}} +
+ +
-{{/unless}} - \ No newline at end of file + {{#if this.canAttachUploads}} + + {{/if}} + + {{#if this.shouldRenderReplyingIndicator}} +
+ +
+ {{/if}} + + +
\ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer.js index a3236f394c6..ead10d17c57 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer.js @@ -1,172 +1,268 @@ -import { isEmpty } from "@ember/utils"; -import Component from "@ember/component"; -import showModal from "discourse/lib/show-modal"; -import discourseComputed, { - afterRender, - bind, -} from "discourse-common/utils/decorators"; -import I18n from "I18n"; -import TextareaTextManipulation from "discourse/mixins/textarea-text-manipulation"; -import userSearch from "discourse/lib/user-search"; +import Component from "@glimmer/component"; import { action } from "@ember/object"; -import { cancel, next, schedule, throttle } from "@ember/runloop"; +import { inject as service } from "@ember/service"; +import { tracked } from "@glimmer/tracking"; +import { cancel, next } from "@ember/runloop"; import { cloneJSON } from "discourse-common/lib/object"; +import { chatComposerButtons } from "discourse/plugins/chat/discourse/lib/chat-composer-buttons"; +import showModal from "discourse/lib/show-modal"; +import TextareaInteractor from "discourse/plugins/chat/discourse/lib/textarea-interactor"; +import { getOwner } from "discourse-common/lib/get-owner"; +import userSearch from "discourse/lib/user-search"; import { findRawTemplate } from "discourse-common/lib/raw-templates"; import { emojiSearch, isSkinTonableEmoji } from "pretty-text/emoji"; import { emojiUrlFor } from "discourse/lib/text"; -import { inject as service } from "@ember/service"; -import { reads } from "@ember/object/computed"; import { SKIP } from "discourse/lib/autocomplete"; -import { Promise } from "rsvp"; +import I18n from "I18n"; import { translations } from "pretty-text/emoji/data"; import { setupHashtagAutocomplete } from "discourse/lib/hashtag-autocomplete"; -import { - chatComposerButtons, - chatComposerButtonsDependentKeys, -} from "discourse/plugins/chat/discourse/lib/chat-composer-buttons"; +import { isEmpty, isPresent } from "@ember/utils"; -const THROTTLE_MS = 150; +export default class ChatComposer extends Component { + @service capabilities; + @service site; + @service siteSettings; + @service chat; + @service chatComposerPresenceManager; + @service chatComposerWarningsTracker; + @service appEvents; + @service chatEmojiReactionStore; + @service chatEmojiPickerManager; + @service currentUser; + @service chatApi; -export default Component.extend(TextareaTextManipulation, { - chatChannel: null, - chat: service(), - classNames: ["chat-composer-container"], - classNameBindings: ["emojiPickerVisible:with-emoji-picker"], - chatEmojiReactionStore: service("chat-emoji-reaction-store"), - chatEmojiPickerManager: service("chat-emoji-picker-manager"), - chatStateManager: service("chat-state-manager"), - timer: null, - value: "", - inProgressUploads: null, - composerEventPrefix: "chat", - composerFocusSelector: ".chat-composer-input", - canAttachUploads: reads("siteSettings.chat_allow_uploads"), - isNetworkUnreliable: reads("chat.isNetworkUnreliable"), - typingMention: false, - chatComposerWarningsTracker: service(), + @tracked isFocused = false; - @discourseComputed(...chatComposerButtonsDependentKeys()) - inlineButtons() { + get shouldRenderReplyingIndicator() { + return this.context === "channel" && !this.args.channel?.isDraft; + } + + get shouldRenderMessageDetails() { + return this.currentMessage?.editing || this.currentMessage?.inReplyTo; + } + + get inlineButtons() { return chatComposerButtons(this, "inline", this.context); - }, + } - @discourseComputed(...chatComposerButtonsDependentKeys()) - dropdownButtons() { + get dropdownButtons() { return chatComposerButtons(this, "dropdown", this.context); - }, + } - @discourseComputed("chatEmojiPickerManager.{opened,context}") - emojiPickerVisible(picker) { - return picker.opened && picker.context === "chat-composer"; - }, + get fileUploadElementId() { + return this.context + "-file-uploader"; + } - @discourseComputed("chatStateManager.isFullPageActive") - fileUploadElementId(fullPage) { - return fullPage ? "chat-full-page-uploader" : "chat-widget-uploader"; - }, - - init() { - this._super(...arguments); - - this.appEvents.on( - "upload-mixin:chat-composer-uploader:in-progress-uploads", - this, - "_inProgressUploadsChanged" + get canAttachUploads() { + return ( + this.siteSettings.chat_allow_uploads && + isPresent(this.args.uploadDropZone) ); + } - this.setProperties({ - inProgressUploads: [], - _uploads: [], - }); + get disabled() { + return ( + (this.args.channel.isDraft && + isEmpty(this.args.channel?.chatable?.users)) || + !this.chat.userCanInteractWithChat || + !this.args.channel.canModifyMessages(this.currentUser) + ); + } - this.composerService?.registerFocusHandler(() => { - this._focusTextArea(); - }); - }, + @action + persistDraft() {} - didInsertElement() { - this._super(...arguments); + @action + setupAutocomplete(textarea) { + const $textarea = $(textarea); + this.#applyUserAutocomplete($textarea); + this.#applyEmojiAutocomplete($textarea); + this.#applyCategoryHashtagAutocomplete($textarea); + } - this._textarea = this.element.querySelector(".chat-composer-input"); - this._$textarea = $(this._textarea); - this._applyUserAutocomplete(this._$textarea); - this._applyCategoryHashtagAutocomplete(this._$textarea); - this._applyEmojiAutocomplete(this._$textarea); - this.appEvents.on("chat:insert-text", this, "insertText"); - this._focusTextArea(); + @action + setupTextareaInteractor(textarea) { + this.textareaInteractor = new TextareaInteractor(getOwner(this), textarea); + } - this.appEvents.on("chat:modify-selection", this, "_modifySelection"); + @action + didUpdateMessage() { + cancel(this._persistHandler); + this.textareaInteractor.value = this.currentMessage.message || ""; + this.textareaInteractor.focus({ refreshHeight: true }); + } + + @action + didUpdateInReplyTo() { + this.textareaInteractor.focus({ ensureAtEnd: true, refreshHeight: true }); + this.persistDraft(); + } + + get currentMessage() { + return this.composer.message; + } + + get hasContent() { + const minLength = this.siteSettings.chat_minimum_message_length || 0; + return ( + this.currentMessage?.message?.length > minLength || + (this.canAttachUploads && this.currentMessage?.uploads?.length > 0) + ); + } + + get sendEnabled() { + return this.hasContent && !this.pane.sending; + } + + @action + setupAppEvents() { + this.appEvents.on("chat:modify-selection", this, "modifySelection"); this.appEvents.on( "chat:open-insert-link-modal", this, - "_openInsertLinkModal" + "openInsertLinkModal" ); - document.addEventListener("visibilitychange", this._blurInput); - document.addEventListener("resume", this._blurInput); - document.addEventListener("freeze", this._blurInput); + } - this.set("ready", true); - }, + @action + teardownAppEvents() { + this.appEvents.off("chat:modify-selection", this, "modifySelection"); + this.appEvents.off( + "chat:open-insert-link-modal", + this, + "openInsertLinkModal" + ); + } - _modifySelection(opts = { type: null, context: null }) { - if (opts.context !== this.context) { - return; - } - const sel = this.getSelected("", { lineVal: true }); - if (opts.type === "bold") { - this.applySurround(sel, "**", "**", "bold_text"); - } else if (opts.type === "italic") { - this.applySurround(sel, "_", "_", "italic_text"); - } else if (opts.type === "code") { - this.applySurround(sel, "`", "`", "code_text"); - } - }, - - _openInsertLinkModal() { - const selected = this.getSelected("", { lineVal: true }); - const linkText = selected?.value; - showModal("insert-hyperlink").setProperties({ - linkText, - toolbarEvent: { - addText: (text) => this.addText(selected, text), + @action + insertDiscourseLocalDate() { + showModal("discourse-local-dates-create-modal").setProperties({ + insertDate: (markup) => { + this.textareaInteractor.addText( + this.textareaInteractor.getSelected(), + markup + ); + this.textareaInteractor.focus(); }, }); - }, + } - willDestroyElement() { - this._super(...arguments); + @action + uploadClicked() { + document.querySelector(`#${this.fileUploadElementId}`).click(); + } - this.appEvents.off( - "upload-mixin:chat-composer-uploader:in-progress-uploads", - this, - "_inProgressUploadsChanged" - ); + @action + computeIsFocused(isFocused) { + next(() => { + this.isFocused = isFocused; + }); + } - cancel(this.timer); + @action + onInput(event) { + this.currentMessage.message = event.target.value; + this.textareaInteractor.refreshHeight(); + this.reportReplyingPresence(); + this.persistDraft(); + this.captureMentions(); + } - this.appEvents.off("chat:insert-text", this, "insertText"); - this.appEvents.off("chat:modify-selection", this, "_modifySelection"); - this.appEvents.off( - "chat:open-insert-link-modal", - this, - "_openInsertLinkModal" - ); - document.removeEventListener("visibilitychange", this._blurInput); - document.removeEventListener("resume", this._blurInput); - document.removeEventListener("freeze", this._blurInput); - }, + @action + onUploadChanged(uploads, { inProgressUploadsCount }) { + if ( + typeof uploads !== "undefined" && + inProgressUploadsCount !== "undefined" && + inProgressUploadsCount === 0 && + this.currentMessage + ) { + this.currentMessage.uploads = cloneJSON(uploads); + } - // It is important that this is keyDown and not keyUp, otherwise - // we add new lines to chat message on send and on edit, because - // you cannot prevent default with a keyUp event -- it is like trying - // to shut the gate after the horse has already bolted! - keyDown(event) { - if (this.site.mobileView || event.altKey || event.metaKey) { + this.textareaInteractor.focus(); + this.reportReplyingPresence(); + this.persistDraft(); + } + + @action + onSend() { + if (!this.sendEnabled) { return; } - // keyCode for 'Enter' - if (event.keyCode === 13) { + if (this.site.mobileView) { + // 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.args.onSendMessage(this.currentMessage); + this.textareaInteractor.focus({ refreshHeight: true }); + } + + @action + onCancel() { + this.composer.cancel(); + } + + reportReplyingPresence() { + if (this.args.channel.isDraft) { + return; + } + + this.chatComposerPresenceManager.notifyState( + this.args.channel.id, + !this.currentMessage.editing && this.hasContent + ); + } + + @action + modifySelection(event, options = { type: null, context: null }) { + if (options.context !== this.context) { + return; + } + + const sel = this.textareaInteractor.getSelected("", { lineVal: true }); + if (options.type === "bold") { + this.textareaInteractor.applySurround(sel, "**", "**", "bold_text"); + } else if (options.type === "italic") { + this.textareaInteractor.applySurround(sel, "_", "_", "italic_text"); + } else if (options.type === "code") { + this.textareaInteractor.applySurround(sel, "`", "`", "code_text"); + } + } + + @action + onTextareaFocusIn(textarea) { + if (!this.capabilities.isIOS) { + return; + } + + // hack to prevent the whole viewport + // to move on focus input + textarea = document.querySelector(".chat-composer__input"); + textarea.style.transform = "translateY(-99999px)"; + textarea.focus(); + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + textarea.style.transform = ""; + }); + }); + } + + @action + onKeyDown(event) { + if ( + this.site.mobileView || + event.altKey || + event.metaKey || + this.#isAutocompleteDisplayed() + ) { + return; + } + + if (event.key === "Enter") { if (event.shiftKey) { // Shift+Enter: insert newline return; @@ -175,224 +271,139 @@ export default Component.extend(TextareaTextManipulation, { // Ctrl+Enter, plain Enter: send if (!event.ctrlKey) { // if we are inside a code block just insert newline - const { pre } = this.getSelected(null, { lineVal: true }); - if (this.isInside(pre, /(^|\n)```/g)) { + const { pre } = this.textareaInteractor.getSelected({ lineVal: true }); + if (this.textareaInteractor.isInside(pre, /(^|\n)```/g)) { return; } } - this.sendClicked(); + this.onSend(); + event.preventDefault(); return false; } if ( event.key === "ArrowUp" && - this._messageIsEmpty() && - !this.composerService?.editingMessage + !this.hasContent && + !this.currentMessage.editing ) { - event.preventDefault(); - this.paneService?.editLastMessageRequested(); + const editableMessage = this.pane?.lastCurrentUserMessage; + if (editableMessage) { + this.composer.editMessage(editableMessage); + } } if (event.key === "Escape") { - if (this.composerService?.replyToMsg) { - this.set("value", ""); - this.composerService?.setReplyTo(null); + if (this.currentMessage?.inReplyTo) { + this.reset(); return false; - } else if (this.composerService?.editingMessage) { - this.cancelEditing(); + } else if (this.currentMessage?.editing) { + this.composer.onCancelEditing(); return false; } else { - this._textarea.blur(); + event.target.blur(); } } - }, - - didReceiveAttrs() { - this._super(...arguments); - - if ( - !this.composerService?.editingMessage && - this.chatChannel?.draft && - this.chatChannel?.canModifyMessages(this.currentUser) - ) { - // uses uploads from draft here... - this.set("value", this.chatChannel.draft.message); - this.composerService?.setReplyTo(this.chatChannel.draft.replyToMsg); - - this._captureMentions(); - this._syncUploads(this.chatChannel.draft.uploads); - } - - this.resizeTextarea(); - }, + } @action - updateEditingMessage() { - if ( - this.composerService?.editingMessage && - !this.paneService?.sendingLoading - ) { - this.set("value", this.composerService?.editingMessage.message); + reset() { + this.composer.reset(this.args.channel); + } - this.composerService?.setReplyTo(null); - - this._syncUploads(this.composerService?.editingMessage.uploads); - this._focusTextArea({ ensureAtEnd: true, resizeTextarea: false }); - } - }, - - // the chat-composer needs to be able to set the internal list of uploads - // for chat-composer-uploads to preload in existing uploads for drafts - // and for when messages are being edited. - // - // the opposite is true as well -- when an upload is completed the chat-composer - // needs its internal state updated so drafts can be saved, which is handled - // by the uploadsChanged action - _syncUploads(newUploads = []) { - const currentUploadIds = this._uploads.mapBy("id"); - const newUploadIds = newUploads.mapBy("id"); - - // don't need to load the uploads into chat-composer-uploads if - // nothing has changed otherwise we would rerender for no reason - if ( - currentUploadIds.length === newUploadIds.length && - newUploadIds.every((newUploadId) => - currentUploadIds.includes(newUploadId) - ) - ) { + @action + openInsertLinkModal(event, options = { context: null }) { + if (options.context !== this.context) { return; } - this.set("_uploads", cloneJSON(newUploads)); - }, - - _inProgressUploadsChanged(inProgressUploads) { - next(() => { - if (this.isDestroying || this.isDestroyed) { - return; - } - - this.set("inProgressUploads", inProgressUploads); + const selected = this.textareaInteractor.getSelected("", { lineVal: true }); + const linkText = selected?.value; + showModal("insert-hyperlink").setProperties({ + linkText, + toolbarEvent: { + addText: (text) => this.textareaInteractor.addText(selected, text), + }, }); - }, + } @action - onTextareaInput(value) { - this.set("value", value); - this.resizeTextarea(); - - this._captureMentions(); - - // throttle, not debounce, because we do eventually want to react during the typing - this.timer = throttle(this, this._handleTextareaInput, THROTTLE_MS); - }, - - @bind - _handleTextareaInput() { - this.composerService?.onComposerValueChange?.({ value: this.value }); - }, - - @bind - _captureMentions() { - if (this.value) { - this.chatComposerWarningsTracker.trackMentions(this.value); - } - }, - - @bind - _blurInput() { - document.activeElement?.blur(); - }, - - @action - uploadClicked() { - this.element.querySelector(`#${this.fileUploadElementId}`).click(); - }, - - @bind - didSelectEmoji(emoji) { + onSelectEmoji(emoji) { const code = `:${emoji}:`; this.chatEmojiReactionStore.track(code); - this.addText(this.getSelected(), code); + this.textareaInteractor.addText( + this.textareaInteractor.getSelected(), + code + ); if (this.site.desktopView) { - this._focusTextArea(); + this.textareaInteractor.focus(); } else { this.chatEmojiPickerManager.close(); } - }, + } @action - closeComposerDropdown() { - this.chatEmojiPickerManager.close(); - this.appEvents.trigger("d-popover:close"); - }, + captureMentions() { + if (this.hasContent) { + this.chatComposerWarningsTracker.trackMentions( + this.currentMessage.message + ); + } + } - @action - insertDiscourseLocalDate() { - showModal("discourse-local-dates-create-modal").setProperties({ - insertDate: (markup) => { - this.addText(this.getSelected(), markup); + #applyUserAutocomplete($textarea) { + if (!this.siteSettings.enable_mentions) { + return; + } + + $textarea.autocomplete({ + template: findRawTemplate("user-selector-autocomplete"), + key: "@", + width: "100%", + treatAsTextarea: true, + autoSelectFirstSuggestion: true, + transformComplete: (v) => v.username || v.name, + dataSource: (term) => { + return userSearch({ term, includeGroups: true }).then((result) => { + if (result?.users?.length > 0) { + const presentUserNames = + this.chat.presenceChannel.users?.mapBy("username"); + result.users.forEach((user) => { + if (presentUserNames.includes(user.username)) { + user.cssClasses = "is-online"; + } + }); + } + return result; + }); + }, + afterComplete: (text, event) => { + event.preventDefault(); + this.textareaInteractor.value = text; + this.textareaInteractor.focus(); + this.captureMentions(); }, }); - }, + } - // text-area-manipulation mixin override - addText() { - this._super(...arguments); - - this.resizeTextarea(); - }, - - _applyUserAutocomplete($textarea) { - if (this.siteSettings.enable_mentions) { - $textarea.autocomplete({ - template: findRawTemplate("user-selector-autocomplete"), - key: "@", - width: "100%", - treatAsTextarea: true, - autoSelectFirstSuggestion: true, - transformComplete: (v) => v.username || v.name, - dataSource: (term) => { - return userSearch({ term, includeGroups: true }).then((result) => { - if (result?.users?.length > 0) { - const presentUserNames = - this.chat.presenceChannel.users?.mapBy("username"); - result.users.forEach((user) => { - if (presentUserNames.includes(user.username)) { - user.cssClasses = "is-online"; - } - }); - } - return result; - }); - }, - afterComplete: (text) => { - this.set("value", text); - this._focusTextArea(); - this._captureMentions(); - }, - }); - } - }, - - _applyCategoryHashtagAutocomplete($textarea) { + #applyCategoryHashtagAutocomplete($textarea) { setupHashtagAutocomplete( this.site.hashtag_configurations["chat-composer"], $textarea, this.siteSettings, { treatAsTextarea: true, - afterComplete: (value) => { - this.set("value", value); - return this._focusTextArea(); + afterComplete: (text, event) => { + event.preventDefault(); + this.textareaInteractor.value = text; + this.textareaInteractor.focus(); }, } ); - }, + } - _applyEmojiAutocomplete($textarea) { + #applyEmojiAutocomplete($textarea) { if (!this.siteSettings.enable_emoji) { return; } @@ -400,12 +411,12 @@ export default Component.extend(TextareaTextManipulation, { $textarea.autocomplete({ template: findRawTemplate("emoji-selector-autocomplete"), key: ":", - afterComplete: (text) => { - this.set("value", text); - this._focusTextArea(); + afterComplete: (text, event) => { + event.preventDefault(); + this.textareaInteractor.value = text; + this.textareaInteractor.focus(); }, treatAsTextarea: true, - onKeyUp: (text, cp) => { const matches = /(?:^|[\s.\?,@\/#!%&*;:\[\]{}=\-_()])(:(?!:).?[\w-]*:?(?!:)(?:t\d?)?:?) ?$/gi.exec( @@ -416,7 +427,6 @@ export default Component.extend(TextareaTextManipulation, { return [matches[1]]; } }, - transformComplete: (v) => { if (v.code) { this.chatEmojiReactionStore.track(v.code); @@ -430,7 +440,6 @@ export default Component.extend(TextareaTextManipulation, { return ""; } }, - dataSource: (term) => { return new Promise((resolve) => { const full = `:${term}`; @@ -472,8 +481,7 @@ export default Component.extend(TextareaTextManipulation, { // note this will only work for emojis starting with : // eg: :-) - const emojiTranslation = - this.get("site.custom_emoji_translation") || {}; + const emojiTranslation = this.site.custom_emoji_translation || {}; const allTranslations = Object.assign( {}, translations, @@ -483,7 +491,7 @@ export default Component.extend(TextareaTextManipulation, { return resolve([allTranslations[full]]); } - const emojiDenied = this.get("site.denied_emojis") || []; + const emojiDenied = this.site.denied_emojis || []; const match = term.match(/^:?(.*?):t([2-6])?$/); if (match) { const name = match[1]; @@ -520,264 +528,9 @@ export default Component.extend(TextareaTextManipulation, { }); }, }); - }, + } - @afterRender - _focusTextArea(opts = { ensureAtEnd: false, resizeTextarea: true }) { - if (this.chatChannel.isDraft) { - return; - } - - if (!this._textarea) { - return; - } - - if (opts.resizeTextarea) { - this.resizeTextarea(); - } - - if (opts.ensureAtEnd) { - this._textarea.setSelectionRange(this.value.length, this.value.length); - } - - if (this.capabilities.isIpadOS || this.site.mobileView) { - return; - } - - schedule("afterRender", () => { - this._textarea?.focus(); - }); - }, - - @action - onEmojiSelected(code) { - this.emojiSelected(code); - this.set("emojiPickerIsActive", false); - }, - - @discourseComputed( - "chatChannel.{id,chatable.users.[]}", - "chat.userCanInteractWithChat" - ) - disableComposer(channel, userCanInteractWithChat) { - return ( - (channel.isDraft && isEmpty(channel?.chatable?.users)) || - !userCanInteractWithChat || - !channel.canModifyMessages(this.currentUser) - ); - }, - - @discourseComputed( - "chatChannel.{chatable.users.[],id}", - "chat.userCanInteractWithChat" - ) - placeholder(chatChannel, userCanInteractWithChat) { - if (!chatChannel.canModifyMessages(this.currentUser)) { - return I18n.t( - `chat.placeholder_new_message_disallowed.${chatChannel.status}` - ); - } - - if (chatChannel.isDraft) { - if (chatChannel?.chatable?.users?.length) { - return I18n.t("chat.placeholder_start_conversation_users", { - commaSeparatedUsernames: chatChannel.chatable.users - .mapBy("username") - .join(I18n.t("word_connector.comma")), - }); - } else { - return I18n.t("chat.placeholder_start_conversation"); - } - } - - if (!userCanInteractWithChat) { - return I18n.t("chat.placeholder_silenced"); - } else { - return this.messageRecipient(chatChannel); - } - }, - - messageRecipient(chatChannel) { - if (chatChannel.isDirectMessageChannel) { - const directMessageRecipients = chatChannel.chatable.users; - if ( - directMessageRecipients.length === 1 && - directMessageRecipients[0].id === this.currentUser.id - ) { - return I18n.t("chat.placeholder_self"); - } - - return I18n.t("chat.placeholder_users", { - commaSeparatedNames: directMessageRecipients - .map((u) => u.name || `@${u.username}`) - .join(I18n.t("word_connector.comma")), - }); - } else { - return I18n.t("chat.placeholder_channel", { - channelName: `#${chatChannel.title}`, - }); - } - }, - - @discourseComputed( - "value", - "paneService.sendingLoading", - "disableComposer", - "inProgressUploads.[]" - ) - sendDisabled(value, loading, disableComposer, inProgressUploads) { - if (loading || disableComposer || inProgressUploads.length > 0) { - return true; - } - - return !this._messageIsValid(); - }, - - @action - sendClicked() { - if (this.site.mobileView) { - // prevents android to hide the keyboard after sending a message - // we do a focusTextarea later but it's too late for android - document.querySelector(this.composerFocusSelector).focus(); - } - - if (this.sendDisabled) { - return; - } - - this.composerService?.editingMessage - ? this.internalEditMessage() - : this.internalSendMessage(); - }, - - @action - internalSendMessage() { - // FIXME: This is fairly hacky, we should have a nicer - // flow and relationship between the panes for resetting - // the value here on send. - const _previousValue = this.value; - this.set("value", ""); - return this.sendMessage(_previousValue, this._uploads) - .then(this.reset) - .catch(() => { - this.set("value", _previousValue); - }); - }, - - @action - internalEditMessage() { - return this.paneService - ?.editMessage(this.value, this._uploads) - .then(this.reset); - }, - - _messageIsValid() { - const validLength = - (this.value || "").trim().length >= - (this.siteSettings.chat_minimum_message_length || 0); - - if (this.canAttachUploads) { - if (this._messageIsEmpty()) { - // If message is empty, an an upload must present for sending to be enabled - return this._uploads.length; - } else { - // Message is non-empty. Make sure it's long enough to be valid. - return validLength; - } - } - - // Attachments are disabled so for a message to be valid it must be long enough. - return validLength; - }, - - _messageIsEmpty() { - return (this.value || "").trim() === ""; - }, - - @action - reset() { - if (this.isDestroyed || this.isDestroying) { - return; - } - - this.setProperties({ - value: "", - inReplyMsg: null, - }); - this._captureMentions(); - this._syncUploads([]); - this._focusTextArea({ ensureAtEnd: true, resizeTextarea: true }); - this.composerService?.onComposerValueChange?.( - this.value, - this._uploads, - this.composerService?.replyToMsg - ); - }, - - @action - cancelReplyTo() { - this.composerService?.setReplyTo(null); - }, - - @action - cancelEditing() { - this.composerService?.cancelEditing(); - this.set("value", ""); - this._focusTextArea({ ensureAtEnd: true, resizeTextarea: true }); - }, - - _cursorIsOnEmptyLine() { - const selectionStart = this._textarea.selectionStart; - if (selectionStart === 0) { - return true; - } else if (this._textarea.value.charAt(selectionStart - 1) === "\n") { - return true; - } else { - return false; - } - }, - - @action - uploadsChanged(uploads, { inProgressUploadsCount }) { - this.set("_uploads", cloneJSON(uploads)); - this.composerService?.onComposerValueChange?.({ - uploads: this._uploads, - inProgressUploadsCount, - }); - }, - - @action - onTextareaFocusIn(target) { - if (!this.capabilities.isIOS) { - return; - } - - // hack to prevent the whole viewport - // to move on focus input - target = document.querySelector(".chat-composer-input"); - target.style.transform = "translateY(-99999px)"; - target.focus(); - window.requestAnimationFrame(() => { - window.requestAnimationFrame(() => { - target.style.transform = ""; - }); - }); - }, - - @action - resizeTextarea() { - schedule("afterRender", () => { - if (!this._textarea) { - return; - } - - // this is a quirk which forces us to `auto` first or textarea - // won't resize - this._textarea.style.height = "auto"; - - // +1 is to workaround a rounding error visible on electron - // causing scrollbars to show when they shouldn’t - this._textarea.style.height = this._textarea.scrollHeight + 1 + "px"; - }); - }, -}); + #isAutocompleteDisplayed() { + return document.querySelector(".autocomplete"); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-draft-channel-screen.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-draft-channel-screen.hbs index fccac8b872a..35d7499f23b 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-draft-channel-screen.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-draft-channel-screen.hbs @@ -19,6 +19,6 @@ /> {{#if this.previewedChannel}} - + {{/if}}
\ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/channel.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/channel.hbs index 7a94b39267e..f78588d7632 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/channel.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/channel.hbs @@ -16,7 +16,7 @@ {{did-update this.fetchChannel @params.channelId}} > {{#if this.chat.activeChannel}} - diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message.js b/plugins/chat/assets/javascripts/discourse/components/chat-message.js index d4eafdc3ceb..460fae13aa1 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message.js @@ -210,7 +210,7 @@ export default class ChatMessage extends Component { } document.activeElement.blur(); - document.querySelector(".chat-composer-input")?.blur(); + document.querySelector(".chat-composer__input")?.blur(); this._setActiveMessage(); } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-side-panel-resizer.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-side-panel-resizer.hbs new file mode 100644 index 00000000000..a717e52b509 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-side-panel-resizer.hbs @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-side-panel.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-side-panel.hbs index a177d7d0c7e..fc0c040248f 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-side-panel.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-side-panel.hbs @@ -1,5 +1,18 @@ {{#if this.chatStateManager.isSidePanelExpanded}} -
+
{{yield}} +
{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-side-panel.js b/plugins/chat/assets/javascripts/discourse/components/chat-side-panel.js index 53be6e18eb9..5c046ab32d2 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-side-panel.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-side-panel.js @@ -1,6 +1,49 @@ import Component from "@glimmer/component"; import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; +import { htmlSafe } from "@ember/template"; +import { tracked } from "@glimmer/tracking"; + +const MIN_CHAT_CHANNEL_WIDTH = 300; export default class ChatSidePanel extends Component { @service chatStateManager; + @service chatSidePanelSize; + @service site; + + @tracked sidePanel; + + @action + setSidePanel(element) { + this.sidePanel = element; + } + + get width() { + if (!this.sidePanel) { + return; + } + + const maxWidth = Math.min( + this.#maxWidth(this.sidePanel), + this.chatSidePanelSize.width + ); + + return htmlSafe(`width:${maxWidth}px`); + } + + @action + didResize(element, size) { + const parentWidth = element.parentElement.getBoundingClientRect().width; + const mainPanelWidth = parentWidth - size.width; + + if (mainPanelWidth > MIN_CHAT_CHANNEL_WIDTH) { + this.chatSidePanelSize.width = size.width; + element.style.width = size.width + "px"; + } + } + + #maxWidth(element) { + const parentWidth = element.parentElement.getBoundingClientRect().width; + return parentWidth - MIN_CHAT_CHANNEL_WIDTH; + } } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs index 1c00da944af..43f265938ad 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs @@ -1,10 +1,12 @@
{{#if @includeHeader}} @@ -13,7 +15,8 @@ {{d-icon "times"}} @@ -47,20 +50,20 @@ {{#if this.chatChannelThreadPane.selectingMessages}} {{else}} - {{/if}} + +
\ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-thread.js b/plugins/chat/assets/javascripts/discourse/components/chat-thread.js index ee63ef2ed39..7eb9fc2851f 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-thread.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-thread.js @@ -1,6 +1,4 @@ import Component from "@glimmer/component"; -import { cloneJSON } from "discourse-common/lib/object"; -import ChatMessageDraft from "discourse/plugins/chat/discourse/models/chat-message-draft"; import { tracked } from "@glimmer/tracking"; import { action } from "@ember/object"; import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; @@ -27,6 +25,7 @@ export default class ChatThreadPanel extends Component { @tracked loading; @tracked loadingMorePast; + @tracked uploadDropZone; scrollable = null; @@ -43,6 +42,19 @@ export default class ChatThreadPanel extends Component { this.chatChannelThreadPaneSubscriptionsManager.subscribe(this.thread); } + @action + setUploadDropZone(element) { + this.uploadDropZone = element; + } + + @action + setupMessage() { + this.chatChannelThreadComposer.message = ChatMessage.createDraftMessage( + this.channel, + { user: this.currentUser, thread_id: this.thread.id } + ); + } + @action unsubscribeFromUpdates() { this.chatChannelThreadPaneSubscriptionsManager.unsubscribe(); @@ -168,27 +180,34 @@ export default class ChatThreadPanel extends Component { } @action - sendMessage(message, uploads = []) { + onSendMessage(message) { + if (message.editing) { + this.#sendEditMessage(message); + } else { + this.#sendNewMessage(message); + } + } + + @action + resetComposer() { + this.chatChannelThreadComposer.reset(this.channel); + } + + #sendNewMessage(message) { // TODO (martin) For desktop notifications // resetIdle() - if (this.chatChannelThreadPane.sendingLoading) { + if (this.chatChannelThreadPane.sending) { return; } - this.chatChannelThreadPane.sendingLoading = true; - this.channel.draft = ChatMessageDraft.create(); + this.chatChannelThreadPane.sending = true; // TODO (martin) Handling case when channel is not followed???? IDK if we // even let people send messages in threads without this, seems weird. - const stagedMessage = ChatMessage.createStagedMessage(this.channel, { - message, - created_at: new Date(), - uploads: cloneJSON(uploads), - user: this.currentUser, - thread_id: this.thread.id, - }); - + this.thread.stageMessage(message); + const stagedMessage = message; + this.resetComposer(); this.thread.messagesManager.addMessages([stagedMessage]); // TODO (martin) Scrolling!! @@ -214,8 +233,25 @@ export default class ChatThreadPanel extends Component { if (this._selfDeleted) { return; } - this.chatChannelThreadPane.sendingLoading = false; - this.chatChannelThreadPane.resetAfterSend(); + this.chatChannelThreadPane.sending = false; + }); + } + + #sendEditMessage(message) { + this.chatChannelThreadPane.sending = true; + + const data = { + new_message: message.message, + upload_ids: message.uploads.map((upload) => upload.id), + }; + + this.resetComposer(); + + return this.chatApi + .editMessage(message.channelId, message.id, data) + .catch(popupAjaxError) + .finally(() => { + this.chatChannelThreadPane.sending = false; }); } @@ -302,6 +338,6 @@ export default class ChatThreadPanel extends Component { } } - this.chatChannelThreadPane.resetAfterSend(); + this.resetComposer(); } } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-upload-drop-zone.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-upload-drop-zone.hbs new file mode 100644 index 00000000000..08980dba7aa --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-upload-drop-zone.hbs @@ -0,0 +1,65 @@ +
+
+
+ + + +
+
+ + + + + + + + +
+
+ + {{this.title}} + +
+
+
\ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-upload-drop-zone.js b/plugins/chat/assets/javascripts/discourse/components/chat-upload-drop-zone.js new file mode 100644 index 00000000000..3e6b2487685 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-upload-drop-zone.js @@ -0,0 +1,19 @@ +import Component from "@glimmer/component"; +import ChatThread from "discourse/plugins/chat/discourse/models/chat-thread"; +import I18n from "I18n"; + +export default class ChatUploadDropZone extends Component { + get title() { + if (this.#isThread()) { + return I18n.t("chat.upload_to_thread"); + } else { + return I18n.t("chat.upload_to_channel", { + title: this.args.model.title, + }); + } + } + + #isThread() { + return this.args.model instanceof ChatThread; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/composer/channel.js b/plugins/chat/assets/javascripts/discourse/components/chat/composer/channel.js new file mode 100644 index 00000000000..5f5e66e38d7 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/composer/channel.js @@ -0,0 +1,95 @@ +import ChatComposer from "../../chat-composer"; +import { inject as service } from "@ember/service"; +import I18n from "I18n"; +import discourseDebounce from "discourse-common/lib/debounce"; +import { action } from "@ember/object"; +import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; +import { Promise } from "rsvp"; + +export default class ChatComposerChannel extends ChatComposer { + @service("chat-channel-composer") composer; + @service("chat-channel-pane") pane; + + context = "channel"; + + @action + sendMessage(raw) { + const message = ChatMessage.createDraftMessage(this.args.channel, { + user: this.currentUser, + message: raw, + }); + + this.args.onSendMessage(message); + + return Promise.resolve(); + } + + @action + persistDraft() { + if (this.args.channel?.isDraft) { + return; + } + + this._persistHandler = discourseDebounce( + this, + this._debouncedPersistDraft, + 2000 + ); + } + + @action + _debouncedPersistDraft() { + this.chatApi.saveDraft( + this.args.channel.id, + this.currentMessage.toJSONDraft() + ); + } + + get placeholder() { + if (!this.args.channel.canModifyMessages(this.currentUser)) { + return I18n.t( + `chat.placeholder_new_message_disallowed.${this.args.channel.status}` + ); + } + + if (this.args.channel.isDraft) { + if (this.args.channel?.chatable?.users?.length) { + return I18n.t("chat.placeholder_start_conversation_users", { + commaSeparatedUsernames: this.args.channel.chatable.users + .mapBy("username") + .join(I18n.t("word_connector.comma")), + }); + } else { + return I18n.t("chat.placeholder_start_conversation"); + } + } + + if (!this.chat.userCanInteractWithChat) { + return I18n.t("chat.placeholder_silenced"); + } else { + return this.#messageRecipients(this.args.channel); + } + } + + #messageRecipients(channel) { + if (channel.isDirectMessageChannel) { + const directMessageRecipients = channel.chatable.users; + if ( + directMessageRecipients.length === 1 && + directMessageRecipients[0].id === this.currentUser.id + ) { + return I18n.t("chat.placeholder_self"); + } + + return I18n.t("chat.placeholder_users", { + commaSeparatedNames: directMessageRecipients + .map((u) => u.name || `@${u.username}`) + .join(I18n.t("word_connector.comma")), + }); + } else { + return I18n.t("chat.placeholder_channel", { + channelName: `#${channel.title}`, + }); + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/composer/thread.js b/plugins/chat/assets/javascripts/discourse/components/chat/composer/thread.js new file mode 100644 index 00000000000..76a6c15860c --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/composer/thread.js @@ -0,0 +1,30 @@ +import ChatComposer from "../../chat-composer"; +import { inject as service } from "@ember/service"; +import I18n from "I18n"; +import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; +import { Promise } from "rsvp"; +import { action } from "@ember/object"; + +export default class ChatComposerThread extends ChatComposer { + @service("chat-channel-thread-composer") composer; + @service("chat-channel-thread-pane") pane; + + context = "thread"; + + @action + sendMessage(raw) { + const message = ChatMessage.createDraftMessage(this.args.channel, { + user: this.currentUser, + message: raw, + thread_id: this.args.channel.activeThread.id, + }); + + this.args.onSendMessage(message); + + return Promise.resolve(); + } + + get placeholder() { + return I18n.t("chat.placeholder_thread"); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/direct-message-creator.js b/plugins/chat/assets/javascripts/discourse/components/direct-message-creator.js index 60ce768975e..b70382d4274 100644 --- a/plugins/chat/assets/javascripts/discourse/components/direct-message-creator.js +++ b/plugins/chat/assets/javascripts/discourse/components/direct-message-creator.js @@ -163,7 +163,7 @@ export default Component.extend({ @action handleFilterKeyUp(event) { if (event.key === "Tab") { - const enabledComposer = document.querySelector(".chat-composer-input"); + const enabledComposer = document.querySelector(".chat-composer__input"); if (enabledComposer && !enabledComposer.disabled) { event.preventDefault(); event.stopPropagation(); diff --git a/plugins/chat/assets/javascripts/discourse/components/full-page-chat.hbs b/plugins/chat/assets/javascripts/discourse/components/full-page-chat.hbs index b30ea425d5a..97fc62b9593 100644 --- a/plugins/chat/assets/javascripts/discourse/components/full-page-chat.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/full-page-chat.hbs @@ -1,5 +1,5 @@ {{#if @channel.id}} - diff --git a/plugins/chat/assets/javascripts/discourse/helpers/format-chat-date.js b/plugins/chat/assets/javascripts/discourse/helpers/format-chat-date.js index 5d91f205e4f..cd68b634d5c 100644 --- a/plugins/chat/assets/javascripts/discourse/helpers/format-chat-date.js +++ b/plugins/chat/assets/javascripts/discourse/helpers/format-chat-date.js @@ -8,15 +8,21 @@ registerUnbound("format-chat-date", function (message, mode) { const currentUser = User.current(); const tz = currentUser ? currentUser.user_option.timezone : moment.tz.guess(); const date = moment(new Date(message.createdAt), tz); - const url = getURL(`/chat/c/-/${message.channelId}/${message.id}`); - const title = date.format(I18n.t("dates.long_with_year")); + const title = date.format(I18n.t("dates.long_with_year")); const display = mode === "tiny" ? date.format(I18n.t("chat.dates.time_tiny")) : date.format(I18n.t("dates.time")); - return htmlSafe( - `${display}` - ); + if (message.staged) { + return htmlSafe( + `${display}` + ); + } else { + const url = getURL(`/chat/c/-/${message.channel.id}/${message.id}`); + return htmlSafe( + `${display}` + ); + } }); diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-cook-function.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-cook-function.js deleted file mode 100644 index 5d436f6a4f7..00000000000 --- a/plugins/chat/assets/javascripts/discourse/initializers/chat-cook-function.js +++ /dev/null @@ -1,33 +0,0 @@ -import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; -import { generateCookFunction } from "discourse/lib/text"; -import simpleCategoryHashMentionTransform from "discourse/plugins/chat/discourse/lib/simple-category-hash-mention-transform"; - -export default { - name: "chat-cook-function", - - before: "chat-setup", - - initialize(container) { - const site = container.lookup("service:site"); - - const markdownOptions = { - featuresOverride: - site.markdown_additional_options?.chat?.limited_pretty_text_features, - markdownItRules: - site.markdown_additional_options?.chat - ?.limited_pretty_text_markdown_rules, - hashtagTypesInPriorityOrder: - site.hashtag_configurations?.["chat-composer"], - hashtagIcons: site.hashtag_icons, - }; - - generateCookFunction(markdownOptions).then((cookFunction) => { - ChatMessage.cookFunction = (raw) => { - return simpleCategoryHashMentionTransform( - cookFunction(raw), - site.categories - ); - }; - }); - }, -}; diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-keyboard-shortcuts.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-keyboard-shortcuts.js index 8439c53d8eb..b535c4e119d 100644 --- a/plugins/chat/assets/javascripts/discourse/initializers/chat-keyboard-shortcuts.js +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-keyboard-shortcuts.js @@ -42,7 +42,8 @@ export default { chatService.switchChannelUpOrDown("down"); }; - const isChatComposer = (el) => el.classList.contains("chat-composer-input"); + const isChatComposer = (el) => + el.classList.contains("chat-composer__input"); const isInputSelection = (el) => { const inputs = ["input", "textarea", "select", "button"]; const elementTagName = el?.tagName.toLowerCase(); @@ -58,7 +59,7 @@ export default { } event.preventDefault(); event.stopPropagation(); - appEvents.trigger("chat:modify-selection", { + appEvents.trigger("chat:modify-selection", event, { type, context: event.target.dataset.chatComposerContext, }); @@ -70,7 +71,9 @@ export default { } event.preventDefault(); event.stopPropagation(); - appEvents.trigger("chat:open-insert-link-modal", { event }); + appEvents.trigger("chat:open-insert-link-modal", event, { + context: event.target.dataset.chatComposerContext, + }); }; const openChatDrawer = (event) => { diff --git a/plugins/chat/assets/javascripts/discourse/lib/chat-message-container.js b/plugins/chat/assets/javascripts/discourse/lib/chat-message-container.js index 2be3b6aec36..ccf5aef3f14 100644 --- a/plugins/chat/assets/javascripts/discourse/lib/chat-message-container.js +++ b/plugins/chat/assets/javascripts/discourse/lib/chat-message-container.js @@ -6,7 +6,7 @@ export default function chatMessageContainer(id, context) { if (context === MESSAGE_CONTEXT_THREAD) { selector = `.chat-thread .chat-message-container[data-id="${id}"]`; } else { - selector = `.chat-live-pane .chat-message-container[data-id="${id}"]`; + selector = `.chat-channel .chat-message-container[data-id="${id}"]`; } return document.querySelector(selector); diff --git a/plugins/chat/assets/javascripts/discourse/lib/chat-message-interactor.js b/plugins/chat/assets/javascripts/discourse/lib/chat-message-interactor.js index 349b83f577e..d65a3e532ae 100644 --- a/plugins/chat/assets/javascripts/discourse/lib/chat-message-interactor.js +++ b/plugins/chat/assets/javascripts/discourse/lib/chat-message-interactor.js @@ -349,12 +349,12 @@ export default class ChatMessageInteractor { @action reply() { - this.composer.setReplyTo(this.message.id); + this.composer.replyTo(this.message); } @action edit() { - this.composer.editButtonClicked(this.message.id); + this.composer.editMessage(this.message); } @action diff --git a/plugins/chat/assets/javascripts/discourse/lib/textarea-interactor.js b/plugins/chat/assets/javascripts/discourse/lib/textarea-interactor.js new file mode 100644 index 00000000000..025f9c316d8 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/textarea-interactor.js @@ -0,0 +1,72 @@ +import EmberObject from "@ember/object"; +import TextareaTextManipulation from "discourse/mixins/textarea-text-manipulation"; +import { next, schedule } from "@ember/runloop"; +import { setOwner } from "@ember/application"; +import { inject as service } from "@ember/service"; + +// This class sole purpose is to provide a way to interact with the textarea +// using the existing TextareaTextManipulation mixin without using it directly +// in the composer component. It will make future migration easier. +export default class TextareaInteractor extends EmberObject.extend( + TextareaTextManipulation +) { + @service capabilities; + @service site; + + constructor(owner, textarea) { + super(...arguments); + setOwner(this, owner); + this.textarea = textarea; + this._textarea = textarea; + this.element = this._textarea; + this.ready = true; + } + + set value(value) { + this._textarea.value = value; + const event = new Event("input", { + bubbles: true, + cancelable: true, + }); + this._textarea.dispatchEvent(event); + } + + focus(opts = { ensureAtEnd: false, refreshHeight: true }) { + next(() => { + if (opts.refreshHeight) { + this.refreshHeight(); + } + + if (opts.ensureAtEnd) { + this.ensureCaretAtEnd(); + } + + if (this.capabilities.isIpadOS || this.site.mobileView) { + return; + } + + this.focusTextArea(); + }); + } + + ensureCaretAtEnd() { + schedule("afterRender", () => { + this._textarea.setSelectionRange( + this._textarea.value.length, + this._textarea.value.length + ); + }); + } + + refreshHeight() { + schedule("afterRender", () => { + // this is a quirk which forces us to `auto` first or textarea + // won't resize + this._textarea.style.height = "auto"; + + // +1 is to workaround a rounding error visible on electron + // causing scrollbars to show when they shouldn’t + this._textarea.style.height = this._textarea.scrollHeight + 1 + "px"; + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-channel.js b/plugins/chat/assets/javascripts/discourse/models/chat-channel.js index 207174e7de9..76ba2a1209b 100644 --- a/plugins/chat/assets/javascripts/discourse/models/chat-channel.js +++ b/plugins/chat/assets/javascripts/discourse/models/chat-channel.js @@ -8,6 +8,7 @@ import slugifyChannel from "discourse/plugins/chat/discourse/lib/slugify-channel import ChatThreadsManager from "discourse/plugins/chat/discourse/lib/chat-threads-manager"; import ChatMessagesManager from "discourse/plugins/chat/discourse/lib/chat-messages-manager"; import { getOwner } from "discourse-common/lib/get-owner"; +import guid from "pretty-text/guid"; export const CHATABLE_TYPES = { directMessageChannel: "DirectMessage", @@ -78,6 +79,10 @@ export default class ChatChannel extends RestModel { this.messagesManager.messages = messages; } + get canLoadMoreFuture() { + return this.messagesManager.canLoadMoreFuture; + } + get escapedTitle() { return escapeExpression(this.title); } @@ -150,6 +155,27 @@ export default class ChatChannel extends RestModel { this.channelMessageBusLastId = details.channel_message_bus_last_id; } + stageMessage(message) { + message.id = guid(); + message.staged = true; + message.draft = false; + message.createdAt ??= moment.utc().format(); + message.cook(); + + if (message.inReplyTo) { + if (!message.inReplyTo.threadId) { + message.inReplyTo.threadId = guid(); + message.inReplyTo.threadReplyCount = 1; + } + + if (!this.threadingEnabled) { + this.messagesManager.addMessages([message]); + } + } else { + this.messagesManager.addMessages([message]); + } + } + canModifyMessages(user) { if (user.staff) { return !STAFF_READONLY_STATUSES.includes(this.status); diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-message-draft.js b/plugins/chat/assets/javascripts/discourse/models/chat-message-draft.js deleted file mode 100644 index 00709add3f3..00000000000 --- a/plugins/chat/assets/javascripts/discourse/models/chat-message-draft.js +++ /dev/null @@ -1,62 +0,0 @@ -import { tracked } from "@glimmer/tracking"; - -export default class ChatMessageDraft { - static create(args = {}) { - return new ChatMessageDraft(args ?? {}); - } - - @tracked uploads; - @tracked message; - @tracked _replyToMsg; - - constructor(args = {}) { - this.message = args.message ?? ""; - this.uploads = args.uploads ?? []; - this.replyToMsg = args.replyToMsg; - } - - get replyToMsg() { - return this._replyToMsg; - } - - set replyToMsg(message) { - this._replyToMsg = message - ? { - id: message.id, - excerpt: message.excerpt, - user: { - id: message.user.id, - name: message.user.name, - avatar_template: message.user.avatar_template, - username: message.user.username, - }, - } - : null; - } - - toJSON() { - if ( - this.message?.length === 0 && - this.uploads?.length === 0 && - !this.replyToMsg - ) { - return null; - } - - const data = {}; - - if (this.uploads?.length > 0) { - data.uploads = this.uploads; - } - - if (this.message?.length > 0) { - data.message = this.message; - } - - if (this.replyToMsg) { - data.replyToMsg = this.replyToMsg; - } - - return JSON.stringify(data); - } -} diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-message.js b/plugins/chat/assets/javascripts/discourse/models/chat-message.js index 814964baa0e..880e7d03feb 100644 --- a/plugins/chat/assets/javascripts/discourse/models/chat-message.js +++ b/plugins/chat/assets/javascripts/discourse/models/chat-message.js @@ -4,7 +4,9 @@ import { TrackedArray, TrackedObject } from "@ember-compat/tracked-built-ins"; import ChatMessageReaction from "discourse/plugins/chat/discourse/models/chat-message-reaction"; import Bookmark from "discourse/models/bookmark"; import I18n from "I18n"; -import guid from "pretty-text/guid"; +import { generateCookFunction } from "discourse/lib/text"; +import simpleCategoryHashMentionTransform from "discourse/plugins/chat/discourse/lib/simple-category-hash-mention-transform"; +import { getOwner } from "discourse-common/lib/get-owner"; export default class ChatMessage { static cookFunction = null; @@ -13,10 +15,9 @@ export default class ChatMessage { return new ChatMessage(channel, args); } - static createStagedMessage(channel, args = {}) { - args.id = guid(); - args.staged = true; - return new ChatMessage(channel, args); + static createDraftMessage(channel, args = {}) { + args.draft = true; + return ChatMessage.create(channel, args); } @tracked id; @@ -24,6 +25,7 @@ export default class ChatMessage { @tracked selected; @tracked channel; @tracked staged = false; + @tracked draft = false; @tracked channelId; @tracked createdAt; @tracked deletedAt; @@ -69,11 +71,20 @@ export default class ChatMessage { this.reviewableId = args.reviewableId || args.reviewable_id; this.userFlagStatus = args.userFlagStatus || args.user_flag_status; this.inReplyTo = - args.inReplyTo || args.in_reply_to - ? ChatMessage.create(channel, args.in_reply_to) - : null; - this.message = args.message; - this.cooked = args.cooked || ChatMessage.cookFunction(this.message); + args.inReplyTo || + (args.in_reply_to || args.replyToMsg + ? ChatMessage.create(channel, args.in_reply_to || args.replyToMsg) + : null); + this.draft = args.draft; + this.message = args.message || ""; + + if (args.cooked) { + this.cooked = args.cooked; + } else { + this.cooked = ""; + this.cook(); + } + this.reactions = this.#initChatMessageReactionModel( args.id, args.reactions @@ -83,6 +94,38 @@ export default class ChatMessage { this.bookmark = args.bookmark ? Bookmark.create(args.bookmark) : null; } + cook() { + const site = getOwner(this).lookup("service:site"); + + const markdownOptions = { + featuresOverride: + site.markdown_additional_options?.chat?.limited_pretty_text_features, + markdownItRules: + site.markdown_additional_options?.chat + ?.limited_pretty_text_markdown_rules, + hashtagTypesInPriorityOrder: + site.hashtag_configurations?.["chat-composer"], + hashtagIcons: site.hashtag_icons, + }; + + if (ChatMessage.cookFunction) { + this.cooked = ChatMessage.cookFunction(this.message); + this.incrementVersion(); + } else { + generateCookFunction(markdownOptions).then((cookFunction) => { + ChatMessage.cookFunction = (raw) => { + return simpleCategoryHashMentionTransform( + cookFunction(raw), + site.categories + ); + }; + + this.cooked = ChatMessage.cookFunction(this.message); + this.incrementVersion(); + }); + } + } + get threadRouteModels() { return [...this.channel.routeModels, this.threadId]; } @@ -134,6 +177,41 @@ export default class ChatMessage { this.version++; } + toJSONDraft() { + if ( + this.message?.length === 0 && + this.uploads?.length === 0 && + !this.inReplyTo + ) { + return null; + } + + const data = {}; + + if (this.uploads?.length > 0) { + data.uploads = this.uploads; + } + + if (this.message?.length > 0) { + data.message = this.message; + } + + if (this.inReplyTo) { + data.replyToMsg = { + id: this.inReplyTo.id, + excerpt: this.inReplyTo.excerpt, + user: { + id: this.inReplyTo.user.id, + name: this.inReplyTo.user.name, + avatar_template: this.inReplyTo.user.avatar_template, + username: this.inReplyTo.user.username, + }, + }; + } + + return JSON.stringify(data); + } + react(emoji, action, actor, currentUserId) { const selfReaction = actor.id === currentUserId; const existingReaction = this.reactions.find( diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-thread.js b/plugins/chat/assets/javascripts/discourse/models/chat-thread.js index 1ee2c07f79b..e44b4406780 100644 --- a/plugins/chat/assets/javascripts/discourse/models/chat-thread.js +++ b/plugins/chat/assets/javascripts/discourse/models/chat-thread.js @@ -3,6 +3,7 @@ import ChatMessagesManager from "discourse/plugins/chat/discourse/lib/chat-messa import User from "discourse/models/user"; import { escapeExpression } from "discourse/lib/utilities"; import { tracked } from "@glimmer/tracking"; +import guid from "pretty-text/guid"; export const THREAD_STATUSES = { open: "open", @@ -28,6 +29,16 @@ export default class ChatThread { this.originalMessage.user = this.originalMessageUser; } + stageMessage(message) { + message.id = guid(); + message.staged = true; + message.draft = false; + message.createdAt ??= moment.utc().format(); + message.cook(); + + this.messagesManager.addMessages([message]); + } + get messages() { return this.messagesManager.messages; } diff --git a/plugins/chat/assets/javascripts/discourse/modifiers/chat/resizable-node.js b/plugins/chat/assets/javascripts/discourse/modifiers/chat/resizable-node.js index 3ac6d835601..1ab09e0e100 100644 --- a/plugins/chat/assets/javascripts/discourse/modifiers/chat/resizable-node.js +++ b/plugins/chat/assets/javascripts/discourse/modifiers/chat/resizable-node.js @@ -9,6 +9,7 @@ export default class ResizableNode extends Modifier { element = null; resizerSelector = null; didResizeContainer = null; + options = null; _originalWidth = 0; _originalHeight = 0; @@ -22,10 +23,14 @@ export default class ResizableNode extends Modifier { registerDestructor(this, (instance) => instance.cleanup()); } - modify(element, [resizerSelector, didResizeContainer]) { + modify(element, [resizerSelector, didResizeContainer, options = {}]) { this.resizerSelector = resizerSelector; this.element = element; this.didResizeContainer = didResizeContainer; + this.options = Object.assign( + { vertical: true, horizontal: true, position: true, mutate: true }, + options + ); this.element .querySelector(this.resizerSelector) @@ -97,21 +102,29 @@ export default class ResizableNode extends Modifier { const newStyle = {}; - if (width > MINIMUM_SIZE) { + if (this.options.horizontal && width > MINIMUM_SIZE) { newStyle.width = width + "px"; - newStyle.left = - Math.ceil(this._originalX + (event.pageX - this._originalMouseX)) + - "px"; + + if (this.options.position) { + newStyle.left = + Math.ceil(this._originalX + (event.pageX - this._originalMouseX)) + + "px"; + } } - if (height > MINIMUM_SIZE) { + if (this.options.vertical && height > MINIMUM_SIZE) { newStyle.height = height + "px"; - newStyle.top = - Math.ceil(this._originalY + (event.pageY - this._originalMouseY)) + - "px"; + + if (this.options.position) { + newStyle.top = + Math.ceil(this._originalY + (event.pageY - this._originalMouseY)) + + "px"; + } } - Object.assign(this.element.style, newStyle); + if (this.options.mutate) { + Object.assign(this.element.style, newStyle); + } this.didResizeContainer?.(this.element, { width, height }); } diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-api.js b/plugins/chat/assets/javascripts/discourse/services/chat-api.js index 7be02b5ed3c..3c29d8041f3 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-api.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-api.js @@ -299,7 +299,7 @@ export default class ChatApi extends Service { /** * Saves a draft for the channel, which includes message contents and uploads. * @param {number} channelId - The ID of the channel. - * @param {object} data - The draft data, see ChatMessageDraft.toJSON() for more details. + * @param {object} data - The draft data, see ChatMessage.toJSONDraft() for more details. * @returns {Promise} */ saveDraft(channelId, data) { diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-channel-composer.js b/plugins/chat/assets/javascripts/discourse/services/chat-channel-composer.js index e06ed71af0b..6afa1a96a76 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-channel-composer.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-channel-composer.js @@ -1,132 +1,60 @@ -import { debounce } from "discourse-common/utils/decorators"; import { tracked } from "@glimmer/tracking"; import Service, { inject as service } from "@ember/service"; +import { action } from "@ember/object"; +import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; export default class ChatChannelComposer extends Service { @service chat; @service chatApi; @service chatComposerPresenceManager; + @service currentUser; - @tracked editingMessage = null; - @tracked replyToMsg = null; - @tracked linkedComponent = null; + @tracked _message; - reset() { - this.editingMessage = null; - this.replyToMsg = null; - } - - get model() { - return this.chat.activeChannel; - } - - setReplyTo(messageOrId) { - if (messageOrId) { - this.cancelEditing(); - - const message = - typeof messageOrId === "number" - ? this.model.messagesManager.findMessage(messageOrId) - : messageOrId; - this.replyToMsg = message; - this.focusComposer(); - } else { - this.replyToMsg = null; + @action + cancel() { + if (this.message.editing) { + this.reset(); + } else if (this.message.inReplyTo) { + this.message.inReplyTo = null; } - - this.onComposerValueChange({ replyToMsg: this.replyToMsg }); } - editButtonClicked(messageId) { - const message = this.model.messagesManager.findMessage(messageId); - this.editingMessage = message; - - // TODO (martin) Move scrollToLatestMessage to live panel. - // this.scrollToLatestMessage(); - - this.focusComposer(); + @action + reset(channel) { + this.message = ChatMessage.createDraftMessage(channel, { + user: this.currentUser, + }); } - onComposerValueChange({ - value, - uploads, - replyToMsg, - inProgressUploadsCount, - }) { - if (!this.model) { - return; - } - - if (!this.editingMessage && !this.model.isDraft) { - if (typeof value !== "undefined" && this.model.draft) { - this.model.draft.message = value; - } - - // only save the uploads to the draft if we are not still uploading other - // ones, otherwise we get into a cycle where we pass the draft uploads as - // existingUploads back to the upload component and cause in progress ones - // to be cancelled - if ( - typeof uploads !== "undefined" && - inProgressUploadsCount !== "undefined" && - inProgressUploadsCount === 0 && - this.model.draft - ) { - this.model.draft.uploads = uploads; - } - - if (typeof replyToMsg !== "undefined" && this.model.draft) { - this.model.draft.replyToMsg = replyToMsg; - } - } - - if (!this.model.isDraft) { - this.#reportReplyingPresence(value); - } - - this._persistDraft(); + @action + clear() { + this.message.message = ""; } - cancelEditing() { - this.editingMessage = null; + @action + editMessage(message) { + this.chat.activeMessage = null; + message.editing = true; + this.message = message; } - registerFocusHandler(handlerFn) { - this.focusHandler = handlerFn; + @action + onCancelEditing() { + this.reset(); } - focusComposer() { - this.focusHandler(); + @action + replyTo(message) { + this.chat.activeMessage = null; + this.message.inReplyTo = message; } - #reportReplyingPresence(composerValue) { - if (this.#componentDeleted) { - return; - } - - if (this.model.isDraft) { - return; - } - - const replying = !this.editingMessage && !!composerValue; - this.chatComposerPresenceManager.notifyState(this.model.id, replying); + get message() { + return this._message; } - @debounce(2000) - _persistDraft() { - if (this.#componentDeleted || !this.model) { - return; - } - - if (!this.model.draft) { - return; - } - - return this.chatApi.saveDraft(this.model.id, this.model.draft.toJSON()); - } - - get #componentDeleted() { - // note I didn't set this in the new version, not sure yet what to do with it - // return this.linkedComponent._selfDeleted; + set message(message) { + this._message = message; } } diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-channel-pane.js b/plugins/chat/assets/javascripts/discourse/services/chat-channel-pane.js index 91b118dbcff..86b29e08290 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-channel-pane.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-channel-pane.js @@ -1,6 +1,5 @@ import { tracked } from "@glimmer/tracking"; import { action } from "@ember/object"; -import { popupAjaxError } from "discourse/lib/ajax-error"; import Service, { inject as service } from "@ember/service"; export default class ChatChannelPane extends Service { @@ -13,7 +12,7 @@ export default class ChatChannelPane extends Service { @tracked reacting = false; @tracked selectingMessages = false; @tracked lastSelectedMessage = null; - @tracked sendingLoading = false; + @tracked sending = false; get selectedMessageIds() { return this.chat.activeChannel?.selectedMessages?.mapBy("id") || []; @@ -23,6 +22,10 @@ export default class ChatChannelPane extends Service { return this.chatChannelComposer; } + get channel() { + return this.chat.activeChannel; + } + @action cancelSelecting(selectedMessages) { this.selectingMessages = false; @@ -37,55 +40,19 @@ export default class ChatChannelPane extends Service { this.selectingMessages = true; } - @action - editMessage(newContent, uploads) { - this.sendingLoading = true; - let data = { - new_message: newContent, - upload_ids: (uploads || []).map((upload) => upload.id), - }; - return this.chatApi - .editMessage( - this.composerService.editingMessage.channelId, - this.composerService.editingMessage.id, - data - ) - .then(() => { - this.resetAfterSend(); - }) - .catch(popupAjaxError) - .finally(() => { - if (this._selfDeleted) { - return; - } - this.sendingLoading = false; - }); - } - - resetAfterSend() { - const channelId = this.composerService.editingMessage?.channelId; - if (channelId) { - this.chatComposerPresenceManager.notifyState(channelId, false); - } - - this.composerService.reset(); - } - - @action - editLastMessageRequested() { - const lastUserMessage = this.chat.activeChannel.messages.findLast( + get lastCurrentUserMessage() { + const lastCurrentUserMessage = this.chat.activeChannel.messages.findLast( (message) => message.user.id === this.currentUser.id ); - if (!lastUserMessage) { + if (!lastCurrentUserMessage) { return; } - if (lastUserMessage.staged || lastUserMessage.error) { + if (lastCurrentUserMessage.staged || lastCurrentUserMessage.error) { return; } - this.composerService.editingMessage = lastUserMessage; - this.composerService.focusComposer(); + return lastCurrentUserMessage; } } diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-channel-thread-composer.js b/plugins/chat/assets/javascripts/discourse/services/chat-channel-thread-composer.js index bd691b7ce44..81749d4e725 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-channel-thread-composer.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-channel-thread-composer.js @@ -1,15 +1,13 @@ import ChatChannelComposer from "./chat-channel-composer"; +import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; +import { action } from "@ember/object"; export default class extends ChatChannelComposer { - get model() { - return this.chat.activeChannel.activeThread; - } - - _persistDraft() { - // eslint-disable-next-line no-console - console.debug( - "Drafts are unsupported for chat threads at this point in time" - ); - return; + @action + reset(channel) { + this.message = ChatMessage.createDraftMessage(channel, { + user: this.currentUser, + thread_id: channel.activeThread.id, + }); } } diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-channel-thread-pane.js b/plugins/chat/assets/javascripts/discourse/services/chat-channel-thread-pane.js index 839f690fb85..dd39dc6dffc 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-channel-thread-pane.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-channel-thread-pane.js @@ -11,4 +11,21 @@ export default class ChatChannelThreadPane extends ChatChannelPane { get composerService() { return this.chatChannelThreadComposer; } + + get lastCurrentUserMessage() { + const lastCurrentUserMessage = + this.chat.activeChannel.activeThread.messages.findLast( + (message) => message.user.id === this.currentUser.id + ); + + if (!lastCurrentUserMessage) { + return; + } + + if (lastCurrentUserMessage.staged || lastCurrentUserMessage.error) { + return; + } + + return lastCurrentUserMessage; + } } diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-side-panel-size.js b/plugins/chat/assets/javascripts/discourse/services/chat-side-panel-size.js new file mode 100644 index 00000000000..e09e354fc67 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-side-panel-size.js @@ -0,0 +1,24 @@ +import Service from "@ember/service"; +import KeyValueStore from "discourse/lib/key-value-store"; + +export default class ChatSidePanelSize extends Service { + STORE_NAMESPACE = "discourse_chat_side_panel_size_"; + MIN_WIDTH = 250; + + store = new KeyValueStore(this.STORE_NAMESPACE); + + get width() { + return this.store.getObject("width") || this.MIN_WIDTH; + } + + set width(width) { + this.store.setObject({ + key: "width", + value: this.#min(width, this.MIN_WIDTH), + }); + } + + #min(number, min) { + return Math.max(number, min); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat.js b/plugins/chat/assets/javascripts/discourse/services/chat.js index c5e58e52c44..169a2bbdadd 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat.js @@ -8,7 +8,7 @@ import { cancel, next } from "@ember/runloop"; import { and } from "@ember/object/computed"; import { computed } from "@ember/object"; import discourseLater from "discourse-common/lib/later"; -import ChatMessageDraft from "discourse/plugins/chat/discourse/models/chat-message-draft"; +import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; const CHAT_ONLINE_OPTIONS = { userUnseenTime: 300000, // 5 minutes seconds with no interaction @@ -126,8 +126,15 @@ export default class Chat extends Service { const storedDraft = this.currentUser.chat_drafts.find( (draft) => draft.channel_id === channel.id ); - channel.draft = ChatMessageDraft.create( - storedDraft ? JSON.parse(storedDraft.data) : null + + channel.draft = ChatMessage.createDraftMessage( + channel, + Object.assign( + { + user: this.currentUser, + }, + storedDraft ? JSON.parse(storedDraft.data) : {} + ) ); } diff --git a/plugins/chat/assets/stylesheets/common/base-common.scss b/plugins/chat/assets/stylesheets/common/base-common.scss index af91d41e948..3ec626ce2a3 100644 --- a/plugins/chat/assets/stylesheets/common/base-common.scss +++ b/plugins/chat/assets/stylesheets/common/base-common.scss @@ -145,13 +145,9 @@ $float-height: 530px; word-wrap: break-word; white-space: normal; position: relative; - will-change: transform; - transform: translateZ(0); .chat-message-container { display: grid; - will-change: transform; - transform: translateZ(0); &.selecting-messages { grid-template-columns: 1.5em 1fr; @@ -256,7 +252,7 @@ $float-height: 530px; } } -.chat-live-pane { +.chat-channel { display: flex; flex-direction: column; width: 100%; @@ -432,9 +428,8 @@ body.has-full-page-chat { } } - .chat-live-pane, .chat-messages-scroll, - .chat-live-pane { + .chat-channel { box-sizing: border-box; height: 100%; } @@ -629,7 +624,7 @@ html.has-full-page-chat { } .full-page-chat, - .chat-live-pane, + .chat-channel, #main-outlet { // allows containers to shrink to fit min-height: 0; diff --git a/plugins/chat/assets/stylesheets/common/chat-channel.scss b/plugins/chat/assets/stylesheets/common/chat-channel.scss new file mode 100644 index 00000000000..3db4cb4eabb --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-channel.scss @@ -0,0 +1,127 @@ +.chat-channel { + display: flex; + flex-direction: column; + min-height: 1px; + position: relative; + overflow: hidden; + grid-area: main; + width: 100%; + min-width: 300px; + + .open-drawer-btn { + color: var(--primary-low-mid); + + &:visited { + color: var(--primary-low-mid); + } + + &:hover { + color: var(--primary); + } + + > * { + pointer-events: none; + } + } + + .chat-messages-scroll { + flex-grow: 1; + overflow-y: scroll; + overscroll-behavior: contain; + display: flex; + flex-direction: column-reverse; + z-index: 1; + margin: 0 1px 0 0; + will-change: transform; + @include chat-scrollbar(); + + .join-channel-btn.in-float { + position: absolute; + transform: translateX(-50%); + left: 50%; + top: 10px; + z-index: 10; + } + + .all-loaded-message { + text-align: center; + color: var(--primary-medium); + font-size: var(--font-down-1); + padding: 0.5em 0.25em 0.25em; + } + } + + .scroll-stick-wrap { + display: flex; + justify-content: center; + margin: 0 1rem; + position: relative; + } + + .chat-scroll-to-bottom { + align-items: center; + justify-content: center; + position: absolute; + z-index: 1; + flex-direction: column; + bottom: -25px; + background: none; + opacity: 0; + transition: opacity 0.25s ease, transform 0.5s ease; + transform: scale(0.1); + padding: 0; + + > * { + pointer-events: none; + } + + &:hover, + &:active, + &:focus { + background: none !important; + } + + &.visible { + transform: translateY(-32px) scale(1); + opacity: 0.8; + } + + &__text { + color: var(--secondary); + padding: 0.5rem; + margin-bottom: 0.5rem; + background: var(--primary-medium); + border-radius: 3px; + text-align: center; + font-size: var(--font-down-1); + bottom: 40px; + position: absolute; + } + + &__arrow { + display: flex; + background: var(--primary-medium); + border-radius: 100%; + align-items: center; + justify-content: center; + height: 32px; + width: 32px; + position: relative; + + .d-icon { + color: var(--secondary); + margin-left: 1px; // "fixes" the 1px svg shift + } + } + + &:hover { + opacity: 1; + + .chat-scroll-to-bottom__arrow { + .d-icon { + color: var(--secondary); + } + } + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-composer-upload.scss b/plugins/chat/assets/stylesheets/common/chat-composer-upload.scss index 5d4b36303d2..0b1ef875bb2 100644 --- a/plugins/chat/assets/stylesheets/common/chat-composer-upload.scss +++ b/plugins/chat/assets/stylesheets/common/chat-composer-upload.scss @@ -1,30 +1,62 @@ .chat-composer-upload { display: inline-flex; - height: 50px; + height: 64px; padding: 0.5rem; border: 1px solid var(--primary-low-mid); margin-right: 0.5em; + position: relative; + border-radius: 5px; + box-sizing: border-box; + + &--image:not(.chat-composer-upload--in-progress) { + padding: 0; + + .preview-img { + height: 62px; + width: 62px; + box-sizing: border-box; + } + } &:last-child { margin-right: 0; } + &:hover { + .chat-composer-upload__remove-btn { + visibility: visible; + background: rgba(var(--always-black-rgb), 0.9); + padding: 5px; + border-radius: 100%; + font-size: var(--font-down-2); + } + } + + &__remove-btn { + border: 1px solid var(--primary-medium); + position: absolute; + top: -8px; + right: -8px; + visibility: hidden; + } + .preview { - width: 50px; + width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; - margin: 0 1em 0 0; - border-radius: 8px; + margin: 0; .d-icon { font-size: var(--font-up-6); + margin-right: 0.5rem; } .preview-img { - max-width: 100%; - max-height: 100%; + object-position: center; + object-fit: cover; + border-radius: 5px; } } diff --git a/plugins/chat/assets/stylesheets/common/chat-composer-uploads.scss b/plugins/chat/assets/stylesheets/common/chat-composer-uploads.scss index bdae3a83f08..81693b18010 100644 --- a/plugins/chat/assets/stylesheets/common/chat-composer-uploads.scss +++ b/plugins/chat/assets/stylesheets/common/chat-composer-uploads.scss @@ -2,7 +2,7 @@ max-width: 100%; .chat-composer-uploads-container { - padding: 0.5rem 10px; + padding: 0.5rem 0.25rem; display: flex; white-space: nowrap; overflow-x: auto; diff --git a/plugins/chat/assets/stylesheets/common/chat-composer.scss b/plugins/chat/assets/stylesheets/common/chat-composer.scss index 2077128b215..8a020f1fe35 100644 --- a/plugins/chat/assets/stylesheets/common/chat-composer.scss +++ b/plugins/chat/assets/stylesheets/common/chat-composer.scss @@ -1,60 +1,104 @@ -.chat-composer-container { - display: flex; - flex-direction: column; - z-index: 3; - background-color: var(--secondary); - - #chat-full-page-uploader, - #chat-widget-uploader { - display: none; - } - - .drop-a-file { - display: none; - } -} - .chat-composer { display: flex; align-items: center; - background-color: var(--secondary); - border: 1px solid var(--primary-low-mid); - border-radius: 5px; - padding: 0.15rem 0.25rem; - margin-top: 0.5rem; - &.is-disabled { - background-color: var(--primary-low); - border: 1px solid var(--primary-low-mid); + &__wrapper { + display: flex; + flex-direction: column; + z-index: 3; + background-color: var(--secondary); + margin-top: 0.1rem; + + #chat-full-page-uploader, + #chat-widget-uploader { + display: none; + } + + .drop-a-file { + display: none; + } } - .send-btn { - padding: 0.4rem 0.5rem; - border: 1px solid transparent; - border-radius: 5px; + &__outer-container { display: flex; align-items: center; + padding-inline: 0.25rem; + box-sizing: border-box; + width: 100%; + } - .d-icon { - color: var(--tertiary); + &__inner-container { + display: flex; + align-items: center; + box-sizing: border-box; + width: 100%; + flex-direction: row; + border: 1px solid var(--primary-low-mid); + border-radius: 5px; + background-color: var(--secondary); + padding-inline: 0.25rem; + height: 42px; + + .chat-composer--focused & { + border-color: var(--primary-medium); } - &:disabled { - cursor: not-allowed; + .chat-composer--disabled & { + background: var(--primary-low); + } + } + + &__send-btn { + border-radius: 3px; + background: none; + will-change: scale; + + .chat-composer--send-enabled & { + &:hover { + background: none; + } + + &:focus { + background: none; + outline: auto; + } .d-icon { - color: var(--primary-low); + color: var(--tertiary) !important; } } - &:not(:disabled) { - &:hover, - &:focus { - background: var(--tertiary); - .d-icon { - color: var(--secondary); - } + @keyframes sendingScales { + 0% { + transform: scale(0.8); } + 50% { + transform: scale(1.2); + } + 100% { + transform: scale(0.8); + } + } + + .chat-composer--sending & { + animation: sendingScales 1s infinite; + } + + .chat-composer--send-disabled & { + cursor: default; + opacity: 0.6 !important; + + &:hover { + background: none !important; + } + } + + > * { + pointer-events: none; + } + + .d-icon { + color: var(--primary) !important; } } @@ -82,7 +126,14 @@ } } - .chat-composer-input { + &__input-container { + display: flex; + align-items: center; + box-sizing: border-box; + width: 100%; + } + + &__input { overflow-x: hidden; width: 100%; appearance: none; @@ -97,14 +148,21 @@ @include chat-scrollbar(); + &[disabled] { + background: none; + } + + &:focus, + &:active { + outline: none; + } + &:placeholder-shown, &::placeholder { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - - @include chat-scrollbar(); } &__unreliable-network { diff --git a/plugins/chat/assets/stylesheets/common/chat-draft-channel.scss b/plugins/chat/assets/stylesheets/common/chat-draft-channel.scss index cc2510d9bea..6f5e4d1f9e9 100644 --- a/plugins/chat/assets/stylesheets/common/chat-draft-channel.scss +++ b/plugins/chat/assets/stylesheets/common/chat-draft-channel.scss @@ -37,7 +37,7 @@ } } - .chat-composer-container { + .chat-composer__wrapper { padding-bottom: 0.5em; } } diff --git a/plugins/chat/assets/stylesheets/common/chat-drawer.scss b/plugins/chat/assets/stylesheets/common/chat-drawer.scss index 296491de2c9..be3f22e40e4 100644 --- a/plugins/chat/assets/stylesheets/common/chat-drawer.scss +++ b/plugins/chat/assets/stylesheets/common/chat-drawer.scss @@ -94,7 +94,7 @@ html.rtl { height: auto !important; } - .chat-live-pane { + .chat-channel { height: 100%; } } diff --git a/plugins/chat/assets/stylesheets/common/chat-side-panel-resizer.scss b/plugins/chat/assets/stylesheets/common/chat-side-panel-resizer.scss new file mode 100644 index 00000000000..291d9e53c1b --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-side-panel-resizer.scss @@ -0,0 +1,16 @@ +.chat-side-panel-resizer { + top: 0; + bottom: 0; + left: -3px; + width: 5px; + position: absolute; + z-index: z("max"); + transition: background-color 0.15s 0.15s; + background-color: transparent; + + &:hover, + &:active { + cursor: col-resize; + background: var(--tertiary); + } +} diff --git a/plugins/chat/assets/stylesheets/common/chat-side-panel.scss b/plugins/chat/assets/stylesheets/common/chat-side-panel.scss index 09e2e22c3e0..f3139712ab6 100644 --- a/plugins/chat/assets/stylesheets/common/chat-side-panel.scss +++ b/plugins/chat/assets/stylesheets/common/chat-side-panel.scss @@ -1,13 +1,9 @@ #main-chat-outlet.chat-view { min-height: 0; display: grid; - grid-template-rows: 1fr; + grid-template-rows: 100%; grid-template-areas: "main threads"; - grid-template-columns: 1fr; - - &.has-side-panel-expanded { - grid-template-columns: 3fr 2fr; - } + grid-template-columns: 1fr auto; } .chat-side-panel { @@ -15,6 +11,8 @@ min-height: 100%; box-sizing: border-box; border-left: 1px solid var(--primary-low); + position: relative; + min-width: 250px; &__list { flex-grow: 1; diff --git a/plugins/chat/assets/stylesheets/common/chat-thread.scss b/plugins/chat/assets/stylesheets/common/chat-thread.scss index 4204eeead4b..d124fbd8ad4 100644 --- a/plugins/chat/assets/stylesheets/common/chat-thread.scss +++ b/plugins/chat/assets/stylesheets/common/chat-thread.scss @@ -2,6 +2,7 @@ display: flex; flex-direction: column; height: 100%; + position: relative; &__header { height: var(--chat-header-offset); @@ -27,4 +28,8 @@ flex-direction: column-reverse; will-change: transform; } + + .chat-composer { + padding-bottom: 28px; + } } diff --git a/plugins/chat/assets/stylesheets/common/chat-upload-drop-zone.scss b/plugins/chat/assets/stylesheets/common/chat-upload-drop-zone.scss new file mode 100644 index 00000000000..b313056f3aa --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-upload-drop-zone.scss @@ -0,0 +1,77 @@ +.chat-upload-drop-zone { + position: absolute; + visibility: hidden; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: z("max"); + align-items: center; + justify-content: center; + display: flex; + background: rgba(var(--always-black-rgb), 0.85); + + .uppy-is-drag-over & { + visibility: visible; + } + + &__content { + position: relative; + width: 50%; + height: 50%; + } + + &__background { + svg { + transform: scale(0.1); + transition: transform 200ms ease-in-out; + height: 80px; + + .uppy-is-drag-over & { + transform: scale(1); + } + } + + position: absolute; + top: 0; + left: calc(50% - 100px / 2); + z-index: 1; + } + + &__illustration { + svg { + transform: scale(0.1); + transition: transform 200ms ease-in-out; + height: 80px; + + .uppy-is-drag-over & { + transform: scale(1); + } + } + + position: absolute; + top: 0; + left: calc(50% - 100px / 2); + z-index: 1; + } + + &__text { + position: absolute; + top: 100px; + left: 0; + right: 0; + width: 100%; + z-index: 1; + display: flex; + justify-content: center; + + &__title { + width: 100%; + font-weight: 600; + text-align: center; + font-size: var(--font-up-2); + padding-inline: 1rem; + color: var(--secondary-or-primary); + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/index.scss b/plugins/chat/assets/stylesheets/common/index.scss index 6f5cad90861..713e5ef5460 100644 --- a/plugins/chat/assets/stylesheets/common/index.scss +++ b/plugins/chat/assets/stylesheets/common/index.scss @@ -1,6 +1,7 @@ @import "base-common"; @import "sidebar-extensions"; @import "chat-browse"; +@import "chat-channel"; @import "chat-channel-card"; @import "chat-channel-info"; @import "chat-channel-preview-card"; @@ -35,6 +36,8 @@ @import "chat-skeleton"; @import "chat-tabs"; @import "chat-thread"; +@import "chat-side-panel-resizer"; +@import "chat-upload-drop-zone"; @import "chat-transcript"; @import "core-extensions"; @import "create-channel-modal"; diff --git a/plugins/chat/assets/stylesheets/desktop/base-desktop.scss b/plugins/chat/assets/stylesheets/desktop/base-desktop.scss index 3eb8d02902b..121e85ab6e5 100644 --- a/plugins/chat/assets/stylesheets/desktop/base-desktop.scss +++ b/plugins/chat/assets/stylesheets/desktop/base-desktop.scss @@ -7,7 +7,7 @@ &.teams-sidebar-on { grid-template-columns: 1fr; - .chat-live-pane { + .chat-channel { border-radius: var(--full-page-border-radius); } } @@ -19,7 +19,7 @@ flex-shrink: 0; } - .chat-live-pane { + .chat-channel { .chat-messages-container { .chat-message { &.is-reply { @@ -87,7 +87,7 @@ border-right: 1px solid var(--primary-low); border-left: 1px solid var(--primary-low); - .chat-live-pane { + .chat-channel { border-radius: unset; } } @@ -112,7 +112,7 @@ } .full-page-chat.teams-sidebar-on { - .chat-live-pane { + .chat-channel { border-radius: 0; } diff --git a/plugins/chat/assets/stylesheets/mobile/base-mobile.scss b/plugins/chat/assets/stylesheets/mobile/base-mobile.scss index 107c8629dcc..d44ae726388 100644 --- a/plugins/chat/assets/stylesheets/mobile/base-mobile.scss +++ b/plugins/chat/assets/stylesheets/mobile/base-mobile.scss @@ -15,7 +15,7 @@ html.has-full-page-chat { padding: 0; .main-chat-outlet { - .chat-live-pane { + .chat-channel { min-width: 0; } @@ -23,7 +23,7 @@ html.has-full-page-chat { grid-template-columns: 1fr; grid-template-areas: "threads"; - .chat-live-pane { + .chat-channel { display: none; } } @@ -45,7 +45,7 @@ html.has-full-page-chat { } } - .chat-live-pane { + .chat-channel { border-radius: 0; padding: 0; } diff --git a/plugins/chat/assets/stylesheets/mobile/chat-composer-upload.scss b/plugins/chat/assets/stylesheets/mobile/chat-composer-upload.scss new file mode 100644 index 00000000000..b99e9877373 --- /dev/null +++ b/plugins/chat/assets/stylesheets/mobile/chat-composer-upload.scss @@ -0,0 +1,11 @@ +.chat-composer-upload { + &__remove-btn { + visibility: visible; + background: rgba(var(--always-black-rgb), 0.9); + border-radius: 100%; + + // overwrite ios style + font-size: var(--font-down-2) !important; + padding: 5px !important; + } +} diff --git a/plugins/chat/assets/stylesheets/mobile/chat-message.scss b/plugins/chat/assets/stylesheets/mobile/chat-message.scss index c6f772e9b60..84874803e56 100644 --- a/plugins/chat/assets/stylesheets/mobile/chat-message.scss +++ b/plugins/chat/assets/stylesheets/mobile/chat-message.scss @@ -2,7 +2,7 @@ #skip-link, .d-header, .chat-message-actions-mobile-outlet, - .chat-live-pane, + .chat-channel, .chat-thread { > * { @include user-select(none); diff --git a/plugins/chat/assets/stylesheets/mobile/index.scss b/plugins/chat/assets/stylesheets/mobile/index.scss index 1fa0be669d6..90fce33e9d0 100644 --- a/plugins/chat/assets/stylesheets/mobile/index.scss +++ b/plugins/chat/assets/stylesheets/mobile/index.scss @@ -6,3 +6,4 @@ @import "chat-message"; @import "chat-selection-manager"; @import "chat-emoji-picker"; +@import "chat-composer-upload"; diff --git a/plugins/chat/config/locales/client.en.yml b/plugins/chat/config/locales/client.en.yml index 4ccb13f1199..3261b7f2ac2 100644 --- a/plugins/chat/config/locales/client.en.yml +++ b/plugins/chat/config/locales/client.en.yml @@ -221,7 +221,8 @@ en: close_full_page: "Close full-screen chat" open_message: "Open message in chat" placeholder_self: "Jot something down" - placeholder_channel: "Chat with %{channelName}" + placeholder_channel: "Chat in %{channelName}" + placeholder_thread: "Chat in thread" placeholder_users: "Chat with %{commaSeparatedNames}" placeholder_new_message_disallowed: archived: "Channel is archived, you cannot send new messages right now." @@ -257,6 +258,8 @@ en: title: "chat" title_capitalized: "Chat" upload: "Attach a file" + upload_to_channel: "Upload to %{title}" + upload_to_thread: "Upload to thread" uploaded_files: one: "%{count} file" other: "%{count} files" @@ -399,6 +402,7 @@ en: italic_text: "emphasized text" bold_text: "strong text" code_text: "code text" + send: "Send" quote: original_channel: 'Originally sent in %{channel}' @@ -539,6 +543,7 @@ en: one: "%{count} reply" other: "%{count} replies" label: Thread + close: "Close Thread" threads: started_by: "Started by" open: "Open Thread" diff --git a/plugins/chat/spec/system/chat_composer_spec.rb b/plugins/chat/spec/system/chat_composer_spec.rb index fde6408d1a0..22d59dd6e55 100644 --- a/plugins/chat/spec/system/chat_composer_spec.rb +++ b/plugins/chat/spec/system/chat_composer_spec.rb @@ -10,10 +10,83 @@ RSpec.describe "Chat composer", type: :system, js: true do before { chat_system_bootstrap } - xit "it stores draft in replies" do - end + context "when loading a channel with a draft" do + fab!(:draft_1) do + Chat::Draft.create!( + chat_channel: channel_1, + user: current_user, + data: { message: "draft" }.to_json, + ) + end - xit "it stores draft" do + before do + channel_1.add(current_user) + sign_in(current_user) + end + + it "loads the draft" do + chat.visit_channel(channel_1) + + expect(find(".chat-composer__input").value).to eq("draft") + end + + context "with uploads" do + fab!(:upload_1) do + Fabricate( + :upload, + url: "/images/logo-dark.png", + original_filename: "logo_dark.png", + width: 400, + height: 300, + extension: "png", + ) + end + + fab!(:draft_1) do + Chat::Draft.create!( + chat_channel: channel_1, + user: current_user, + data: { message: "draft", uploads: [upload_1] }.to_json, + ) + end + + it "loads the draft with the upload" do + chat.visit_channel(channel_1) + + expect(find(".chat-composer__input").value).to eq("draft") + expect(page).to have_selector(".chat-composer-upload--image", count: 1) + end + end + + context "when replying" do + fab!(:draft_1) do + Chat::Draft.create!( + chat_channel: channel_1, + user: current_user, + data: { + message: "draft", + replyToMsg: { + id: message_1.id, + excerpt: message_1.excerpt, + user: { + id: message_1.user.id, + name: nil, + avatar_template: message_1.user.avatar_template, + username: message_1.user.username, + }, + }, + }.to_json, + ) + end + + it "loads the draft with replied to mesage" do + chat.visit_channel(channel_1) + + expect(find(".chat-composer__input").value).to eq("draft") + expect(page).to have_selector(".chat-reply__username", text: message_1.user.username) + expect(page).to have_selector(".chat-reply__excerpt", text: message_1.excerpt) + end + end end context "when replying to a message" do @@ -62,17 +135,17 @@ RSpec.describe "Chat composer", type: :system, js: true do ".chat-composer-message-details .chat-reply__username", text: current_user.username, ) - expect(find(".chat-composer-input").value).to eq(message_2.message) + expect(find(".chat-composer__input").value).to eq(message_2.message) end context "when pressing escape" do it "cancels editing" do chat.visit_channel(channel_1) channel.edit_message(message_2) - find(".chat-composer-input").send_keys(:escape) + find(".chat-composer__input").send_keys(:escape) expect(page).to have_no_selector(".chat-composer-message-details .chat-reply__username") - expect(find(".chat-composer-input").value).to eq("") + expect(find(".chat-composer__input").value).to eq("") end end @@ -83,7 +156,7 @@ RSpec.describe "Chat composer", type: :system, js: true do find(".cancel-message-action").click expect(page).to have_no_selector(".chat-composer-message-details .chat-reply__username") - expect(find(".chat-composer-input").value).to eq("") + expect(find(".chat-composer__input").value).to eq("") end end end @@ -100,7 +173,7 @@ RSpec.describe "Chat composer", type: :system, js: true do channel.click_action_button("emoji") find("[data-emoji='grimacing']").click(wait: 0.5) - expect(find(".chat-composer-input").value).to eq(":grimacing:") + expect(find(".chat-composer__input").value).to eq(":grimacing:") end it "removes denied emojis from insert emoji picker" do @@ -123,20 +196,20 @@ RSpec.describe "Chat composer", type: :system, js: true do it "adds the emoji to the composer" do chat.visit_channel(channel_1) - find(".chat-composer-input").fill_in(with: ":gri") + find(".chat-composer__input").fill_in(with: ":gri") find(".emoji-shortname", text: "grimacing").click - expect(find(".chat-composer-input").value).to eq(":grimacing: ") + expect(find(".chat-composer__input").value).to eq(":grimacing: ") end it "doesn't suggest denied emojis and aliases" do SiteSetting.emoji_deny_list = "peach|poop" chat.visit_channel(channel_1) - find(".chat-composer-input").fill_in(with: ":peac") + find(".chat-composer__input").fill_in(with: ":peac") expect(page).to have_no_selector(".emoji-shortname", text: "peach") - find(".chat-composer-input").fill_in(with: ":hank") # alias + find(".chat-composer__input").fill_in(with: ":hank") # alias expect(page).to have_no_selector(".emoji-shortname", text: "poop") end end @@ -149,7 +222,7 @@ RSpec.describe "Chat composer", type: :system, js: true do xit "prefills the emoji picker filter input" do chat.visit_channel(channel_1) - find(".chat-composer-input").fill_in(with: ":gri") + find(".chat-composer__input").fill_in(with: ":gri") click_link(I18n.t("js.composer.more_emoji")) @@ -158,7 +231,7 @@ RSpec.describe "Chat composer", type: :system, js: true do it "filters with the prefilled input" do chat.visit_channel(channel_1) - find(".chat-composer-input").fill_in(with: ":fr") + find(".chat-composer__input").fill_in(with: ":fr") click_link(I18n.t("js.composer.more_emoji")) @@ -178,15 +251,15 @@ RSpec.describe "Chat composer", type: :system, js: true do find("body").send_keys("b") - expect(find(".chat-composer-input").value).to eq("b") + expect(find(".chat-composer__input").value).to eq("b") find("body").send_keys("b") - expect(find(".chat-composer-input").value).to eq("bb") + expect(find(".chat-composer__input").value).to eq("bb") find("body").send_keys(:enter) # special case - expect(find(".chat-composer-input").value).to eq("bb") + expect(find(".chat-composer__input").value).to eq("bb") end end end diff --git a/plugins/chat/spec/system/chat_message/channel_spec.rb b/plugins/chat/spec/system/chat_message/channel_spec.rb index 39d9556bd06..a91c6b94511 100644 --- a/plugins/chat/spec/system/chat_message/channel_spec.rb +++ b/plugins/chat/spec/system/chat_message/channel_spec.rb @@ -22,7 +22,7 @@ RSpec.describe "Chat message", type: :system, js: true do channel.hover_message(message_1) expect(page).to have_css( - ".chat-live-pane[data-id='#{channel_1.id}'] [data-id='#{message_1.id}'] .chat-message.is-active", + ".chat-channel[data-id='#{channel_1.id}'] [data-id='#{message_1.id}'] .chat-message.is-active", ) end end diff --git a/plugins/chat/spec/system/chat_message/thread_spec.rb b/plugins/chat/spec/system/chat_message/thread_spec.rb index aaa8aa1287a..187a6aef2cc 100644 --- a/plugins/chat/spec/system/chat_message/thread_spec.rb +++ b/plugins/chat/spec/system/chat_message/thread_spec.rb @@ -11,6 +11,7 @@ RSpec.describe "Chat message - channel", type: :system, js: true do let(:cdp) { PageObjects::CDP.new } let(:chat) { PageObjects::Pages::Chat.new } let(:channel) { PageObjects::Pages::ChatChannel.new } + let(:thread) { PageObjects::Pages::ChatThread.new } let(:message_1) { thread_1.chat_messages.first } before do @@ -24,12 +25,13 @@ RSpec.describe "Chat message - channel", type: :system, js: true do context "when hovering a message" do it "adds an active class" do + last_message = thread_1.chat_messages.last chat.visit_thread(thread_1) - channel.hover_message(message_1) + thread.hover_message(last_message) expect(page).to have_css( - ".chat-thread[data-id='#{thread_1.id}'] [data-id='#{message_1.id}'] .chat-message.is-active", + ".chat-thread[data-id='#{thread_1.id}'] [data-id='#{last_message.id}'] .chat-message.is-active", ) end end diff --git a/plugins/chat/spec/system/drawer_spec.rb b/plugins/chat/spec/system/drawer_spec.rb index 125b1cfdc42..e85004efa27 100644 --- a/plugins/chat/spec/system/drawer_spec.rb +++ b/plugins/chat/spec/system/drawer_spec.rb @@ -81,7 +81,7 @@ RSpec.describe "Drawer", type: :system, js: true do channel_page.hover_message(message_1) expect(page).to have_css(".chat-message-actions-container") - find(".chat-composer-input").send_keys(:escape) + find(".chat-composer__input").send_keys(:escape) expect(page).to have_no_css(".chat-message-actions-container") end diff --git a/plugins/chat/spec/system/message_thread_indicator_spec.rb b/plugins/chat/spec/system/message_thread_indicator_spec.rb index 8135a1023fa..d1985046012 100644 --- a/plugins/chat/spec/system/message_thread_indicator_spec.rb +++ b/plugins/chat/spec/system/message_thread_indicator_spec.rb @@ -88,8 +88,15 @@ describe "Thread indicator for chat messages", type: :system, js: true do channel_page.reply_to(message_without_thread) channel_page.fill_composer("this is a reply to make a new thread") channel_page.click_send_message + expect(channel_page).to have_thread_indicator(message_without_thread) - new_thread = message_without_thread.reload.thread + + new_thread = nil + try_until_success(timeout: 5) do + new_thread = message_without_thread.reload.thread + expect(new_thread).to be_present + end + expect(page).not_to have_css(channel_page.message_by_id_selector(new_thread.replies.first)) end diff --git a/plugins/chat/spec/system/page_objects/chat/chat.rb b/plugins/chat/spec/system/page_objects/chat/chat.rb index 0e80ff632d1..8079a10f60b 100644 --- a/plugins/chat/spec/system/page_objects/chat/chat.rb +++ b/plugins/chat/spec/system/page_objects/chat/chat.rb @@ -19,7 +19,7 @@ module PageObjects def visit_channel(channel, mobile: false) visit(channel.url + (mobile ? "?mobile_view=1" : "")) - has_no_css?(".not-loaded-once") + has_no_css?(".chat-channel--not-loaded-once") has_no_css?(".chat-skeleton") end diff --git a/plugins/chat/spec/system/page_objects/chat/chat_channel.rb b/plugins/chat/spec/system/page_objects/chat/chat_channel.rb index f2e193ade82..6975f561410 100644 --- a/plugins/chat/spec/system/page_objects/chat/chat_channel.rb +++ b/plugins/chat/spec/system/page_objects/chat/chat_channel.rb @@ -4,25 +4,25 @@ module PageObjects module Pages class ChatChannel < PageObjects::Pages::Base def type_in_composer(input) - find(".chat-composer-input--channel").click # makes helper more reliable by ensuring focus is not lost - find(".chat-composer-input--channel").send_keys(input) + find(".chat-channel .chat-composer__input").click # makes helper more reliable by ensuring focus is not lost + find(".chat-channel .chat-composer__input").send_keys(input) end def fill_composer(input) - find(".chat-composer-input--channel").click # makes helper more reliable by ensuring focus is not lost - find(".chat-composer-input--channel").fill_in(with: input) + find(".chat-channel .chat-composer__input").click # makes helper more reliable by ensuring focus is not lost + find(".chat-channel .chat-composer__input").fill_in(with: input) end def click_composer - find(".chat-composer-input--channel").click # ensures autocomplete is closed and not masking anything + find(".chat-channel .chat-composer__input").click # ensures autocomplete is closed and not masking anything end def click_send_message - find(".chat-composer .send-btn:enabled").click + find(".chat-composer--send-enabled .chat-composer__send-btn").click end def message_by_id_selector(id) - ".chat-live-pane .chat-messages-container .chat-message-container[data-id=\"#{id}\"]" + ".chat-channel .chat-messages-container .chat-message-container[data-id=\"#{id}\"]" end def message_by_id(id) diff --git a/plugins/chat/spec/system/page_objects/chat/chat_thread.rb b/plugins/chat/spec/system/page_objects/chat/chat_thread.rb index cf857d805fb..c76e4d68945 100644 --- a/plugins/chat/spec/system/page_objects/chat/chat_thread.rb +++ b/plugins/chat/spec/system/page_objects/chat/chat_thread.rb @@ -24,17 +24,17 @@ module PageObjects end def type_in_composer(input) - find(".chat-composer-input--thread").click # makes helper more reliable by ensuring focus is not lost - find(".chat-composer-input--thread").send_keys(input) + find(".chat-thread .chat-composer__input").click # makes helper more reliable by ensuring focus is not lost + find(".chat-thread .chat-composer__input").send_keys(input) end def fill_composer(input) - find(".chat-composer-input--thread").click # makes helper more reliable by ensuring focus is not lost - find(".chat-composer-input--thread").fill_in(with: input) + find(".chat-thread .chat-composer__input").click # makes helper more reliable by ensuring focus is not lost + find(".chat-thread .chat-composer__input").fill_in(with: input) end def click_composer - find(".chat-composer-input--thread").click # ensures autocomplete is closed and not masking anything + find(".chat-thread .chat-composer__input").click # ensures autocomplete is closed and not masking anything end def send_message(id, text = nil) @@ -45,7 +45,9 @@ module PageObjects end def click_send_message(id) - find(thread_selector_by_id(id)).find(".chat-composer .send-btn:enabled").click + find(thread_selector_by_id(id)).find( + ".chat-composer--send-enabled .chat-composer__send-btn", + ).click end def has_message?(thread_id, text: nil, id: nil) @@ -73,6 +75,18 @@ module PageObjects ) end end + + def hover_message(message) + message_by_id(message.id).hover + end + + def message_by_id(id) + find(message_by_id_selector(id)) + end + + def message_by_id_selector(id) + ".chat-thread .chat-messages-container .chat-message-container[data-id=\"#{id}\"]" + end end end end diff --git a/plugins/chat/spec/system/page_objects/chat_drawer/chat_drawer.rb b/plugins/chat/spec/system/page_objects/chat_drawer/chat_drawer.rb index 64f5816abbc..a6befde3676 100644 --- a/plugins/chat/spec/system/page_objects/chat_drawer/chat_drawer.rb +++ b/plugins/chat/spec/system/page_objects/chat_drawer/chat_drawer.rb @@ -36,7 +36,7 @@ module PageObjects end def has_open_channel?(channel) - has_css?("#{VISIBLE_DRAWER} .chat-live-pane[data-id='#{channel.id}']") + has_css?("#{VISIBLE_DRAWER} .chat-channel[data-id='#{channel.id}']") end end end diff --git a/plugins/chat/spec/system/shortcuts/chat_composer_spec.rb b/plugins/chat/spec/system/shortcuts/chat_composer_spec.rb index 07084cdddf4..15f3ffd003e 100644 --- a/plugins/chat/spec/system/shortcuts/chat_composer_spec.rb +++ b/plugins/chat/spec/system/shortcuts/chat_composer_spec.rb @@ -23,7 +23,7 @@ RSpec.describe "Shortcuts | chat composer", type: :system, js: true do it "adds bold text" do chat.visit_channel(channel_1) - composer = find(".chat-composer-input") + composer = find(".chat-composer__input") composer.send_keys([key_modifier, "b"]) expect(composer.value).to eq("**strong text**") @@ -34,7 +34,7 @@ RSpec.describe "Shortcuts | chat composer", type: :system, js: true do it "adds italic text" do chat.visit_channel(channel_1) - composer = find(".chat-composer-input") + composer = find(".chat-composer__input") composer.send_keys([key_modifier, "i"]) expect(composer.value).to eq("_emphasized text_") @@ -45,7 +45,7 @@ RSpec.describe "Shortcuts | chat composer", type: :system, js: true do it "adds preformatted text" do chat.visit_channel(channel_1) - composer = find(".chat-composer-input") + composer = find(".chat-composer__input") composer.send_keys([key_modifier, "e"]) expect(composer.value).to eq("`indent preformatted text by 4 spaces`") @@ -71,8 +71,8 @@ RSpec.describe "Shortcuts | chat composer", type: :system, js: true do chat.visit_channel(channel_1) channel_page.message_thread_indicator(thread.original_message).click - composer = find(".chat-composer-input--channel") - thread_composer = find(".chat-composer-input--thread") + composer = find(".chat-channel .chat-composer__input") + thread_composer = find(".chat-thread .chat-composer__input") composer.send_keys([key_modifier, "i"]) expect(composer.value).to eq("_emphasized text_") @@ -98,7 +98,7 @@ RSpec.describe "Shortcuts | chat composer", type: :system, js: true do chat.visit_channel(channel_1) expect(channel_page).to have_message(id: message_1.id) - find(".chat-composer-input").send_keys(:arrow_up) + find(".chat-composer__input").send_keys(:arrow_up) expect(page.find(".chat-composer-message-details")).to have_content(message_1.message) end @@ -111,7 +111,7 @@ RSpec.describe "Shortcuts | chat composer", type: :system, js: true do page.driver.browser.network_conditions = { offline: true } channel_page.send_message("Hello world") - find(".chat-composer-input").send_keys(:arrow_up) + find(".chat-composer__input").send_keys(:arrow_up) expect(page).to have_no_css(".chat-composer-message-details") end diff --git a/plugins/chat/spec/system/shortcuts/drawer_spec.rb b/plugins/chat/spec/system/shortcuts/drawer_spec.rb index d9c3640df2b..6333afca295 100644 --- a/plugins/chat/spec/system/shortcuts/drawer_spec.rb +++ b/plugins/chat/spec/system/shortcuts/drawer_spec.rb @@ -36,7 +36,7 @@ RSpec.describe "Shortcuts | drawer", type: :system, js: true do expect(page).to have_css(".chat-drawer.is-expanded") drawer.open_channel(channel_1) - find(".chat-composer-input").send_keys(:escape) + find(".chat-composer__input").send_keys(:escape) expect(page).to have_no_css(".chat-drawer.is-expanded") end @@ -49,7 +49,7 @@ RSpec.describe "Shortcuts | drawer", type: :system, js: true do page.send_keys("e") - expect(find(".chat-composer-input").value).to eq("") + expect(find(".chat-composer__input").value).to eq("") end end @@ -59,15 +59,15 @@ RSpec.describe "Shortcuts | drawer", type: :system, js: true do expect(page).to have_selector(".chat-drawer[data-chat-channel-id=\"#{channel_1.id}\"]") - find(".chat-composer-input").send_keys(%i[alt arrow_down]) + find(".chat-composer__input").send_keys(%i[alt arrow_down]) expect(page).to have_selector(".chat-drawer[data-chat-channel-id=\"#{channel_2.id}\"]") - find(".chat-composer-input").send_keys(%i[alt arrow_down]) + find(".chat-composer__input").send_keys(%i[alt arrow_down]) expect(page).to have_selector(".chat-drawer[data-chat-channel-id=\"#{channel_1.id}\"]") - find(".chat-composer-input").send_keys(%i[alt arrow_up]) + find(".chat-composer__input").send_keys(%i[alt arrow_up]) expect(page).to have_selector(".chat-drawer[data-chat-channel-id=\"#{channel_2.id}\"]") end diff --git a/plugins/chat/spec/system/shortcuts/full_page_spec.rb b/plugins/chat/spec/system/shortcuts/full_page_spec.rb index 84478929be6..6b833b11dae 100644 --- a/plugins/chat/spec/system/shortcuts/full_page_spec.rb +++ b/plugins/chat/spec/system/shortcuts/full_page_spec.rb @@ -19,7 +19,7 @@ RSpec.describe "Shortcuts | full page", type: :system, js: true do page.send_keys("e") - expect(find(".chat-composer-input").value).to eq("e") + expect(find(".chat-composer__input").value).to eq("e") end end end diff --git a/plugins/chat/spec/system/sidebar_navigation_menu_spec.rb b/plugins/chat/spec/system/sidebar_navigation_menu_spec.rb index 869ef68f2e0..1be4ec80d91 100644 --- a/plugins/chat/spec/system/sidebar_navigation_menu_spec.rb +++ b/plugins/chat/spec/system/sidebar_navigation_menu_spec.rb @@ -182,7 +182,7 @@ RSpec.describe "Sidebar navigation menu", type: :system, js: true do visit("/") expect(sidebar_page.dms_section.find(".channel-#{dm_channel_1.id}")["title"]).to eq( - "Chat with @<script>alert('hello')</script>", + "Chat in @<script>alert('hello')</script>", ) end end diff --git a/plugins/chat/spec/system/silenced_user_spec.rb b/plugins/chat/spec/system/silenced_user_spec.rb index 50a73ee1497..5c845134d59 100644 --- a/plugins/chat/spec/system/silenced_user_spec.rb +++ b/plugins/chat/spec/system/silenced_user_spec.rb @@ -27,10 +27,10 @@ RSpec.describe "Silenced user", type: :system, js: true do ) end - it "removes the send button" do + it "disables the send button" do chat.visit_channel(channel_1) - expect(page).to have_css(".send-btn[disabled]") + expect(page).to have_css(".chat-composer__send-btn[disabled]") end it "prevents reactions" do diff --git a/plugins/chat/spec/system/uploads_spec.rb b/plugins/chat/spec/system/uploads_spec.rb index 7000e8e178c..ab9339efbea 100644 --- a/plugins/chat/spec/system/uploads_spec.rb +++ b/plugins/chat/spec/system/uploads_spec.rb @@ -25,7 +25,6 @@ describe "Uploading files in chat messages", type: :system, js: true do end expect(page).to have_css(".chat-composer-upload .preview .preview-img") - expect(page).to have_content(File.basename(file_path)) channel.send_message("upload testing") @@ -61,7 +60,6 @@ describe "Uploading files in chat messages", type: :system, js: true do channel.click_action_button("chat-upload-btn") end - expect(page).to have_content(File.basename(file_path)) expect(find(".chat-composer-upload")).to have_content("Processing") # image processing clientside is slow! here we are waiting for processing @@ -101,10 +99,11 @@ describe "Uploading files in chat messages", type: :system, js: true do it "allows deleting uploads" do chat.visit_channel(channel_1) channel.open_edit_message(message_2) - find(".chat-composer-upload").find(".remove-upload").click + find(".chat-composer-upload").hover + find(".chat-composer-upload__remove-btn").click channel.click_send_message expect(channel.message_by_id(message_2.id)).not_to have_css(".chat-uploads") - expect(message_2.reload.uploads).to be_empty + try_until_success(timeout: 5) { expect(message_2.reload.uploads).to be_empty } end it "allows adding more uploads" do @@ -118,13 +117,13 @@ describe "Uploading files in chat messages", type: :system, js: true do end expect(page).to have_css(".chat-composer-upload .preview .preview-img", count: 2) - expect(page).to have_content(File.basename(file_path)) channel.click_send_message expect(page).not_to have_css(".chat-composer-upload") expect(page).to have_css(".chat-img-upload", count: 2) - expect(message_2.reload.uploads.count).to eq(2) + + try_until_success(timeout: 5) { expect(message_2.reload.uploads.count).to eq(2) } end end diff --git a/plugins/chat/test/javascripts/acceptance/chat-composer-test.js b/plugins/chat/test/javascripts/acceptance/chat-composer-test.js index b842f618814..26f06d481f7 100644 --- a/plugins/chat/test/javascripts/acceptance/chat-composer-test.js +++ b/plugins/chat/test/javascripts/acceptance/chat-composer-test.js @@ -58,12 +58,12 @@ acceptance("Discourse Chat - Composer", function (needs) { }; document - .querySelector(".chat-composer-input") + .querySelector(".chat-composer__input") .dispatchEvent(clipboardEvent); await settled(); - assert.equal(document.querySelector(".chat-composer-input").value, "Foo"); + assert.equal(document.querySelector(".chat-composer__input").value, "Foo"); }); }); @@ -97,7 +97,7 @@ acceptance("Discourse Chat - Composer - unreliable network", function (needs) { skip("Sending a message with unreliable network", async function (assert) { await visit("/chat/c/-/11"); - await fillIn(".chat-composer-input", "network-error-message"); + await fillIn(".chat-composer__input", "network-error-message"); await click(".send-btn"); assert.ok( @@ -105,7 +105,7 @@ acceptance("Discourse Chat - Composer - unreliable network", function (needs) { "it adds a retry button" ); - await fillIn(".chat-composer-input", "network-error-message"); + await fillIn(".chat-composer__input", "network-error-message"); await click(".send-btn"); await publishToMessageBus(`/chat/11`, { type: "sent", @@ -126,7 +126,7 @@ acceptance("Discourse Chat - Composer - unreliable network", function (needs) { "it sends the message" ); assert.strictEqual( - query(".chat-composer-input").value, + query(".chat-composer__input").value, "", "it clears the input" ); diff --git a/plugins/chat/test/javascripts/components/chat-composer-placeholder-test.js b/plugins/chat/test/javascripts/components/chat-composer-placeholder-test.js index 718b40e4a9c..eb9342fb608 100644 --- a/plugins/chat/test/javascripts/components/chat-composer-placeholder-test.js +++ b/plugins/chat/test/javascripts/components/chat-composer-placeholder-test.js @@ -15,20 +15,17 @@ module( pretender.get("/chat/emojis.json", () => [200, [], {}]); this.currentUser.set("id", 1); - this.set( - "chatChannel", - ChatChannel.create({ - chatable_type: "DirectMessage", - chatable: { - users: [{ id: 1 }], - }, - }) - ); + this.channel = ChatChannel.create({ + chatable_type: "DirectMessage", + chatable: { + users: [{ id: 1 }], + }, + }); - await render(hbs``); + await render(hbs``); assert.strictEqual( - query(".chat-composer-input").placeholder, + query(".chat-composer__input").placeholder, "Jot something down" ); }); @@ -36,24 +33,21 @@ module( test("direct message to multiple folks shows their names", async function (assert) { pretender.get("/chat/emojis.json", () => [200, [], {}]); - this.set( - "chatChannel", - ChatChannel.create({ - chatable_type: "DirectMessage", - chatable: { - users: [ - { name: "Tomtom" }, - { name: "Steaky" }, - { username: "zorro" }, - ], - }, - }) - ); + this.channel = ChatChannel.create({ + chatable_type: "DirectMessage", + chatable: { + users: [ + { name: "Tomtom" }, + { name: "Steaky" }, + { username: "zorro" }, + ], + }, + }); - await render(hbs``); + await render(hbs``); assert.strictEqual( - query(".chat-composer-input").placeholder, + query(".chat-composer__input").placeholder, "Chat with Tomtom, Steaky, @zorro" ); }); @@ -61,19 +55,16 @@ module( test("message to channel shows send message to channel name", async function (assert) { pretender.get("/chat/emojis.json", () => [200, [], {}]); - this.set( - "chatChannel", - ChatChannel.create({ - chatable_type: "Category", - title: "just-cats", - }) - ); + this.channel = ChatChannel.create({ + chatable_type: "Category", + title: "just-cats", + }); - await render(hbs``); + await render(hbs``); assert.strictEqual( - query(".chat-composer-input").placeholder, - "Chat with #just-cats" + query(".chat-composer__input").placeholder, + "Chat in #just-cats" ); }); } diff --git a/plugins/chat/test/javascripts/components/chat-composer-upload-test.js b/plugins/chat/test/javascripts/components/chat-composer-upload-test.js index 7a5d3526b2a..f57820c9f70 100644 --- a/plugins/chat/test/javascripts/components/chat-composer-upload-test.js +++ b/plugins/chat/test/javascripts/components/chat-composer-upload-test.js @@ -86,8 +86,6 @@ module("Discourse Chat | Component | chat-composer-upload", function (hooks) { ); assert.true(exists("img.preview-img[src='/images/avatar.png']")); - assert.strictEqual(query(".file-name").innerText.trim(), "bar_image.png"); - assert.strictEqual(query(".extension-pill").innerText.trim(), "png"); }); test("removing completed upload", async function (assert) { @@ -106,7 +104,7 @@ module("Discourse Chat | Component | chat-composer-upload", function (hooks) { hbs`` ); - await click(".remove-upload"); + await click(".chat-composer-upload__remove-btn"); assert.strictEqual(this.uploadRemoved, true); }); @@ -126,7 +124,7 @@ module("Discourse Chat | Component | chat-composer-upload", function (hooks) { hbs`` ); - await click(".remove-upload"); + await click(".chat-composer-upload__remove-btn"); assert.strictEqual(this.uploadRemoved, true); }); }); diff --git a/plugins/chat/test/javascripts/components/chat-composer-uploads-test.js b/plugins/chat/test/javascripts/components/chat-composer-uploads-test.js index 1a3872bd2c2..6d119715028 100644 --- a/plugins/chat/test/javascripts/components/chat-composer-uploads-test.js +++ b/plugins/chat/test/javascripts/components/chat-composer-uploads-test.js @@ -97,7 +97,7 @@ module("Discourse Chat | Component | chat-composer-uploads", function (hooks) { assert.dom(".chat-composer-upload").exists({ count: 1 }); - await click(".remove-upload"); + await click(".chat-composer-upload__remove-btn"); assert.dom(".chat-composer-upload").exists({ count: 0 }); }); @@ -138,7 +138,7 @@ module("Discourse Chat | Component | chat-composer-uploads", function (hooks) { await waitFor(".chat-composer-upload"); assert.strictEqual(count(".chat-composer-upload"), 1); - await click(".remove-upload"); + await click(".chat-composer-upload__remove-btn"); assert.strictEqual(count(".chat-composer-upload"), 0); }); }); diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 666e83a87b7..f75359e5e00 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -296,7 +296,7 @@ RSpec.configure do |config| Selenium::WebDriver::Chrome::Options .new(logging_prefs: { "browser" => "INFO", "driver" => "ALL" }) .tap do |options| - options.add_argument("--window-size=390,950") + options.add_argument("--window-size=390,960") options.add_argument("--no-sandbox") options.add_argument("--disable-dev-shm-usage") options.add_emulation(device_name: "iPhone 12 Pro")