From ea548292bc52f85601cafe1282d4f0142667f15c Mon Sep 17 00:00:00 2001 From: Martin Brennan <martin@discourse.org> Date: Thu, 6 Apr 2023 23:19:52 +1000 Subject: [PATCH] DEV: Refactoring chat message actions for ChatMessage component usage in thread panel (#20756) This commit is a major overhaul of how chat message actions work, to make it so they are reusable between the main chat channel and the chat thread panel, as well as many improvements and fixes for the thread panel. There are now several new classes and concepts: * ChatMessageInteractor - This is initialized from the ChatMessage, ChatMessageActionsDesktop, and ChatMessageActionsMobile components. This handles permissions about what actions can be done for each message based on the context (thread or channel), handles the actions themselves (e.g. copyLink, delete, edit), and interacts with the pane of the current context to modify the UI * ChatChannelThreadPane and ChatChannelPane services - This represents the UI context which contains the messages, and are mostly used for state management for things like message selection. * ChatChannelThreadComposer and ChatChannelComposer - This handles interaction between the pane, the message actions, and the composer, dealing with reply and edit message state. * Scrolling logic for the messages has now been moved to a helper so it can be shared between the main channel pane and the thread pane * Various improvements with the emoji picker on both mobile and desktop. The DOM node of each component is now located outside of the message which prevents a large range of issues. The thread panel now also works in the chat drawer, and the thread messages have less actions than the main panel, since some do not make sense there (e.g. moving messages to a different channel). The thread panel title, excerpt, and message sender have also been removed for now to save space. This gives us a solid base to keep expanding on and fixing up threads. Subsequent PRs will make the thread MessageBus subscriptions work and disable echo mode for the initial release of threads. Co-authored-by: Joffrey JAFFEUX <j.jaffeux@gmail.com> --- .../chat-channel-message-emoji-picker.hbs | 7 + .../chat-channel-message-emoji-picker.js | 51 ++ .../components/chat-composer-dropdown.hbs | 38 +- .../components/chat-composer-dropdown.js | 63 ++ .../discourse/components/chat-composer.hbs | 49 +- .../discourse/components/chat-composer.js | 144 +++-- .../discourse/components/chat-drawer.hbs | 2 +- .../discourse/components/chat-drawer.js | 2 + .../components/chat-drawer/thread.hbs | 23 + .../components/chat-drawer/thread.js | 29 + .../components/chat-emoji-picker.hbs | 432 ++++++------- .../discourse/components/chat-emoji-picker.js | 15 +- .../discourse/components/chat-live-pane.hbs | 56 +- .../discourse/components/chat-live-pane.js | 477 ++++----------- .../chat-message-actions-desktop.hbs | 123 ++-- .../chat-message-actions-desktop.js | 48 +- .../chat-message-actions-mobile.hbs | 166 ++--- .../components/chat-message-actions-mobile.js | 30 +- .../chat-message-in-reply-to-indicator.js | 2 +- .../components/chat-message-reaction.js | 3 +- .../discourse/components/chat-message.hbs | 57 +- .../discourse/components/chat-message.js | 572 +++--------------- .../components/chat-selection-manager.js | 8 +- .../discourse/components/chat-thread.hbs | 97 ++- .../discourse/components/chat-thread.js | 125 ++-- ...channel-message-emoji-picker-connector.hbs | 1 + .../chat-message-actions-desktop-outlet.hbs | 1 + .../chat-message-actions-mobile-outlet.hbs | 1 + .../discourse/initializers/chat-setup.js | 18 +- .../discourse/lib/chat-composer-buttons.js | 42 +- .../discourse/lib/chat-message-container.js | 13 + .../discourse/lib/chat-message-interactor.js | 399 ++++++++++++ .../discourse/models/chat-channel.js | 9 + .../discourse/models/chat-message-reaction.js | 2 + .../discourse/models/chat-message.js | 23 +- .../discourse/models/chat-thread.js | 8 +- .../routes/chat-channel-decorator.js | 4 + .../javascripts/discourse/routes/chat.js | 1 + .../discourse/services/chat-api.js | 90 +++ .../services/chat-channel-composer.js | 131 ++++ .../chat-channel-emoji-picker-manager.js | 3 + .../discourse/services/chat-channel-pane.js | 92 +++ .../services/chat-channel-thread-composer.js | 15 + .../services/chat-channel-thread-pane.js | 14 + .../discourse/services/chat-drawer-router.js | 10 + .../services/chat-emoji-picker-manager.js | 107 +--- .../javascripts/discourse/services/chat.js | 32 + .../chat-emoji-picker-connector.hbs | 7 - .../chat-emoji-picker-connector.js | 12 - .../stylesheets/common/base-common.scss | 5 +- .../common/chat-composer-dropdown.scss | 13 +- .../stylesheets/common/chat-composer.scss | 2 + .../stylesheets/common/chat-emoji-picker.scss | 37 +- .../common/chat-message-actions.scss | 6 +- .../common/chat-message-left-gutter.scss | 2 +- .../stylesheets/common/chat-message.scss | 65 +- .../stylesheets/common/chat-side-panel.scss | 2 +- .../stylesheets/common/chat-thread.scss | 61 +- .../stylesheets/mobile/chat-emoji-picker.scss | 26 + .../chat/assets/stylesheets/mobile/index.scss | 1 + plugins/chat/config/locales/client.en.yml | 3 +- .../system/page_objects/chat/chat_channel.rb | 4 +- .../page_objects/chat_drawer/chat_drawer.rb | 4 + .../chat/spec/system/react_to_message_spec.rb | 12 +- .../chat/spec/system/single_thread_spec.rb | 13 +- plugins/chat/spec/system/transcript_spec.rb | 4 +- .../chat-composer-placeholder-test.js | 7 + .../components/chat-emoji-picker-test.js | 16 +- .../components/chat-message-reaction-test.js | 2 +- .../components/chat-message-test.js | 17 - .../chat-emoji-picker-manager-test.js | 100 +-- 71 files changed, 2169 insertions(+), 1887 deletions(-) create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-channel-message-emoji-picker.hbs create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-channel-message-emoji-picker.js create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-composer-dropdown.js create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-drawer/thread.hbs create mode 100644 plugins/chat/assets/javascripts/discourse/components/chat-drawer/thread.js create mode 100644 plugins/chat/assets/javascripts/discourse/connectors/below-footer/chat-channel-message-emoji-picker-connector.hbs create mode 100644 plugins/chat/assets/javascripts/discourse/connectors/below-footer/chat-message-actions-desktop-outlet.hbs create mode 100644 plugins/chat/assets/javascripts/discourse/connectors/below-footer/chat-message-actions-mobile-outlet.hbs create mode 100644 plugins/chat/assets/javascripts/discourse/lib/chat-message-container.js create mode 100644 plugins/chat/assets/javascripts/discourse/lib/chat-message-interactor.js create mode 100644 plugins/chat/assets/javascripts/discourse/services/chat-channel-composer.js create mode 100644 plugins/chat/assets/javascripts/discourse/services/chat-channel-emoji-picker-manager.js create mode 100644 plugins/chat/assets/javascripts/discourse/services/chat-channel-pane.js create mode 100644 plugins/chat/assets/javascripts/discourse/services/chat-channel-thread-composer.js create mode 100644 plugins/chat/assets/javascripts/discourse/services/chat-channel-thread-pane.js delete mode 100644 plugins/chat/assets/javascripts/discourse/templates/connectors/below-footer/chat-emoji-picker-connector.hbs delete mode 100644 plugins/chat/assets/javascripts/discourse/templates/connectors/below-footer/chat-emoji-picker-connector.js create mode 100644 plugins/chat/assets/stylesheets/mobile/chat-emoji-picker.scss diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-message-emoji-picker.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel-message-emoji-picker.hbs new file mode 100644 index 00000000000..50899f81535 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-message-emoji-picker.hbs @@ -0,0 +1,7 @@ +<ChatEmojiPicker + @context="chat-channel-message" + @didInsert={{this.didInsert}} + @willDestroy={{this.willDestroy}} + @didSelectEmoji={{this.didSelectEmoji}} + @class="hidden" +/> \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-message-emoji-picker.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-message-emoji-picker.js new file mode 100644 index 00000000000..f8c6d4a25d8 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-message-emoji-picker.js @@ -0,0 +1,51 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; +import { headerOffset } from "discourse/lib/offset-calculator"; +import { createPopper } from "@popperjs/core"; + +export default class ChatChannelMessageEmojiPicker extends Component { + @service site; + @service chatEmojiPickerManager; + + context = "chat-channel-message"; + + @action + didSelectEmoji(emoji) { + this.chatEmojiPickerManager.picker?.didSelectEmoji(emoji); + this.chatEmojiPickerManager.close(); + } + + @action + didInsert(element) { + if (this.site.mobileView) { + element.classList.remove("hidden"); + return; + } + + this._popper = createPopper( + this.chatEmojiPickerManager.picker?.trigger, + element, + { + placement: "top", + modifiers: [ + { + name: "eventListeners", + options: { scroll: false, resize: false }, + }, + { + name: "flip", + options: { padding: { top: headerOffset() } }, + }, + ], + } + ); + + element.classList.remove("hidden"); + } + + @action + willDestroy() { + this._popper?.destroy(); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-dropdown.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-composer-dropdown.hbs index 074cfe86282..fa5698b639d 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer-dropdown.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-dropdown.hbs @@ -1,26 +1,32 @@ {{#if @buttons.length}} - <DPopover - @class="chat-composer-dropdown" - @options={{hash arrow=null}} - as |state| - > - <FlatButton - @disabled={{@isDisabled}} - @class="chat-composer-dropdown__trigger-btn d-popover-trigger" - @title="chat.composer.toggle_toolbar" - @icon={{if state.isExpanded "times" "plus"}} - /> - <ul class="chat-composer-dropdown__list"> + <DButton + @disabled={{@isDisabled}} + @class="chat-composer-dropdown__trigger-btn btn-flat btn-icon" + @title="chat.composer.toggle_toolbar" + @icon={{if @hasActivePanel "times" "plus"}} + @action={{this.toggleExpand}} + {{did-insert this.setupTrigger}} + /> + + {{#if this.isExpanded}} + <ul + class="chat-composer-dropdown__list" + {{did-insert this.setupPanel}} + {{will-destroy this.teardownPanel}} + > {{#each @buttons as |button|}} - <li class="chat-composer-dropdown__item {{button.id}}"> + <li class={{concat-class "chat-composer-dropdown__item" button.id}}> <DButton - @class={{concat "chat-composer-dropdown__action-btn " button.id}} + @class={{concat-class + "chat-composer-dropdown__action-btn" + button.id + }} @icon={{button.icon}} - @action={{button.action}} + @action={{(fn this.onButtonClick button)}} @label={{button.label}} /> </li> {{/each}} </ul> - </DPopover> + {{/if}} {{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer-dropdown.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer-dropdown.js new file mode 100644 index 00000000000..987425ac25e --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer-dropdown.js @@ -0,0 +1,63 @@ +import Component from "@glimmer/component"; +import { iconHTML } from "discourse-common/lib/icon-library"; +import tippy from "tippy.js"; +import { action } from "@ember/object"; +import { hideOnEscapePlugin } from "discourse/lib/d-popover"; +import { tracked } from "@glimmer/tracking"; + +export default class ChatComposerDropdown extends Component { + @tracked isExpanded = false; + + trigger = null; + + @action + setupTrigger(element) { + this.trigger = element; + } + + @action + toggleExpand() { + if (this.args.hasActivePanel) { + this.args.onCloseActivePanel?.(); + } else { + this.isExpanded = !this.isExpanded; + } + } + + @action + onButtonClick(button) { + this._tippyInstance.hide(); + button.action(); + } + + @action + setupPanel(element) { + this._tippyInstance = tippy(this.trigger, { + theme: "chat-composer-drodown", + trigger: "click", + zIndex: 1400, + arrow: iconHTML("tippy-rounded-arrow"), + interactive: true, + allowHTML: false, + appendTo: "parent", + hideOnClick: true, + plugins: [hideOnEscapePlugin], + content: element, + onShow: () => { + this.isExpanded = true; + return true; + }, + onHide: () => { + this.isExpanded = false; + return true; + }, + }); + + this._tippyInstance.show(); + } + + @action + teardownPanel() { + this._tippyInstance?.destroy(); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-composer.hbs index 04f7a22814d..95325f10fcf 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer.hbs @@ -1,45 +1,35 @@ -{{#if this.replyToMsg}} +{{#if this.composerService.replyToMsg}} <ChatComposerMessageDetails - @message={{this.replyToMsg}} + @message={{this.composerService.replyToMsg}} @icon="reply" @action={{action "cancelReplyTo"}} /> {{/if}} -{{#if this.editingMessage}} +{{#if this.composerService.editingMessage}} <ChatComposerMessageDetails - @message={{this.editingMessage}} + @message={{this.composerService.editingMessage}} @icon="pencil-alt" @action={{action "cancelEditing"}} /> {{/if}} -<div class="chat-composer-emoji-picker-anchor"></div> - <div role="region" aria-label={{i18n "chat.aria_roles.composer"}} class="chat-composer {{if this.disableComposer 'is-disabled'}}" + {{did-update this.updateEditingMessage this.composerService.editingMessage}} > - {{#if - (and - this.chatEmojiPickerManager.opened - (eq this.chatEmojiPickerManager.context "chat-composer") - ) - }} - <DButton - @icon="times" - @action={{this.chatEmojiPickerManager.close}} - @class="chat-composer__close-emoji-picker-btn btn-flat" - /> - {{else}} - {{#unless this.disableComposer}} - <ChatComposerDropdown - @buttons={{this.dropdownButtons}} - @isDisabled={{this.disableComposer}} - /> - {{/unless}} - {{/if}} + + <ChatComposerDropdown + @buttons={{this.dropdownButtons}} + @isDisabled={{this.disableComposer}} + @hasActivePanel={{and + this.chatEmojiPickerManager.picker + (eq this.chatEmojiPickerManager.picker.context @context) + }} + @onCloseActivePanel={{this.chatEmojiPickerManager.close}} + /> <DTextarea @value={{readonly this.value}} @@ -82,7 +72,7 @@ @onUploadChanged={{this.uploadsChanged}} @existingUploads={{or this.chatChannel.draft.uploads - this.editingMessage.uploads + this.composerService.editingMessage.uploads }} /> {{/if}} @@ -91,4 +81,9 @@ <div class="chat-replying-indicator-container"> <ChatReplyingIndicator @chatChannel={{this.chatChannel}} /> </div> -{{/unless}} \ No newline at end of file +{{/unless}} + +<ChatEmojiPicker + @context={{@context}} + @didSelectEmoji={{this.didSelectEmoji}} +/> \ 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 eb7d434c4db..25e27a15a03 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer.js @@ -15,7 +15,7 @@ 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 { readOnly, reads } from "@ember/object/computed"; +import { reads } from "@ember/object/computed"; import { SKIP } from "discourse/lib/autocomplete"; import { Promise } from "rsvp"; import { translations } from "pretty-text/emoji/data"; @@ -32,12 +32,9 @@ export default Component.extend(TextareaTextManipulation, { chat: service(), classNames: ["chat-composer-container"], classNameBindings: ["emojiPickerVisible:with-emoji-picker"], - userSilenced: readOnly("chatChannel.userSilenced"), chatEmojiReactionStore: service("chat-emoji-reaction-store"), chatEmojiPickerManager: service("chat-emoji-picker-manager"), chatStateManager: service("chat-state-manager"), - editingMessage: null, - onValueChange: null, timer: null, value: "", inProgressUploads: null, @@ -50,12 +47,12 @@ export default Component.extend(TextareaTextManipulation, { @discourseComputed(...chatComposerButtonsDependentKeys()) inlineButtons() { - return chatComposerButtons(this, "inline"); + return chatComposerButtons(this, "inline", this.context); }, @discourseComputed(...chatComposerButtonsDependentKeys()) dropdownButtons() { - return chatComposerButtons(this, "dropdown"); + return chatComposerButtons(this, "dropdown", this.context); }, @discourseComputed("chatEmojiPickerManager.{opened,context}") @@ -71,7 +68,6 @@ export default Component.extend(TextareaTextManipulation, { init() { this._super(...arguments); - this.appEvents.on("chat-composer:reply-to-set", this, "_replyToMsgChanged"); this.appEvents.on( "upload-mixin:chat-composer-uploader:in-progress-uploads", this, @@ -82,6 +78,10 @@ export default Component.extend(TextareaTextManipulation, { inProgressUploads: [], _uploads: [], }); + + this.composerService?.registerFocusHandler(() => { + this._focusTextArea(); + }); }, didInsertElement() { @@ -92,7 +92,6 @@ export default Component.extend(TextareaTextManipulation, { this._applyUserAutocomplete(this._$textarea); this._applyCategoryHashtagAutocomplete(this._$textarea); this._applyEmojiAutocomplete(this._$textarea); - this.appEvents.on("chat:focus-composer", this, "_focusTextArea"); this.appEvents.on("chat:insert-text", this, "insertText"); this._focusTextArea(); @@ -134,11 +133,6 @@ export default Component.extend(TextareaTextManipulation, { willDestroyElement() { this._super(...arguments); - this.appEvents.off( - "chat-composer:reply-to-set", - this, - "_replyToMsgChanged" - ); this.appEvents.off( "upload-mixin:chat-composer-uploader:in-progress-uploads", this, @@ -147,7 +141,6 @@ export default Component.extend(TextareaTextManipulation, { cancel(this.timer); - this.appEvents.off("chat:focus-composer", this, "_focusTextArea"); this.appEvents.off("chat:insert-text", this, "insertText"); this.appEvents.off("chat:modify-selection", this, "_modifySelection"); this.appEvents.off( @@ -192,19 +185,19 @@ export default Component.extend(TextareaTextManipulation, { if ( event.key === "ArrowUp" && this._messageIsEmpty() && - !this.editingMessage + !this.composerService?.editingMessage ) { event.preventDefault(); - this.onEditLastMessageRequested(); + this.paneService?.editLastMessageRequested(); } if (event.keyCode === 27) { // keyCode for 'Escape' - if (this.replyToMsg) { + if (this.composerService?.replyToMsg) { this.set("value", ""); - this._replyToMsgChanged(null); + this.composerService?.setReplyTo(null); return false; - } else if (this.editingMessage) { + } else if (this.composerService?.editingMessage) { this.set("value", ""); this.cancelEditing(); return false; @@ -218,34 +211,36 @@ export default Component.extend(TextareaTextManipulation, { this._super(...arguments); if ( - !this.editingMessage && + !this.composerService?.editingMessage && this.chatChannel?.draft && this.chatChannel?.canModifyMessages(this.currentUser) ) { // uses uploads from draft here... - this.setProperties({ - value: this.chatChannel.draft.message, - replyToMsg: this.chatChannel.draft.replyToMsg, - }); + this.set("value", this.chatChannel.draft.message); + this.composerService?.setReplyTo(this.chatChannel.draft.replyToMsg); this._captureMentions(); this._syncUploads(this.chatChannel.draft.uploads); - this.setInReplyToMsg(this.chatChannel.draft.replyToMsg); - } - - if (this.editingMessage && !this.loading) { - this.setProperties({ - replyToMsg: null, - value: this.editingMessage.message, - }); - - this._syncUploads(this.editingMessage.uploads); - this._focusTextArea({ ensureAtEnd: true, resizeTextarea: false }); } this.resizeTextarea(); }, + @action + updateEditingMessage() { + if ( + this.composerService?.editingMessage && + !this.paneService?.sendingLoading + ) { + this.set("value", this.composerService?.editingMessage.message); + + 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. @@ -281,11 +276,6 @@ export default Component.extend(TextareaTextManipulation, { }); }, - _replyToMsgChanged(replyToMsg) { - this.set("replyToMsg", replyToMsg); - this.onValueChange?.({ replyToMsg }); - }, - @action onTextareaInput(value) { this.set("value", value); @@ -299,7 +289,7 @@ export default Component.extend(TextareaTextManipulation, { @bind _handleTextareaInput() { - this.onValueChange?.({ value: this.value }); + this.composerService?.onComposerValueChange?.({ value: this.value }); }, @bind @@ -324,6 +314,18 @@ export default Component.extend(TextareaTextManipulation, { const code = `:${emoji}:`; this.chatEmojiReactionStore.track(code); this.addText(this.getSelected(), code); + + if (this.site.desktopView) { + this._focusTextArea(); + } else { + this.chatEmojiPickerManager.close(); + } + }, + + @action + closeComposerDropdown() { + this.chatEmojiPickerManager.close(); + this.appEvents.trigger("d-popover:close"); }, @action @@ -420,8 +422,9 @@ export default Component.extend(TextareaTextManipulation, { return `${v.code}:`; } else { $textarea.autocomplete({ cancel: true }); - this.chatEmojiPickerManager.startFromComposer(this.emojiSelected, { - filter: v.term, + this.chatEmojiPickerManager.open({ + context: this.context, + initialFilter: v.term, }); return ""; } @@ -551,18 +554,21 @@ export default Component.extend(TextareaTextManipulation, { @discourseComputed( "chatChannel.{id,chatable.users.[]}", - "canInteractWithChat" + "chat.userCanInteractWithChat" ) - disableComposer(channel, canInteractWithChat) { + disableComposer(channel, userCanInteractWithChat) { return ( (channel.isDraft && isEmpty(channel?.chatable?.users)) || - !canInteractWithChat || + !userCanInteractWithChat || !channel.canModifyMessages(this.currentUser) ); }, - @discourseComputed("userSilenced", "chatChannel.{chatable.users.[],id}") - placeholder(userSilenced, chatChannel) { + @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}` @@ -581,7 +587,7 @@ export default Component.extend(TextareaTextManipulation, { } } - if (userSilenced) { + if (!userCanInteractWithChat) { return I18n.t("chat.placeholder_silenced"); } else { return this.messageRecipient(chatChannel); @@ -612,7 +618,7 @@ export default Component.extend(TextareaTextManipulation, { @discourseComputed( "value", - "loading", + "paneService.sendingLoading", "disableComposer", "inProgressUploads.[]" ) @@ -636,23 +642,30 @@ export default Component.extend(TextareaTextManipulation, { return; } - this.editingMessage + this.composerService?.editingMessage ? this.internalEditMessage() : this.internalSendMessage(); }, @action internalSendMessage() { - return this.sendMessage(this.value, this._uploads).then(this.reset); + // 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.editMessage( - this.editingMessage, - this.value, - this._uploads - ).then(this.reset); + return this.paneService + ?.editMessage(this.value, this._uploads) + .then(this.reset); }, _messageIsValid() { @@ -691,19 +704,21 @@ export default Component.extend(TextareaTextManipulation, { this._captureMentions(); this._syncUploads([]); this._focusTextArea({ ensureAtEnd: true, resizeTextarea: true }); - this.onValueChange?.(this.value, this._uploads, this.replyToMsg); + this.composerService?.onComposerValueChange?.( + this.value, + this._uploads, + this.composerService?.replyToMsg + ); }, @action cancelReplyTo() { - this.set("replyToMsg", null); - this.setInReplyToMsg(null); - this.onValueChange?.({ replyToMsg: null }); + this.composerService?.setReplyTo(null); }, @action cancelEditing() { - this.onCancelEditing(); + this.composerService?.cancelEditing(); this._focusTextArea({ ensureAtEnd: true, resizeTextarea: true }); }, @@ -721,7 +736,10 @@ export default Component.extend(TextareaTextManipulation, { @action uploadsChanged(uploads, { inProgressUploadsCount }) { this.set("_uploads", cloneJSON(uploads)); - this.onValueChange?.({ uploads: this._uploads, inProgressUploadsCount }); + this.composerService?.onComposerValueChange?.({ + uploads: this._uploads, + inProgressUploadsCount, + }); }, @action diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-drawer.hbs index 2e44c5fde07..e5529087c97 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-drawer.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer.hbs @@ -1,7 +1,7 @@ {{#if this.chatStateManager.isDrawerActive}} - <div data-chat-channel-id={{this.chat.activeChannel.id}} + data-chat-thread-id={{this.chat.activeChannel.activeThread.id}} class={{concat-class "chat-drawer" (if this.chatStateManager.isDrawerExpanded "is-expanded") diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer.js b/plugins/chat/assets/javascripts/discourse/components/chat-drawer.js index 287de5a7c98..98f5c7b5c98 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-drawer.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer.js @@ -22,6 +22,7 @@ export default Component.extend({ didInsertElement() { this._super(...arguments); + if (!this.chat.userCanChat) { return; } @@ -46,6 +47,7 @@ export default Component.extend({ willDestroyElement() { this._super(...arguments); + if (!this.chat.userCanChat) { return; } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/thread.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/thread.hbs new file mode 100644 index 00000000000..a51bd5cfacd --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/thread.hbs @@ -0,0 +1,23 @@ +<ChatDrawer::Header> + <ChatDrawer::Header::LeftActions /> + + <ChatDrawer::Header::ChannelTitle + @channel={{this.chat.activeChannel}} + @drawerActions={{@drawerActions}} + /> + + <ChatDrawer::Header::RightActions @drawerActions={{@drawerActions}} /> +</ChatDrawer::Header> + +{{#if this.chatStateManager.isDrawerExpanded}} + <div + class="chat-drawer-content" + {{did-insert this.fetchChannelAndThread}} + {{did-update this.fetchChannelAndThread @params.channelId}} + {{did-update this.fetchChannelAndThread @params.threadId}} + > + {{#if this.chat.activeChannel.activeThread}} + <ChatThread /> + {{/if}} + </div> +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-drawer/thread.js b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/thread.js new file mode 100644 index 00000000000..cc6304fb125 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-drawer/thread.js @@ -0,0 +1,29 @@ +import Component from "@glimmer/component"; +import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; + +export default class ChatDrawerThread extends Component { + @service appEvents; + @service chat; + @service chatStateManager; + @service chatChannelsManager; + + @action + fetchChannelAndThread() { + if (!this.args.params?.channelId || !this.args.params?.threadId) { + return; + } + + return this.chatChannelsManager + .find(this.args.params.channelId) + .then((channel) => { + this.chat.activeChannel = channel; + + channel.threadsManager + .find(channel.id, this.args.params.threadId) + .then((thread) => { + this.chat.activeChannel.activeThread = thread; + }); + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-emoji-picker.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-emoji-picker.hbs index f4066e0e824..47eaed33158 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-emoji-picker.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-emoji-picker.hbs @@ -1,175 +1,137 @@ {{! template-lint-disable no-invalid-interactive }} {{! template-lint-disable no-nested-interactive }} {{! template-lint-disable no-down-event-binding }} -<div - class={{concat-class - "chat-emoji-picker" - (if this.chatEmojiPickerManager.closing "closing") - }} - {{did-insert this.addClickOutsideEventListener}} - {{will-destroy this.removeClickOutsideEventListener}} - {{on "keydown" this.trapKeyDownEvents}} -> - <div class="chat-emoji-picker__filter-container"> - <DcFilterInput - @class="chat-emoji-picker__filter" - @value={{this.chatEmojiPickerManager.initialFilter}} - @filterAction={{action this.didInputFilter value="target.value"}} - @icons={{hash left="search"}} - placeholder={{i18n "chat.emoji_picker.search_placeholder"}} - autofocus={{true}} - {{did-insert this.focusFilter}} - {{did-insert - (fn this.didInputFilter this.chatEmojiPickerManager.initialFilter) - }} - > - <div - class="chat-emoji-picker__fitzpatrick-scale" - role="toolbar" - {{on "keyup" this.didNavigateFitzpatrickScale}} + +{{#if (eq this.chatEmojiPickerManager.picker.context @context)}} + <div + class={{concat-class + "chat-emoji-picker" + @class + (if this.chatEmojiPickerManager.closing "closing") + }} + {{did-insert this.addClickOutsideEventListener}} + {{did-insert this.chatEmojiPickerManager.loadEmojis}} + {{did-insert (if @didInsert @didInsert (noop))}} + {{will-destroy (if @willDestroy @willDestroy (noop))}} + {{will-destroy this.removeClickOutsideEventListener}} + {{on "keydown" this.trapKeyDownEvents}} + > + <div class="chat-emoji-picker__filter-container"> + <DcFilterInput + @class="chat-emoji-picker__filter" + @value={{this.chatEmojiPickerManager.picker.initialFilter}} + @filterAction={{action this.didInputFilter value="target.value"}} + @icons={{hash left="search"}} + placeholder={{i18n "chat.emoji_picker.search_placeholder"}} + autofocus={{true}} + {{did-insert (if this.site.desktopView this.focusFilter (noop))}} + {{did-insert + (fn + this.didInputFilter this.chatEmojiPickerManager.picker.initialFilter + ) + }} > - {{#if this.isExpandedFitzpatrickScale}} - {{#each this.fitzpatrickModifiers as |fitzpatrick|}} - - {{#if - (not (eq fitzpatrick.scale this.chatEmojiReactionStore.diversity)) - }} - <button - type="button" - title={{concat "t" fitzpatrick.scale}} - tabindex="-1" - class={{concat-class - "chat-emoji-picker__fitzpatrick-modifier-btn" - (concat "t" fitzpatrick.scale) - }} - {{on - "keyup" - (fn this.didRequestFitzpatrickScale fitzpatrick.scale) - }} - {{on - "click" - (fn this.didRequestFitzpatrickScale fitzpatrick.scale) - }} - > - {{d-icon "check"}} - </button> - {{/if}} - {{/each}} - {{/if}} - - <button - type="button" - title={{concat "t" this.fitzpatrick.scale}} - class={{concat-class - "chat-emoji-picker__fitzpatrick-modifier-btn current" - (concat "t" this.chatEmojiReactionStore.diversity) - }} - {{on "keyup" this.didToggleFitzpatrickScale}} - {{on "click" this.didToggleFitzpatrickScale}} - ></button> - </div> - </DcFilterInput> - </div> - - {{#if this.chatEmojiPickerManager.sections.length}} - {{#if (not (gte this.filteredEmojis.length 0))}} - <div class="chat-emoji-picker__sections-nav"> <div - class="chat-emoji-picker__sections-nav__indicator" - style={{this.navIndicatorStyle}} - ></div> + class="chat-emoji-picker__fitzpatrick-scale" + role="toolbar" + {{on "keyup" this.didNavigateFitzpatrickScale}} + > + {{#if this.isExpandedFitzpatrickScale}} + {{#each this.fitzpatrickModifiers as |fitzpatrick|}} - {{#each-in this.groups as |section emojis|}} - <DButton - class={{concat-class - "btn-flat" - "chat-emoji-picker__section-btn" - (if - (eq this.chatEmojiPickerManager.lastVisibleSection section) - "active" - ) - }} - tabindex="-1" - style={{this.navBtnStyle}} - @action={{fn this.didRequestSection section}} - data-section={{section}} - > - {{#if (eq section "favorites")}} - {{replace-emoji ":star:"}} - {{else}} - <img - width="18" - height="18" - class="emoji" - src={{emojis.firstObject.url}} - /> - {{/if}} - </DButton> - {{/each-in}} - </div> - {{/if}} - - <div - class="chat-emoji-picker__scrollable-content" - {{chat/emoji-picker-scroll-listener}} - > - <div - class="chat-emoji-picker__sections" - {{on "click" this.didSelectEmoji}} - {{on "keydown" this.onSectionsKeyDown}} - role="button" - > - {{#if (gte this.filteredEmojis.length 0)}} - <div class="chat-emoji-picker__section filtered"> - {{#each this.filteredEmojis as |emoji|}} - <img - width="32" - height="32" - class="emoji" - src={{tonable-emoji-url - emoji - this.chatEmojiReactionStore.diversity - }} - tabindex="0" - data-emoji={{emoji.name}} - data-tonable={{if emoji.tonable "true"}} - alt={{emoji.name}} - title={{tonable-emoji-title - emoji - this.chatEmojiReactionStore.diversity - }} - loading="lazy" - /> - {{else}} - <p class="chat-emoji-picker__no-reults"> - {{i18n "chat.emoji_picker.no_results"}} - </p> - {{/each}} - </div> - {{/if}} - - {{#each-in this.groups as |section emojis|}} - <div - class={{concat-class - "chat-emoji-picker__section" - (if (gte this.filteredEmojis.length 0) "hidden") - }} - data-section={{section}} - role="region" - aria-label={{i18n - (concat "chat.emoji_picker." section) - translatedFallback=section - }} - > - <h2 class="chat-emoji-picker__section-title"> - {{i18n - (concat "chat.emoji_picker." section) - translatedFallback=section + {{#if + (not + (eq fitzpatrick.scale this.chatEmojiReactionStore.diversity) + ) }} - </h2> - <div class="chat-emoji-picker__section-emojis"> - {{! we always want the first emoji for tabbing}} - {{#let emojis.firstObject as |emoji|}} + <button + type="button" + title={{concat "t" fitzpatrick.scale}} + tabindex="-1" + class={{concat-class + "chat-emoji-picker__fitzpatrick-modifier-btn" + (concat "t" fitzpatrick.scale) + }} + {{on + "keyup" + (fn this.didRequestFitzpatrickScale fitzpatrick.scale) + }} + {{on + "click" + (fn this.didRequestFitzpatrickScale fitzpatrick.scale) + }} + > + {{d-icon "check"}} + </button> + {{/if}} + {{/each}} + {{/if}} + + <button + type="button" + title={{concat "t" this.fitzpatrick.scale}} + class={{concat-class + "chat-emoji-picker__fitzpatrick-modifier-btn current" + (concat "t" this.chatEmojiReactionStore.diversity) + }} + {{on "keyup" this.didToggleFitzpatrickScale}} + {{on "click" this.didToggleFitzpatrickScale}} + ></button> + </div> + </DcFilterInput> + </div> + + {{#if this.chatEmojiPickerManager.sections.length}} + {{#if (not (gte this.filteredEmojis.length 0))}} + <div class="chat-emoji-picker__sections-nav"> + <div + class="chat-emoji-picker__sections-nav__indicator" + style={{this.navIndicatorStyle}} + ></div> + + {{#each-in this.groups as |section emojis|}} + <DButton + class={{concat-class + "btn-flat" + "chat-emoji-picker__section-btn" + (if + (eq this.chatEmojiPickerManager.lastVisibleSection section) + "active" + ) + }} + tabindex="-1" + style={{this.navBtnStyle}} + @action={{fn this.didRequestSection section}} + data-section={{section}} + > + {{#if (eq section "favorites")}} + {{replace-emoji ":star:"}} + {{else}} + <img + width="18" + height="18" + class="emoji" + src={{emojis.firstObject.url}} + /> + {{/if}} + </DButton> + {{/each-in}} + </div> + {{/if}} + + <div + class="chat-emoji-picker__scrollable-content" + {{chat/emoji-picker-scroll-listener}} + > + <div + class="chat-emoji-picker__sections" + {{on "click" this.didSelectEmoji}} + {{on "keydown" this.onSectionsKeyDown}} + role="button" + > + {{#if (gte this.filteredEmojis.length 0)}} + <div class="chat-emoji-picker__section filtered"> + {{#each this.filteredEmojis as |emoji|}} <img width="32" height="32" @@ -187,53 +149,101 @@ this.chatEmojiReactionStore.diversity }} loading="lazy" - {{on "focus" this.didFocusFirstEmoji}} /> - {{/let}} - - {{#if - (includes this.chatEmojiPickerManager.visibleSections section) - }} - {{#each emojis as |emoji index|}} - {{! first emoji has already been rendered, we don't want to re render or would lose focus}} - {{#if (gt index 0)}} - <img - width="32" - height="32" - class="emoji" - src={{tonable-emoji-url - emoji - this.chatEmojiReactionStore.diversity - }} - tabindex="-1" - data-emoji={{emoji.name}} - data-tonable={{if emoji.tonable "true"}} - alt={{emoji.name}} - title={{tonable-emoji-title - emoji - this.chatEmojiReactionStore.diversity - }} - loading="lazy" - /> - {{/if}} - {{/each}} - {{/if}} + {{else}} + <p class="chat-emoji-picker__no-results"> + {{i18n "chat.emoji_picker.no_results"}} + </p> + {{/each}} </div> - </div> - {{/each-in}} - </div> - </div> - {{else}} - <div class="spinner medium"></div> - {{/if}} -</div> + {{/if}} -{{#if - (and - this.chatEmojiPickerManager.opened - this.site.mobileView - (eq this.chatEmojiPickerManager.context "chat-message") - ) -}} - <div class="chat-emoji-picker__backdrop"></div> + {{#each-in this.groups as |section emojis|}} + <div + class={{concat-class + "chat-emoji-picker__section" + (if (gte this.filteredEmojis.length 0) "hidden") + }} + data-section={{section}} + role="region" + aria-label={{i18n + (concat "chat.emoji_picker." section) + translatedFallback=section + }} + > + <h2 class="chat-emoji-picker__section-title"> + {{i18n + (concat "chat.emoji_picker." section) + translatedFallback=section + }} + </h2> + <div class="chat-emoji-picker__section-emojis"> + {{! we always want the first emoji for tabbing}} + {{#let emojis.firstObject as |emoji|}} + <img + width="32" + height="32" + class="emoji" + src={{tonable-emoji-url + emoji + this.chatEmojiReactionStore.diversity + }} + tabindex="0" + data-emoji={{emoji.name}} + data-tonable={{if emoji.tonable "true"}} + alt={{emoji.name}} + title={{tonable-emoji-title + emoji + this.chatEmojiReactionStore.diversity + }} + loading="lazy" + {{on "focus" this.didFocusFirstEmoji}} + /> + {{/let}} + + {{#if + (includes this.chatEmojiPickerManager.visibleSections section) + }} + {{#each emojis as |emoji index|}} + {{! first emoji has already been rendered, we don't want to re render or would lose focus}} + {{#if (gt index 0)}} + <img + width="32" + height="32" + class="emoji" + src={{tonable-emoji-url + emoji + this.chatEmojiReactionStore.diversity + }} + tabindex="-1" + data-emoji={{emoji.name}} + data-tonable={{if emoji.tonable "true"}} + alt={{emoji.name}} + title={{tonable-emoji-title + emoji + this.chatEmojiReactionStore.diversity + }} + loading="lazy" + /> + {{/if}} + {{/each}} + {{/if}} + </div> + </div> + {{/each-in}} + </div> + </div> + {{else}} + <div class="spinner medium"></div> + {{/if}} + </div> + + {{#if + (and + this.site.mobileView + (eq this.chatEmojiPickerManager.picker.context "chat-channel-message") + ) + }} + <div class="chat-emoji-picker__backdrop"></div> + {{/if}} {{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-emoji-picker.js b/plugins/chat/assets/javascripts/discourse/components/chat-emoji-picker.js index 28a9195082f..c98fff610d1 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-emoji-picker.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-emoji-picker.js @@ -1,4 +1,4 @@ -import Component from "@ember/component"; +import Component from "@glimmer/component"; import { htmlSafe } from "@ember/template"; import { action } from "@ember/object"; import { inject as service } from "@ember/service"; @@ -40,9 +40,11 @@ export default class ChatEmojiPicker extends Component { @service chatEmojiPickerManager; @service emojiPickerScrollObserver; @service chatEmojiReactionStore; + @service capabilities; + @service site; + @tracked filteredEmojis = null; @tracked isExpandedFitzpatrickScale = false; - tagName = ""; fitzpatrickModifiers = FITZPATRICK_MODIFIERS; @@ -163,7 +165,7 @@ export default class ChatEmojiPicker extends Component { } } - this.toggleProperty("isExpandedFitzpatrickScale"); + this.isExpandedFitzpatrickScale = !this.isExpandedFitzpatrickScale; } @action @@ -210,7 +212,9 @@ export default class ChatEmojiPicker extends Component { @action focusFilter(target) { - target.focus(); + schedule("afterRender", () => { + target?.focus(); + }); } debouncedDidInputFilter(filter = "") { @@ -347,8 +351,7 @@ export default class ChatEmojiPicker extends Component { emoji = `${emoji}:t${diversity}`; } - this.chatEmojiPickerManager.didSelectEmoji(emoji); - this.appEvents.trigger("chat:focus-composer"); + this.args.didSelectEmoji?.(emoji); } } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.hbs index 3b64a467f62..8e3e8b912e4 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.hbs @@ -2,7 +2,7 @@ class={{concat-class "chat-live-pane" (if this.loading "loading") - (if this.sendingLoading "sending-loading") + (if this.chatChannelPane.sendingLoading "sending-loading") (unless this.loadedOnce "not-loaded-once") }} {{did-insert this.setupListeners}} @@ -23,47 +23,26 @@ <ChatMentionWarnings /> - <div class="chat-message-actions-mobile-anchor"></div> - - <div - class={{concat-class - "chat-message-emoji-picker-anchor" - (if - (and - this.chatEmojiPickerManager.opened - (eq this.chatEmojiPickerManager.context "chat-message") - ) - "-opened" - ) - }} - ></div> - <div class="chat-messages-scroll chat-messages-container" {{on "scroll" this.computeScrollState passive=true}} {{chat/on-scroll this.resetIdle (hash delay=500)}} {{chat/on-scroll this.computeArrow (hash delay=150)}} + {{did-insert this.setScrollable}} > - <div class="chat-message-actions-desktop-anchor"></div> - <div class="chat-messages-container" {{chat/on-resize this.didResizePane}}> + <div + class="chat-messages-container" + {{chat/on-resize this.didResizePane (hash delay=10)}} + > {{#if this.loadedOnce}} {{#each @channel.messages key="id" as |message|}} <ChatMessage @message={{message}} - @canInteractWithChat={{this.canInteractWithChat}} @channel={{@channel}} - @setReplyTo={{this.setReplyTo}} - @replyMessageClicked={{this.replyMessageClicked}} - @editButtonClicked={{this.editButtonClicked}} - @selectingMessages={{this.selectingMessages}} - @onStartSelectingMessages={{this.onStartSelectingMessages}} - @onSelectMessage={{this.onSelectMessage}} - @bulkSelectMessages={{this.bulkSelectMessages}} - @isHovered={{eq message.id this.hoveredMessageId}} - @onHoverMessage={{this.onHoverMessage}} @resendStagedMessage={{this.resendStagedMessage}} @messageDidEnterViewport={{this.messageDidEnterViewport}} @messageDidLeaveViewport={{this.messageDidLeaveViewport}} + @context="channel" /> {{/each}} {{else}} @@ -86,26 +65,25 @@ @channel={{@channel}} /> - {{#if this.selectingMessages}} + {{#if this.chatChannelPane.selectingMessages}} <ChatSelectionManager - @selectedMessageIds={{this.selectedMessageIds}} + @selectedMessageIds={{this.chatChannelPane.selectedMessageIds}} @chatChannel={{@channel}} - @cancelSelecting={{this.cancelSelecting}} + @cancelSelecting={{action + this.chatChannelPane.cancelSelecting + @channel.selectedMessages + }} + @context="channel" /> {{else}} {{#if (or @channel.isDraft @channel.isFollowing)}} <ChatComposer - @canInteractWithChat={{this.canInteractWithChat}} @sendMessage={{this.sendMessage}} - @editMessage={{this.editMessage}} - @setReplyTo={{this.setReplyTo}} - @loading={{this.sendingLoading}} - @editingMessage={{readonly this.editingMessage}} @onCancelEditing={{this.cancelEditing}} - @setInReplyToMsg={{this.setInReplyToMsg}} - @onEditLastMessageRequested={{this.editLastMessageRequested}} - @onValueChange={{this.composerValueChanged}} @chatChannel={{@channel}} + @composerService={{this.chatChannelComposer}} + @paneService={{this.chatChannelPane}} + @context="channel" /> {{else}} <ChatChannelPreviewCard @channel={{@channel}} /> diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js b/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js index ac8b494decc..ac10f67e93e 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-live-pane.js @@ -32,6 +32,8 @@ export default class ChatLivePane extends Component { @service chatEmojiPickerManager; @service chatComposerPresenceManager; @service chatStateManager; + @service chatChannelComposer; + @service chatChannelPane; @service chatApi; @service currentUser; @service appEvents; @@ -41,28 +43,26 @@ export default class ChatLivePane extends Component { @tracked loading = false; @tracked loadingMorePast = false; @tracked loadingMoreFuture = false; - @tracked hoveredMessageId = null; @tracked sendingLoading = false; - @tracked selectingMessages = false; @tracked showChatQuoteSuccess = false; @tracked includeHeader = true; - @tracked editingMessage = null; - @tracked replyToMsg = null; @tracked hasNewMessages = false; @tracked needsArrow = false; @tracked loadedOnce = false; + scrollable = null; _loadedChannelId = null; - _scrollerEl = null; - _lastSelectedMessage = null; _mentionWarningsSeen = {}; _unreachableGroupMentions = []; _overMembersLimitGroupMentions = []; @action - setupListeners(element) { - this._scrollerEl = element.querySelector(".chat-messages-scroll"); + setScrollable(element) { + this.scrollable = element; + } + @action + setupListeners() { document.addEventListener("scroll", this._forceBodyScroll, { passive: true, }); @@ -102,8 +102,8 @@ export default class ChatLivePane extends Component { if (this._loadedChannelId !== this.args.channel?.id) { this._unsubscribeToUpdates(this._loadedChannelId); - this.selectingMessages = false; - this.cancelEditing(); + this.chatChannelPane.selectingMessages = false; + this.chatChannelComposer.cancelEditing(); this._loadedChannelId = this.args.channel?.id; } @@ -239,7 +239,8 @@ export default class ChatLivePane extends Component { .then((results) => { if ( this._selfDeleted || - this.args.channel.id !== results.meta.channel_id + this.args.channel.id !== results.meta.channel_id || + !this.scrollable ) { return; } @@ -372,11 +373,15 @@ export default class ChatLivePane extends Component { } schedule("afterRender", () => { - const messageEl = this._scrollerEl.querySelector( + if (this._selfDeleted) { + return; + } + + const messageEl = this.scrollable.querySelector( `.chat-message-container[data-id='${messageId}']` ); - if (!messageEl || this._selfDeleted) { + if (!messageEl) { return; } @@ -428,13 +433,13 @@ export default class ChatLivePane extends Component { return; } - const element = this._scrollerEl.querySelector( + const element = this.scrollable.querySelector( `[data-id='${lastUnreadVisibleMessage.id}']` ); // if the last visible message is not fully visible, we don't want to mark it as read // attempt to mark previous one as read - if (!this.#isBottomOfMessageVisible(element, this._scrollerEl)) { + if (!this.#isBottomOfMessageVisible(element, this.scrollable)) { lastUnreadVisibleMessage = lastUnreadVisibleMessage.previousMessage; if ( @@ -449,23 +454,6 @@ export default class ChatLivePane extends Component { }); } - @action - scrollToBottom() { - schedule("afterRender", () => { - if (this._selfDeleted) { - return; - } - - // A more consistent way to scroll to the bottom when we are sure this is our goal - // it will also limit issues with any element changing the height while we are scrolling - // to the bottom - this._scrollerEl.scrollTop = -1; - this.forceRendering(() => { - this._scrollerEl.scrollTop = 0; - }); - }); - } - @action scrollToLatestMessage() { schedule("afterRender", () => { @@ -485,13 +473,21 @@ export default class ChatLivePane extends Component { @action computeArrow() { - this.needsArrow = Math.abs(this._scrollerEl.scrollTop) >= 250; + if (!this.scrollable) { + return; + } + + this.needsArrow = Math.abs(this.scrollable.scrollTop) >= 250; } @action computeScrollState() { cancel(this.onScrollEndedHandler); + if (!this.scrollable) { + return; + } + if (this.#isAtTop()) { this.fetchMoreMessages({ direction: PAST }); this.onScrollEnded(); @@ -719,6 +715,8 @@ export default class ChatLivePane extends Component { } } + // TODO (martin) Maybe change this to public, since its referred to by + // livePanel.linkedComponent at the moment. get _selfDeleted() { return this.isDestroying || this.isDestroyed; } @@ -731,11 +729,11 @@ export default class ChatLivePane extends Component { sendMessage(message, uploads = []) { resetIdle(); - if (this.sendingLoading) { + if (this.chatChannelPane.sendingLoading) { return; } - this.sendingLoading = true; + this.chatChannelPane.sendingLoading = true; this.args.channel.draft = ChatMessageDraft.create(); // TODO: all send message logic is due for massive refactoring @@ -758,8 +756,8 @@ export default class ChatLivePane extends Component { return; } this.loading = false; - this.sendingLoading = false; - this._resetAfterSend(); + this.chatChannelPane.sendingLoading = false; + this.chatChannelPane.resetAfterSend(); this.scrollToLatestMessage(); }); } @@ -771,8 +769,8 @@ export default class ChatLivePane extends Component { user: this.currentUser, }); - if (this.replyToMsg) { - stagedMessage.inReplyTo = this.replyToMsg; + if (this.chatChannelComposer.replyToMsg) { + stagedMessage.inReplyTo = this.chatChannelComposer.replyToMsg; } this.args.channel.messagesManager.addMessages([stagedMessage]); @@ -797,8 +795,8 @@ export default class ChatLivePane extends Component { if (this._selfDeleted) { return; } - this.sendingLoading = false; - this._resetAfterSend(); + this.chatChannelPane.sendingLoading = false; + this.chatChannelPane.resetAfterSend(); }); } @@ -836,12 +834,12 @@ export default class ChatLivePane extends Component { } } - this._resetAfterSend(); + this.chatChannelPane.resetAfterSend(); } @action resendStagedMessage(stagedMessage) { - this.sendingLoading = true; + this.chatChannelPane.sendingLoading = true; stagedMessage.error = null; @@ -864,154 +862,14 @@ export default class ChatLivePane extends Component { if (this._selfDeleted) { return; } - this.sendingLoading = false; + this.chatChannelPane.sendingLoading = false; }); } - @action - editMessage(chatMessage, newContent, uploads) { - this.sendingLoading = true; - let data = { - new_message: newContent, - upload_ids: (uploads || []).map((upload) => upload.id), - }; - return ajax(`/chat/${this.args.channel.id}/edit/${chatMessage.id}`, { - type: "PUT", - data, - }) - .then(() => { - this._resetAfterSend(); - }) - .catch(popupAjaxError) - .finally(() => { - if (this._selfDeleted) { - return; - } - this.sendingLoading = false; - }); - } - - _resetAfterSend() { - if (this._selfDeleted) { - return; - } - - this.replyToMsg = null; - this.editingMessage = null; - this.chatComposerPresenceManager.notifyState(this.args.channel.id, false); - this.appEvents.trigger("chat-composer:reply-to-set", null); - } - - @action - editLastMessageRequested() { - const lastUserMessage = this.args.channel.messages.findLast( - (message) => message.user.id === this.currentUser.id - ); - - if (!lastUserMessage) { - return; - } - - if (lastUserMessage.staged || lastUserMessage.error) { - return; - } - - this.editingMessage = lastUserMessage; - this._focusComposer(); - } - - @action - setReplyTo(messageId) { - if (messageId) { - this.cancelEditing(); - - const message = this.args.channel.messagesManager.findMessage(messageId); - this.replyToMsg = message; - this.appEvents.trigger("chat-composer:reply-to-set", message); - this._focusComposer(); - } else { - this.replyToMsg = null; - this.appEvents.trigger("chat-composer:reply-to-set", null); - } - } - - @action - replyMessageClicked(message) { - const replyMessageFromLookup = - this.args.channel.messagesManager.findMessage(message.id); - if (replyMessageFromLookup) { - this.scrollToMessage(replyMessageFromLookup.id, { - highlight: true, - position: "start", - autoExpand: true, - }); - } else { - // Message is not present in the loaded messages. Fetch it! - this.requestedTargetMessageId = message.id; - this.fetchMessages(); - } - } - - @action - editButtonClicked(messageId) { - const message = this.args.channel.messagesManager.findMessage(messageId); - this.editingMessage = message; - this.scrollToLatestMessage(); - this._focusComposer(); - } - - get canInteractWithChat() { - return !this.args.channel?.userSilenced; - } - get chatProgressBarContainer() { return document.querySelector("#chat-progress-bar-container"); } - get selectedMessageIds() { - return this.args.channel?.messages - ?.filter((m) => m.selected) - ?.map((m) => m.id); - } - - @action - onStartSelectingMessages(message) { - this._lastSelectedMessage = message; - this.selectingMessages = true; - } - - @action - cancelSelecting() { - this.selectingMessages = false; - this.args.channel.messages.forEach((message) => { - message.selected = false; - }); - } - - @action - onSelectMessage(message) { - this._lastSelectedMessage = message; - } - - @action - bulkSelectMessages(message, checked) { - const lastSelectedIndex = this._findIndexOfMessage( - this._lastSelectedMessage - ); - const newlySelectedIndex = this._findIndexOfMessage(message); - const sortedIndices = [lastSelectedIndex, newlySelectedIndex].sort( - (a, b) => a - b - ); - - for (let i = sortedIndices[0]; i <= sortedIndices[1]; i++) { - this.args.channel.messages[i].selected = checked; - } - } - - _findIndexOfMessage(message) { - return this.args.channel.messages.findIndex((m) => m.id === message.id); - } - @action onCloseFullScreen() { this.chatStateManager.prefersDrawer(); @@ -1023,144 +881,6 @@ export default class ChatLivePane extends Component { }); } - @action - cancelEditing() { - this.editingMessage = null; - } - - @action - setInReplyToMsg(inReplyMsg) { - this.replyToMsg = inReplyMsg; - } - - @action - composerValueChanged({ value, uploads, replyToMsg, inProgressUploadsCount }) { - if (!this.editingMessage && !this.args.channel.isDraft) { - if (typeof value !== "undefined") { - this.args.channel.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.args.channel.draft.uploads = uploads; - } - - if (typeof replyToMsg !== "undefined") { - this.args.channel.draft.replyToMsg = replyToMsg; - } - } - - if (!this.args.channel.isDraft) { - this._reportReplyingPresence(value); - } - - this._persistDraft(); - } - - @debounce(2000) - _persistDraft() { - if (this._selfDeleted) { - return; - } - - if (!this.args.channel.draft) { - return; - } - - ajax("/chat/drafts.json", { - type: "POST", - data: { - chat_channel_id: this.args.channel.id, - data: this.args.channel.draft.toJSON(), - }, - ignoreUnsent: false, - }) - .then(() => { - this.chat.markNetworkAsReliable(); - }) - .catch((error) => { - // we ignore a draft which can't be saved because it's too big - // and only deal with network error for now - if (!error.jqXHR?.responseJSON?.errors?.length) { - this.chat.markNetworkAsUnreliable(); - } - }); - } - - @action - onHoverMessage(message, options = {}, event) { - if (this.site.mobileView && options.desktopOnly) { - return; - } - - if (this.isScrolling) { - return; - } - - if (message?.staged) { - return; - } - - if ( - this.hoveredMessageId && - message?.id && - this.hoveredMessageId === message?.id - ) { - return; - } - - if (event) { - if ( - event.type === "mouseleave" && - (event.toElement || event.relatedTarget)?.closest( - ".chat-message-actions-desktop-anchor" - ) - ) { - return; - } - - if ( - event.type === "mouseenter" && - (event.fromElement || event.relatedTarget)?.closest( - ".chat-message-actions-desktop-anchor" - ) - ) { - this.hoveredMessageId = message?.id; - return; - } - } - - this.hoveredMessageId = - message?.id && message.id !== this.hoveredMessageId ? message.id : null; - } - - _reportReplyingPresence(composerValue) { - if (this._selfDeleted) { - return; - } - - if (this.args.channel.isDraft) { - return; - } - - const replying = !this.editingMessage && !!composerValue; - this.chatComposerPresenceManager.notifyState( - this.args.channel.id, - replying - ); - } - - _focusComposer() { - this.appEvents.trigger("chat:focus-composer"); - } - _unsubscribeToUpdates(channelId) { if (!channelId) { return; @@ -1213,33 +933,6 @@ export default class ChatLivePane extends Component { } } - // since -webkit-overflow-scrolling: touch can't be used anymore to disable momentum scrolling - // we now use this hack to disable it - @bind - forceRendering(callback) { - schedule("afterRender", () => { - if (!this._scrollerEl) { - return; - } - - if (this.capabilities.isIOS) { - this._scrollerEl.style.overflow = "hidden"; - } - - callback?.(); - - if (this.capabilities.isIOS) { - discourseLater(() => { - if (!this._scrollerEl) { - return; - } - - this._scrollerEl.style.overflow = "auto"; - }, 50); - } - }); - } - @action addAutoFocusEventListener() { document.addEventListener("keydown", this._autoFocus); @@ -1277,14 +970,14 @@ export default class ChatLivePane extends Component { return; } - event.preventDefault(); - event.stopPropagation(); - const composer = document.querySelector(".chat-composer-input"); if (composer && !this.args.channel.isDraft) { - this.appEvents.trigger("chat:insert-text", key); composer.focus(); + return; } + + event.preventDefault(); + event.stopPropagation(); } @action @@ -1292,12 +985,66 @@ export default class ChatLivePane extends Component { throttle(this, this._computeDatesSeparators, 50, false); } + // A more consistent way to scroll to the bottom when we are sure this is our goal + // it will also limit issues with any element changing the height while we are scrolling + // to the bottom + @action + scrollToBottom() { + if (!this.scrollable) { + return; + } + + this.scrollable.scrollTop = -1; + this.forceRendering(() => { + this.scrollable.scrollTop = 0; + }); + } + + // since -webkit-overflow-scrolling: touch can't be used anymore to disable momentum scrolling + // we now use this hack to disable it + @bind + forceRendering(callback) { + schedule("afterRender", () => { + if (this._selfDeleted) { + return; + } + + if (!this.scrollable) { + return; + } + + if (this.capabilities.isIOS) { + this.scrollable.style.overflow = "hidden"; + } + + callback?.(); + + if (this.capabilities.isIOS) { + discourseLater(() => { + if (!this.scrollable) { + return; + } + + this.scrollable.style.overflow = "auto"; + }, 50); + } + }); + } + _computeDatesSeparators() { schedule("afterRender", () => { + if (this._selfDeleted) { + return; + } + + if (!this.scrollable) { + return; + } + const dates = [ - ...this._scrollerEl.querySelectorAll(".chat-message-separator-date"), + ...this.scrollable.querySelectorAll(".chat-message-separator-date"), ].reverse(); - const height = this._scrollerEl.querySelector( + const height = this.scrollable.querySelector( ".chat-messages-container" ).clientHeight; @@ -1336,17 +1083,29 @@ export default class ChatLivePane extends Component { } #isAtBottom() { - return Math.abs(this._scrollerEl.scrollTop) <= 2; + if (!this.scrollable) { + return false; + } + + return Math.abs(this.scrollable.scrollTop) <= 2; } #isTowardsBottom() { - return Math.abs(this._scrollerEl.scrollTop) <= 50; + if (!this.scrollable) { + return false; + } + + return Math.abs(this.scrollable.scrollTop) <= 50; } #isAtTop() { + if (!this.scrollable) { + return false; + } + return ( - Math.abs(this._scrollerEl.scrollTop) >= - this._scrollerEl.scrollHeight - this._scrollerEl.offsetHeight - 2 + Math.abs(this.scrollable.scrollTop) >= + this.scrollable.scrollHeight - this.scrollable.offsetHeight - 2 ); } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.hbs index 10e4858a533..a9a935cc45c 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.hbs @@ -1,63 +1,72 @@ -<div - class="chat-message-actions-container" - data-id={{@message.id}} - {{did-insert this.attachPopper}} - {{will-destroy this.destroyPopper}} -> - <div class="chat-message-actions"> - {{#if this.chatStateManager.isFullPageActive}} - {{#each @emojiReactions key="emoji" as |reaction|}} - <ChatMessageReaction - @reaction={{reaction}} - @react={{@messageActions.react}} - @showCount={{false}} +{{#if (and this.site.desktopView this.chat.activeMessage.model.id)}} + <div + {{did-insert this.setupPopper}} + {{did-update this.setupPopper this.chat.activeMessage.model.id}} + {{will-destroy this.teardownPopper}} + class="chat-message-actions-container" + data-id={{this.message.id}} + > + <div class="chat-message-actions"> + {{#if this.chatStateManager.isFullPageActive}} + {{#each + this.messageInteractor.emojiReactions + key="emoji" + as |reaction| + }} + <ChatMessageReaction + @reaction={{reaction}} + @onReaction={{this.messageInteractor.react}} + @message={{this.message}} + @showCount={{false}} + /> + {{/each}} + {{/if}} + + {{#if this.messageInteractor.canInteractWithMessage}} + <DButton + @class="btn-flat react-btn" + @action={{this.messageInteractor.openEmojiPicker}} + @icon="discourse-emojis" + @title="chat.react" + @forwardEvent={{true}} /> - {{/each}} - {{/if}} + {{/if}} - {{#if @messageCapabilities.canReact}} - <DButton - @class="btn-flat react-btn" - @action={{@messageActions.startReactionForMessageActions}} - @icon="discourse-emojis" - @title="chat.react" - /> - {{/if}} + {{#if this.messageInteractor.canBookmark}} + <DButton + @class="btn-flat bookmark-btn" + @action={{this.messageInteractor.toggleBookmark}} + > + <BookmarkIcon @bookmark={{this.message.bookmark}} /> + </DButton> + {{/if}} - {{#if @messageCapabilities.canBookmark}} - <DButton - @class="btn-flat bookmark-btn" - @action={{@messageActions.toggleBookmark}} - > - <BookmarkIcon @bookmark={{@message.bookmark}} /> - </DButton> - {{/if}} + {{#if this.messageInteractor.canReply}} + <DButton + @class="btn-flat reply-btn" + @action={{this.messageInteractor.reply}} + @icon="reply" + @title="chat.reply" + /> + {{/if}} - {{#if @messageCapabilities.canReply}} - <DButton - @class="btn-flat reply-btn" - @action={{@messageActions.reply}} - @icon="reply" - @title="chat.reply" - /> - {{/if}} + {{#if this.messageInteractor.canOpenThread}} + <DButton + @class="btn-flat chat-message-thread-btn" + @action={{this.messageInteractor.openThread}} + @icon="puzzle-piece" + @title="chat.threads.open" + /> + {{/if}} - {{#if @messageCapabilities.hasThread}} - <DButton - @class="btn-flat chat-message-thread-btn" - @action={{@messageActions.openThread}} - @icon="puzzle-piece" - @title="chat.threads.open" - /> - {{/if}} - - {{#if @secondaryButtons.length}} - <DropdownSelectBox - @class="more-buttons" - @options={{hash icon="ellipsis-v" placement="left"}} - @content={{@secondaryButtons}} - @onChange={{action "handleSecondaryButtons"}} - /> - {{/if}} + {{#if this.messageInteractor.secondaryButtons.length}} + <DropdownSelectBox + @class="more-buttons" + @options={{hash icon="ellipsis-v" placement="left"}} + @content={{this.messageInteractor.secondaryButtons}} + @onChange={{action this.messageInteractor.handleSecondaryButtons}} + /> + {{/if}} + </div> </div> -</div> \ No newline at end of file +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.js index 111e24b523a..93f4da32cb9 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-desktop.js @@ -1,34 +1,48 @@ import Component from "@glimmer/component"; -import { action } from "@ember/object"; -import { createPopper } from "@popperjs/core"; -import { schedule } from "@ember/runloop"; import { inject as service } from "@ember/service"; +import ChatMessageInteractor from "discourse/plugins/chat/discourse/lib/chat-message-interactor"; +import { getOwner } from "@ember/application"; +import { schedule } from "@ember/runloop"; +import { createPopper } from "@popperjs/core"; +import chatMessageContainer from "discourse/plugins/chat/discourse/lib/chat-message-container"; +import { action } from "@ember/object"; const MSG_ACTIONS_VERTICAL_PADDING = -10; export default class ChatMessageActionsDesktop extends Component { + @service chat; @service chatStateManager; + @service chatEmojiPickerManager; + @service site; popper = null; - @action - destroyPopper() { - this.popper?.destroy(); - this.popper = null; + get message() { + return this.chat.activeMessage.model; + } + + get context() { + return this.chat.activeMessage.context; + } + + get messageInteractor() { + const activeMessage = this.chat.activeMessage; + + return new ChatMessageInteractor( + getOwner(this), + activeMessage.model, + activeMessage.context + ); } @action - attachPopper() { - this.destroyPopper(); + setupPopper(element) { + this.popper?.destroy(); schedule("afterRender", () => { this.popper = createPopper( - document.querySelector( - `.chat-message-container[data-id="${this.args.message.id}"]` - ), - document.querySelector( - `.chat-message-actions-container[data-id="${this.args.message.id}"] .chat-message-actions` - ), + chatMessageContainer(this.message.id, this.context), + element, { placement: "top-end", strategy: "fixed", @@ -46,7 +60,7 @@ export default class ChatMessageActionsDesktop extends Component { } @action - handleSecondaryButtons(id) { - this.args.messageActions?.[id]?.(); + teardownPopper() { + this.popper?.destroy(); } } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-mobile.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-mobile.hbs index be96ed82385..c91d4d181f3 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-mobile.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-mobile.hbs @@ -1,91 +1,91 @@ -<div - class={{concat-class - "chat-message-actions-backdrop" - (if this.showFadeIn "fade-in") - }} - {{did-insert this.fadeAndVibrate}} -> +{{#if (and this.site.mobileView this.chat.activeMessage)}} <div - role="button" - class="collapse-area" - {{on "touchstart" this.collapseMenu passive=true}} + class={{concat-class + "chat-message-actions-backdrop" + (if this.showFadeIn "fade-in") + }} + {{did-insert this.fadeAndVibrate}} > - </div> - - <div class="chat-message-actions"> - <div class="selected-message-container"> - <div class="selected-message"> - <ChatUserAvatar @user={{@message.user}} /> - <span - {{on "touchstart" this.expandReply passive=true}} - role="button" - class={{concat-class - "selected-message-reply" - (if this.hasExpandedReply "is-expanded") - }} - > - {{@message.message}} - </span> - </div> + <div + role="button" + class="collapse-area" + {{on "touchstart" this.collapseMenu passive=true}} + > </div> - <ul class="secondary-actions"> - {{#each @secondaryButtons as |button|}} - <li class="chat-message-action-item" data-id={{button.id}}> - <DButton - @class="chat-message-action" - @translatedLabel={{button.name}} - @icon={{button.icon}} - @actionParam={{button.id}} - @action={{action - this.actAndCloseMenu - (get @messageActions button.id) + <div class="chat-message-actions"> + <div class="selected-message-container"> + <div class="selected-message"> + <ChatUserAvatar @user={{this.message.user}} /> + <span + {{on "touchstart" this.expandReply passive=true}} + role="button" + class={{concat-class + "selected-message-reply" + (if this.hasExpandedReply "is-expanded") }} - /> - </li> - {{/each}} - </ul> - - {{#if (or @messageCapabilities.canReact @messageCapabilities.canReply)}} - <div class="main-actions"> - {{#if @messageCapabilities.canReact}} - {{#each @emojiReactions as |reaction|}} - <ChatMessageReaction - @reaction={{reaction}} - @react={{@messageActions.react}} - @showCount={{false}} - /> - {{/each}} - - <DButton - @class="btn-flat react-btn" - @action={{action - this.actAndCloseMenu - @messageActions.startReactionForMessageActions - }} - @icon="discourse-emojis" - @title="chat.react" - /> - {{/if}} - - {{#if @messageCapabilities.canBookmark}} - <DButton - @class="btn-flat bookmark-btn" - @action={{@messageActions.toggleBookmark}} > - <BookmarkIcon @bookmark={{@message.bookmark}} /> - </DButton> - {{/if}} - - {{#if @messageCapabilities.canReply}} - <DButton - @class="chat-message-action reply-btn btn-flat" - @action={{action "actAndCloseMenu" @messageActions.reply}} - @icon="reply" - @title="chat.reply" - /> - {{/if}} + {{this.message.message}} + </span> + </div> </div> - {{/if}} + + <ul class="secondary-actions"> + {{#each this.messageInteractor.secondaryButtons as |button|}} + <li class="chat-message-action-item" data-id={{button.id}}> + <DButton + @class="chat-message-action" + @translatedLabel={{button.name}} + @icon={{button.icon}} + @actionParam={{button.id}} + @action={{action this.actAndCloseMenu button.id}} + /> + </li> + {{/each}} + </ul> + + {{#if + (or this.messageInteractor.canReact this.messageInteractor.canReply) + }} + <div class="main-actions"> + {{#if this.messageInteractor.canReact}} + {{#each this.messageInteractor.emojiReactions as |reaction|}} + <ChatMessageReaction + @reaction={{reaction}} + @onReaction={{this.messageInteractor.react}} + @message={{this.message}} + @showCount={{false}} + /> + {{/each}} + + <DButton + @class="btn-flat react-btn" + @action={{this.openEmojiPicker}} + @icon="discourse-emojis" + @title="chat.react" + @forwardEvent={{true}} + /> + {{/if}} + + {{#if this.messageInteractor.canBookmark}} + <DButton + @class="btn-flat bookmark-btn" + @action={{action this.actAndCloseMenu "toggleBookmark"}} + > + <BookmarkIcon @bookmark={{this.message.bookmark}} /> + </DButton> + {{/if}} + + {{#if this.messageInteractor.canReply}} + <DButton + @class="chat-message-action reply-btn btn-flat" + @action={{action this.actAndCloseMenu "reply"}} + @icon="reply" + @title="chat.reply" + /> + {{/if}} + </div> + {{/if}} + </div> </div> -</div> \ No newline at end of file +{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-mobile.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-mobile.js index b79ab65dfa3..8432c5a4bc0 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-mobile.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-actions-mobile.js @@ -1,4 +1,6 @@ import Component from "@glimmer/component"; +import ChatMessageInteractor from "discourse/plugins/chat/discourse/lib/chat-message-interactor"; +import { getOwner } from "discourse-common/lib/get-owner"; import { tracked } from "@glimmer/tracking"; import discourseLater from "discourse-common/lib/later"; import { action } from "@ember/object"; @@ -6,6 +8,8 @@ import { isTesting } from "discourse-common/config/environment"; import { inject as service } from "@ember/service"; export default class ChatMessageActionsMobile extends Component { + @service chat; + @service site; @service capabilities; @tracked hasExpandedReply = false; @@ -13,6 +17,20 @@ export default class ChatMessageActionsMobile extends Component { messageActions = null; + get message() { + return this.chat.activeMessage.model; + } + + get messageInteractor() { + const activeMessage = this.chat.activeMessage; + + return new ChatMessageInteractor( + getOwner(this), + activeMessage.model, + activeMessage.context + ); + } + @action fadeAndVibrate() { discourseLater(this.#addFadeIn.bind(this)); @@ -35,8 +53,14 @@ export default class ChatMessageActionsMobile extends Component { } @action - actAndCloseMenu(fn) { - fn?.(); + actAndCloseMenu(fnId) { + this.messageInteractor[fnId](); + this.#onCloseMenu(); + } + + @action + openEmojiPicker(_, event) { + this.messageInteractor.openEmojiPicker(_, event); this.#onCloseMenu(); } @@ -52,7 +76,7 @@ export default class ChatMessageActionsMobile extends Component { // by ensuring we are not hovering any message anymore // we also ensure the menu is fully removed - this.args.onHoverMessage?.(null); + this.chat.activeMessage = null; }, 200); } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-in-reply-to-indicator.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-in-reply-to-indicator.js index 55e22889ebd..4601fbcc37f 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-in-reply-to-indicator.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-in-reply-to-indicator.js @@ -28,7 +28,7 @@ export default class ChatMessageInReplyToIndicator extends Component { get hasThread() { return ( - this.args.message?.channel?.get("threading_enabled") && + this.args.message?.channel?.threadingEnabled && this.args.message?.threadId ); } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-reaction.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-reaction.js index 0ab877d5fe6..b909b70319b 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-reaction.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-reaction.js @@ -52,10 +52,11 @@ export default class ChatMessageReaction extends Component { @action handleClick() { - this.args.react?.( + this.args.onReaction?.( this.args.reaction.emoji, this.args.reaction.reacted ? "remove" : "add" ); + return false; } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs index 916eebaf3f5..636b572a6da 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message.hbs @@ -1,57 +1,23 @@ {{! template-lint-disable no-invalid-interactive }} -<ChatMessageSeparatorDate @message={{@message}} /> -<ChatMessageSeparatorNew @message={{@message}} /> - -{{#if - (and - this.showActions this.site.mobileView this.chatMessageActionsMobileAnchor - ) -}} - {{#in-element this.chatMessageActionsMobileAnchor}} - <ChatMessageActionsMobile - @message={{@message}} - @emojiReactions={{this.emojiReactions}} - @secondaryButtons={{this.secondaryButtons}} - @messageActions={{this.messageActions}} - @messageCapabilities={{this.messageCapabilities}} - @onHoverMessage={{@onHoverMessage}} - /> - {{/in-element}} -{{/if}} - -{{#if - (and - this.showActions this.site.desktopView this.chatMessageActionsDesktopAnchor - ) -}} - {{#in-element this.chatMessageActionsDesktopAnchor}} - <ChatMessageActionsDesktop - @message={{@message}} - @emojiReactions={{this.emojiReactions}} - @secondaryButtons={{this.secondaryButtons}} - @messageActions={{this.messageActions}} - @messageCapabilities={{this.messageCapabilities}} - /> - {{/in-element}} +{{#if (eq @context "channel")}} + <ChatMessageSeparatorDate @message={{@message}} /> + <ChatMessageSeparatorNew @message={{@message}} /> {{/if}} <div {{will-destroy this.teardownChatMessage}} - {{did-insert this.setMessageActionsAnchors}} {{did-insert this.decorateCookedMessage}} {{did-update this.decorateCookedMessage @message.id}} {{did-update this.decorateCookedMessage @message.version}} {{on "touchmove" this.handleTouchMove passive=true}} {{on "touchstart" this.handleTouchStart passive=true}} {{on "touchend" this.handleTouchEnd passive=true}} - {{on "mouseenter" (fn @onHoverMessage @message (hash desktopOnly=true))}} - {{on "mousemove" (fn @onHoverMessage @message (hash desktopOnly=true))}} - {{on "mouseleave" (fn @onHoverMessage null (hash desktopOnly=true))}} + {{on "mouseenter" this.onMouseEnter}} + {{on "mouseleave" this.onMouseLeave}} class={{concat-class "chat-message-container" - (if @isHovered "is-hovered") - (if @selectingMessages "selecting-messages") + (if this.pane.selectingMessages "selecting-messages") (if @message.highlighted "highlighted") }} data-id={{@message.id}} @@ -63,7 +29,7 @@ }} > {{#if this.show}} - {{#if @selectingMessages}} + {{#if this.pane.selectingMessages}} <Input @type="checkbox" class="chat-message-selector" @@ -98,7 +64,6 @@ (if this.hideUserInfo "user-info-hidden") (if @message.error "errored") (if @message.bookmark "chat-message-bookmarked") - (if @isHovered "chat-message-selected") }} > {{#unless this.hideReplyToInfo}} @@ -132,18 +97,20 @@ {{#each @message.reactions as |reaction|}} <ChatMessageReaction @reaction={{reaction}} - @react={{this.react}} + @onReaction={{this.messageInteractor.react}} + @message={{@message}} @showTooltip={{true}} /> {{/each}} - {{#if @canInteractWithChat}} + {{#if this.chat.userCanInteractWithChat}} {{#unless this.site.mobileView}} <DButton @class="chat-message-react-btn" - @action={{this.startReactionForReactionList}} + @action={{this.messageInteractor.openEmojiPicker}} @icon="discourse-emojis" @title="chat.react" + @forwardEvent={{true}} /> {{/unless}} {{/if}} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message.js b/plugins/chat/assets/javascripts/discourse/components/chat-message.js index f82bbdd0934..ea445f91e59 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message.js @@ -1,22 +1,17 @@ -import Bookmark from "discourse/models/bookmark"; -import { openBookmarkModal } from "discourse/controllers/bookmark"; import { isTesting } from "discourse-common/config/environment"; import Component from "@glimmer/component"; import I18n from "I18n"; -import getURL from "discourse-common/lib/get-url"; import optionalService from "discourse/lib/optional-service"; import { action } from "@ember/object"; import { ajax } from "discourse/lib/ajax"; import { cancel, schedule } from "@ember/runloop"; -import { clipboardCopy } from "discourse/lib/utilities"; import { inject as service } from "@ember/service"; -import { popupAjaxError } from "discourse/lib/ajax-error"; import discourseLater from "discourse-common/lib/later"; import isZoomed from "discourse/plugins/chat/discourse/lib/zoom-check"; -import showModal from "discourse/lib/show-modal"; -import ChatMessageFlag from "discourse/plugins/chat/discourse/lib/chat-message-flag"; -import { tracked } from "@glimmer/tracking"; -import ChatMessageReaction from "discourse/plugins/chat/discourse/models/chat-message-reaction"; +import { getOwner } from "discourse-common/lib/get-owner"; +import ChatMessageInteractor from "discourse/plugins/chat/discourse/lib/chat-message-interactor"; +import discourseDebounce from "discourse-common/lib/debounce"; +import { bind } from "discourse-common/utils/decorators"; let _chatMessageDecorators = []; @@ -29,8 +24,7 @@ export function resetChatMessageDecorators() { } export const MENTION_KEYWORDS = ["here", "all"]; - -export const REACTIONS = { add: "add", remove: "remove" }; +export const MESSAGE_CONTEXT_THREAD = "thread"; export default class ChatMessage extends Component { @service site; @@ -42,21 +36,25 @@ export default class ChatMessage extends Component { @service chatApi; @service chatEmojiReactionStore; @service chatEmojiPickerManager; + @service chatChannelPane; + @service chatChannelThreadPane; @service chatChannelsManager; @service router; - @tracked chatMessageActionsMobileAnchor = null; - @tracked chatMessageActionsDesktopAnchor = null; - @optionalService adminTools; - cachedFavoritesReactions = null; - reacting = false; + get pane() { + return this.args.context === MESSAGE_CONTEXT_THREAD + ? this.chatChannelThreadPane + : this.chatChannelPane; + } - constructor() { - super(...arguments); - - this.cachedFavoritesReactions = this.chatEmojiReactionStore.favorites; + get messageInteractor() { + return new ChatMessageInteractor( + getOwner(this), + this.args.message, + this.args.context + ); } get deletedAndCollapsed() { @@ -72,15 +70,17 @@ export default class ChatMessage extends Component { } @action - setMessageActionsAnchors() { - schedule("afterRender", () => { - this.chatMessageActionsDesktopAnchor = document.querySelector( - ".chat-message-actions-desktop-anchor" - ); - this.chatMessageActionsMobileAnchor = document.querySelector( - ".chat-message-actions-mobile-anchor" - ); - }); + expand() { + this.args.message.expanded = true; + } + + @action + toggleChecked(event) { + if (event.shiftKey) { + this.messageInteractor.bulkSelect(event.target.checked); + } + + this.messageInteractor.select(event.target.checked); } @action @@ -108,114 +108,6 @@ export default class ChatMessage extends Component { } } - get showActions() { - return ( - this.args.canInteractWithChat && - !this.args.message?.staged && - this.args.isHovered - ); - } - - get secondaryButtons() { - const buttons = []; - - buttons.push({ - id: "copyLinkToMessage", - name: I18n.t("chat.copy_link"), - icon: "link", - }); - - if (this.showEditButton) { - buttons.push({ - id: "edit", - name: I18n.t("chat.edit"), - icon: "pencil-alt", - }); - } - - if (!this.args.selectingMessages) { - buttons.push({ - id: "selectMessage", - name: I18n.t("chat.select"), - icon: "tasks", - }); - } - - if (this.canFlagMessage) { - buttons.push({ - id: "flag", - name: I18n.t("chat.flag"), - icon: "flag", - }); - } - - if (this.showDeleteButton) { - buttons.push({ - id: "deleteMessage", - name: I18n.t("chat.delete"), - icon: "trash-alt", - }); - } - - if (this.showRestoreButton) { - buttons.push({ - id: "restore", - name: I18n.t("chat.restore"), - icon: "undo", - }); - } - - if (this.showRebakeButton) { - buttons.push({ - id: "rebakeMessage", - name: I18n.t("chat.rebake_message"), - icon: "sync-alt", - }); - } - - if (this.hasThread) { - buttons.push({ - id: "openThread", - name: I18n.t("chat.threads.open"), - icon: "puzzle-piece", - }); - } - - return buttons; - } - - get messageActions() { - return { - reply: this.reply, - react: this.react, - copyLinkToMessage: this.copyLinkToMessage, - edit: this.edit, - selectMessage: this.selectMessage, - flag: this.flag, - deleteMessage: this.deleteMessage, - restore: this.restore, - rebakeMessage: this.rebakeMessage, - toggleBookmark: this.toggleBookmark, - openThread: this.openThread, - startReactionForMessageActions: this.startReactionForMessageActions, - }; - } - - get messageCapabilities() { - return { - canReact: this.canReact, - canReply: this.canReply, - canBookmark: this.showBookmarkButton, - hasThread: this.canReply && this.hasThread, - }; - } - - get hasThread() { - return ( - this.args.channel?.get("threading_enabled") && this.args.message?.threadId - ); - } - get show() { return ( !this.args.message?.deletedAt || @@ -225,6 +117,58 @@ export default class ChatMessage extends Component { ); } + @action + onMouseEnter() { + if (this.site.mobileView) { + return; + } + + if (this.pane.hoveredMessageId === this.args.message.id) { + return; + } + + this._onHoverMessageDebouncedHandler = discourseDebounce( + this, + this._debouncedOnHoverMessage, + 250 + ); + } + + @action + onMouseLeave(event) { + if (this.site.mobileView) { + return; + } + + if ( + (event.toElement || event.relatedTarget)?.closest( + ".chat-message-actions-container" + ) + ) { + return; + } + + cancel(this._onHoverMessageDebouncedHandler); + + this.chat.activeMessage = null; + } + + @bind + _debouncedOnHoverMessage() { + if (!this.chat.userCanInteractWithChat) { + return; + } + this._setActiveMessage(); + } + + _setActiveMessage() { + this.chat.activeMessage = { + model: this.args.message, + context: this.args.context, + }; + this.pane.hoveredMessageId = this.args.message.id; + } + @action handleTouchStart() { // if zoomed don't track long press @@ -232,24 +176,20 @@ export default class ChatMessage extends Component { return; } - if (!this.args.isHovered) { - // when testing this must be triggered immediately because there - // is no concept of "long press" there, the Ember `tap` test helper - // does send the touchstart/touchend events but immediately, see - // https://github.com/emberjs/ember-test-helpers/blob/master/API.md#tap - if (isTesting()) { - this._handleLongPress(); - } - - this._isPressingHandler = discourseLater(this._handleLongPress, 500); + // when testing this must be triggered immediately because there + // is no concept of "long press" there, the Ember `tap` test helper + // does send the touchstart/touchend events but immediately, see + // https://github.com/emberjs/ember-test-helpers/blob/master/API.md#tap + if (isTesting()) { + this._handleLongPress(); } + + this._isPressingHandler = discourseLater(this._handleLongPress, 500); } @action handleTouchMove() { - if (!this.args.isHovered) { - cancel(this._isPressingHandler); - } + cancel(this._isPressingHandler); } @action @@ -267,7 +207,7 @@ export default class ChatMessage extends Component { document.activeElement.blur(); document.querySelector(".chat-composer-input")?.blur(); - this.args.onHoverMessage?.(this.args.message); + this._setActiveMessage(); } get hideUserInfo() { @@ -297,81 +237,12 @@ export default class ChatMessage extends Component { get hideReplyToInfo() { return ( + this.args.context === MESSAGE_CONTEXT_THREAD || this.args.message?.inReplyTo?.id === - this.args.message?.previousMessage?.id + this.args.message?.previousMessage?.id ); } - get showEditButton() { - return ( - !this.args.message?.deletedAt && - this.currentUser?.id === this.args.message?.user?.id && - this.args.channel?.canModifyMessages?.(this.currentUser) - ); - } - - get canFlagMessage() { - return ( - this.currentUser?.id !== this.args.message?.user?.id && - !this.args.channel?.isDirectMessageChannel && - this.args.message?.userFlagStatus === undefined && - this.args.channel?.canFlag && - !this.args.message?.chatWebhookEvent && - !this.args.message?.deletedAt - ); - } - - get canManageDeletion() { - return this.currentUser?.id === this.args.message.user.id - ? this.args.channel?.canDeleteSelf - : this.args.channel?.canDeleteOthers; - } - - get canReply() { - return ( - !this.args.message?.deletedAt && - this.args.channel?.canModifyMessages?.(this.currentUser) - ); - } - - get canReact() { - return ( - !this.args.message?.deletedAt && - this.args.channel?.canModifyMessages?.(this.currentUser) - ); - } - - get showDeleteButton() { - return ( - this.canManageDeletion && - !this.args.message?.deletedAt && - this.args.channel?.canModifyMessages?.(this.currentUser) - ); - } - - get showRestoreButton() { - return ( - this.canManageDeletion && - this.args.message?.deletedAt && - this.args.channel?.canModifyMessages?.(this.currentUser) - ); - } - - get showBookmarkButton() { - return this.args.channel?.canModifyMessages?.(this.currentUser); - } - - get showRebakeButton() { - return ( - this.currentUser?.staff && - this.args.channel?.canModifyMessages?.(this.currentUser) - ); - } - - get hasReactions() { - return Object.values(this.args.message.reactions).some((r) => r.count > 0); - } - get mentionWarning() { return this.args.message.mentionWarning; } @@ -447,261 +318,4 @@ export default class ChatMessage extends Component { dismissMentionWarning() { this.args.message.mentionWarning = null; } - - @action - startReactionForMessageActions() { - this.chatEmojiPickerManager.startFromMessageActions( - this.args.message, - this.selectReaction, - { desktop: this.site.desktopView } - ); - } - - @action - startReactionForReactionList() { - this.chatEmojiPickerManager.startFromMessageReactionList( - this.args.message, - this.selectReaction, - { desktop: this.site.desktopView } - ); - } - - deselectReaction(emoji) { - if (!this.args.canInteractWithChat) { - return; - } - - this.react(emoji, REACTIONS.remove); - } - - @action - selectReaction(emoji) { - if (!this.args.canInteractWithChat) { - return; - } - - this.react(emoji, REACTIONS.add); - } - - @action - react(emoji, reactAction) { - if (!this.args.canInteractWithChat) { - return; - } - - if (this.reacting) { - return; - } - - if (this.capabilities.canVibrate && !isTesting()) { - navigator.vibrate(5); - } - - if (this.site.mobileView) { - this.args.onHoverMessage(null); - } - - if (reactAction === REACTIONS.add) { - this.chatEmojiReactionStore.track(`:${emoji}:`); - } - - this.reacting = true; - - this.args.message.react( - emoji, - reactAction, - this.currentUser, - this.currentUser.id - ); - - return ajax( - `/chat/${this.args.message.channelId}/react/${this.args.message.id}`, - { - type: "PUT", - data: { - react_action: reactAction, - emoji, - }, - } - ) - .catch((errResult) => { - popupAjaxError(errResult); - this.args.message.react( - emoji, - REACTIONS.remove, - this.currentUser, - this.currentUser.id - ); - }) - .finally(() => { - this.reacting = false; - }); - } - - // TODO(roman): For backwards-compatibility. - // Remove after the 3.0 release. - _legacyFlag() { - this.dialog.yesNoConfirm({ - message: I18n.t("chat.confirm_flag", { - username: this.args.message.user?.username, - }), - didConfirm: () => { - return ajax("/chat/flag", { - method: "PUT", - data: { - chat_message_id: this.args.message.id, - flag_type_id: 7, // notify_moderators - }, - }).catch(popupAjaxError); - }, - }); - } - - @action - reply() { - this.args.setReplyTo(this.args.message.id); - } - - @action - edit() { - this.args.editButtonClicked(this.args.message.id); - } - - @action - flag() { - const targetFlagSupported = - requirejs.entries["discourse/lib/flag-targets/flag"]; - - if (targetFlagSupported) { - const model = this.args.message; - model.username = model.user?.username; - model.user_id = model.user?.id; - let controller = showModal("flag", { model }); - controller.set("flagTarget", new ChatMessageFlag()); - } else { - this._legacyFlag(); - } - } - - @action - expand() { - this.args.message.expanded = true; - } - - @action - restore() { - return ajax( - `/chat/${this.args.message.channelId}/restore/${this.args.message.id}`, - { - type: "PUT", - } - ).catch(popupAjaxError); - } - - @action - openThread() { - this.router.transitionTo("chat.channel.thread", this.args.message.threadId); - } - - @action - toggleBookmark() { - return openBookmarkModal( - this.args.message.bookmark || - Bookmark.createFor( - this.currentUser, - "Chat::Message", - this.args.message.id - ), - { - onAfterSave: (savedData) => { - const bookmark = Bookmark.create(savedData); - this.args.message.bookmark = bookmark; - this.appEvents.trigger( - "bookmarks:changed", - savedData, - bookmark.attachedTo() - ); - }, - onAfterDelete: () => { - this.args.message.bookmark = null; - }, - } - ); - } - - @action - rebakeMessage() { - return ajax( - `/chat/${this.args.message.channelId}/${this.args.message.id}/rebake`, - { - type: "PUT", - } - ).catch(popupAjaxError); - } - - @action - deleteMessage() { - return this.chatApi - .trashMessage(this.args.message.channelId, this.args.message.id) - .catch(popupAjaxError); - } - - @action - selectMessage() { - this.args.message.selected = true; - this.args.onStartSelectingMessages(this.args.message); - } - - @action - toggleChecked(e) { - if (e.shiftKey) { - this.args.bulkSelectMessages(this.args.message, e.target.checked); - } - - this.args.onSelectMessage(this.args.message); - } - - @action - copyLinkToMessage() { - if (!this.messageContainer) { - return; - } - - this.messageContainer - .querySelector(".link-to-message-btn") - ?.classList?.add("copied"); - - const { protocol, host } = window.location; - let url = getURL( - `/chat/c/-/${this.args.message.channelId}/${this.args.message.id}` - ); - url = url.indexOf("/") === 0 ? protocol + "//" + host + url : url; - clipboardCopy(url); - - discourseLater(() => { - this.messageContainer - ?.querySelector(".link-to-message-btn") - ?.classList?.remove("copied"); - }, 250); - } - - get emojiReactions() { - let favorites = this.cachedFavoritesReactions; - - // may be a {} if no defaults defined in some production builds - if (!favorites || !favorites.slice) { - return []; - } - - return favorites.slice(0, 3).map((emoji) => { - return ( - this.args.message.reactions.find( - (reaction) => reaction.emoji === emoji - ) || - ChatMessageReaction.create({ - emoji, - }) - ); - }); - } } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-selection-manager.js b/plugins/chat/assets/javascripts/discourse/components/chat-selection-manager.js index fe1dadf34ef..f346cf02e59 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-selection-manager.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-selection-manager.js @@ -10,11 +10,13 @@ import { schedule } from "@ember/runloop"; import { inject as service } from "@ember/service"; import getURL from "discourse-common/lib/get-url"; import { bind } from "discourse-common/utils/decorators"; +import { MESSAGE_CONTEXT_THREAD } from "discourse/plugins/chat/discourse/components/chat-message"; -export default class AdminCustomizeColorsShowController extends Component { +export default class ChatSelectionManager extends Component { @service router; tagName = ""; chatChannel = null; + context = null; selectedMessageIds = null; chatCopySuccess = false; showChatCopySuccess = false; @@ -28,7 +30,9 @@ export default class AdminCustomizeColorsShowController extends Component { @computed("chatChannel.isDirectMessageChannel", "chatChannel.canModerate") get showMoveMessageButton() { return ( - !this.chatChannel.isDirectMessageChannel && this.chatChannel.canModerate + this.context !== MESSAGE_CONTEXT_THREAD && + !this.chatChannel.isDirectMessageChannel && + this.chatChannel.canModerate ); } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs index 0813dffb22e..9be768b82c6 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-thread.hbs @@ -2,61 +2,58 @@ class={{concat-class "chat-thread" (if this.loading "loading")}} data-id={{this.thread.id}} {{did-insert this.loadMessages}} + {{did-update this.thread.id this.loadMessages}} > <div class="chat-thread__header"> - <div class="chat-thread__info"> - <div class="chat-thread__title"> - <h2>{{this.title}}</h2> + <span class="chat-thread__label">{{i18n "chat.thread.label"}}</span> + <LinkTo + class="chat-thread__close" + @route="chat.channel" + @models={{this.chat.activeChannel.routeModels}} + > + {{d-icon "times"}} + </LinkTo> + </div> - <LinkTo - class="chat-thread__close" - @route="chat.channel" - @models={{this.chat.activeChannel.routeModels}} - > - {{d-icon "times"}} - </LinkTo> - </div> - - <p class="chat-thread__om"> - {{replace-emoji this.thread.originalMessage.excerpt}} - </p> - - <div class="chat-thread__omu"> - <span class="chat-thread__started-by">{{i18n - "chat.threads.started_by" - }}</span> - <ChatMessageAvatar - class="chat-thread__omu-avatar" - @message={{this.thread.originalMessage}} + <div class="chat-thread__body" {{did-insert this.setScrollable}}> + <div + class="chat-thread__messages chat-messages-container" + {{chat/on-resize this.didResizePane (hash delay=10)}} + > + {{#each this.thread.messages key="id" as |message|}} + <ChatMessage + @message={{message}} + @channel={{this.channel}} + @resendStagedMessage={{this.resendStagedMessage}} + @messageDidEnterViewport={{this.messageDidEnterViewport}} + @messageDidLeaveViewport={{this.messageDidLeaveViewport}} + @context="thread" /> - <span - class="chat-thread__omu-username" - >{{this.thread.originalMessageUser.username}}</span> - </div> + {{/each}} + {{#if (or this.loading this.loadingMoreFuture)}} + <ChatSkeleton /> + {{/if}} </div> </div> - <div class="chat-thread__messages"> - <ul> - {{#each this.thread.messages as |message|}} - <li><strong>{{message.user.username}}</strong>: {{message.message}}</li> - {{/each}} - </ul> - {{#if (or this.loading this.loadingMoreFuture)}} - <ChatSkeleton /> - {{/if}} - </div> - <ChatComposer - @canInteractWithChat="true" - @sendMessage={{this.sendMessage}} - @editMessage={{this.editMessage}} - @setReplyTo={{this.setReplyTo}} - @loading={{this.sendingLoading}} - @editingMessage={{readonly this.editingMessage}} - @onCancelEditing={{this.cancelEditing}} - @setInReplyToMsg={{this.setInReplyToMsg}} - @onEditLastMessageRequested={{this.editLastMessageRequested}} - @onValueChange={{this.composerValueChanged}} - @chatChannel={{this.channel}} - /> + {{#if this.chatChannelThreadPane.selectingMessages}} + <ChatSelectionManager + @selectedMessageIds={{this.chatChannelThreadPane.selectedMessageIds}} + @chatChannel={{this.chat.activeChannel}} + @cancelSelecting={{action + this.chatChannelThreadPane.cancelSelecting + this.chat.activeChannel.selectedMessages + }} + @context="thread" + /> + {{else}} + <ChatComposer + @sendMessage={{this.sendMessage}} + @onCancelEditing={{this.cancelEditing}} + @chatChannel={{this.channel}} + @composerService={{this.chatChannelThreadComposer}} + @paneService={{this.chatChannelThreadPane}} + @context="thread" + /> + {{/if}} </div> \ 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 1ca9041181b..eb1e12521c8 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-thread.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-thread.js @@ -6,8 +6,9 @@ import { action } from "@ember/object"; import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; import { popupAjaxError } from "discourse/lib/ajax-error"; import { bind, debounce } from "discourse-common/utils/decorators"; -import I18n from "I18n"; import { inject as service } from "@ember/service"; +import { schedule } from "@ember/runloop"; +import discourseLater from "discourse-common/lib/later"; const PAGE_SIZE = 50; @@ -18,11 +19,16 @@ export default class ChatThreadPanel extends Component { @service router; @service chatApi; @service chatComposerPresenceManager; + @service chatChannelThreadComposer; + @service chatChannelThreadPane; @service appEvents; + @service capabilities; @tracked loading; @tracked loadingMorePast; + scrollable = null; + get thread() { return this.channel.activeThread; } @@ -31,12 +37,9 @@ export default class ChatThreadPanel extends Component { return this.chat.activeChannel; } - get title() { - if (this.thread.title) { - this.thread.escapedTitle; - } - - return I18n.t("chat.threads.op_said"); + @action + setScrollable(element) { + this.scrollable = element; } @action @@ -53,6 +56,11 @@ export default class ChatThreadPanel extends Component { // } } + @action + didResizePane() { + this.forceRendering(); + } + get _selfDeleted() { return this.isDestroying || this.isDestroyed; } @@ -93,9 +101,6 @@ export default class ChatThreadPanel extends Component { const [messages, meta] = this.afterFetchCallback(this.channel, results); this.thread.messagesManager.addMessages(messages); - // TODO (martin) ECHO MODE - this.channel.messagesManager.addMessages(messages); - // TODO (martin) details needed for thread?? this.thread.details = meta; @@ -127,7 +132,6 @@ export default class ChatThreadPanel extends Component { @bind afterFetchCallback(channel, results) { const messages = []; - let foundFirstNew = false; results.chat_messages.forEach((messageData) => { // If a message has been hidden it is because the current user is ignoring @@ -145,16 +149,6 @@ export default class ChatThreadPanel extends Component { messageData.expanded = !(messageData.hidden || messageData.deleted_at); } - // newest has to be in after fetch callback as we don't want to make it - // dynamic or it will make the pane jump around, it will disappear on reload - if ( - !foundFirstNew && - messageData.id > channel.currentUserMembership.last_read_message_id - ) { - foundFirstNew = true; - messageData.newest = true; - } - messages.push(ChatMessage.create(channel, messageData)); }); @@ -165,11 +159,11 @@ export default class ChatThreadPanel extends Component { sendMessage(message, uploads = []) { // TODO (martin) For desktop notifications // resetIdle() - if (this.sendingLoading) { + if (this.chatChannelThreadPane.sendingLoading) { return; } - this.sendingLoading = true; + this.chatChannelThreadPane.sendingLoading = true; this.channel.draft = ChatMessageDraft.create(); // TODO (martin) Handling case when channel is not followed???? IDK if we @@ -199,8 +193,7 @@ export default class ChatThreadPanel extends Component { thread_id: stagedMessage.threadId, }) .then(() => { - // TODO (martin) Scrolling!! - // this.scrollToBottom(); + this.scrollToBottom(); }) .catch((error) => { this.#onSendError(stagedMessage.stagedId, error); @@ -209,35 +202,70 @@ export default class ChatThreadPanel extends Component { if (this._selfDeleted) { return; } - this.sendingLoading = false; - this.#resetAfterSend(); + this.chatChannelThreadPane.sendingLoading = false; + this.chatChannelThreadPane.resetAfterSend(); }); } + // A more consistent way to scroll to the bottom when we are sure this is our goal + // it will also limit issues with any element changing the height while we are scrolling + // to the bottom @action - editMessage() {} - // editMessage(chatMessage, newContent, uploads) {} + scrollToBottom() { + if (!this.scrollable) { + return; + } - @action - setReplyTo() {} - // setReplyTo(messageId) {} + this.scrollable.scrollTop = -1; + this.forceRendering(() => { + this.scrollable.scrollTop = 0; + }); + } - @action - setInReplyToMsg(inReplyMsg) { - this.replyToMsg = inReplyMsg; + // since -webkit-overflow-scrolling: touch can't be used anymore to disable momentum scrolling + // we now use this hack to disable it + @bind + forceRendering(callback) { + schedule("afterRender", () => { + if (this._selfDeleted) { + return; + } + + if (!this.scrollable) { + return; + } + + if (this.capabilities.isIOS) { + this.scrollable.style.overflow = "hidden"; + } + + callback?.(); + + if (this.capabilities.isIOS) { + discourseLater(() => { + if (!this.scrollable) { + return; + } + + this.scrollable.style.overflow = "auto"; + }, 50); + } + }); } @action - cancelEditing() { - this.editingMessage = null; + resendStagedMessage() {} + // resendStagedMessage(stagedMessage) {} + + @action + messageDidEnterViewport(message) { + message.visible = true; } @action - editLastMessageRequested() {} - - @action - composerValueChanged() {} - // composerValueChanged(value, uploads, replyToMsg) {} + messageDidLeaveViewport(message) { + message.visible = false; + } #handleErrors(error) { switch (error?.jqXHR?.status) { @@ -262,17 +290,6 @@ export default class ChatThreadPanel extends Component { } } - this.#resetAfterSend(); - } - - #resetAfterSend() { - if (this._selfDeleted) { - return; - } - - this.replyToMsg = null; - this.editingMessage = null; - this.chatComposerPresenceManager.notifyState(this.channel.id, false); - this.appEvents.trigger("chat-composer:reply-to-set", null); + this.chatChannelThreadPane.resetAfterSend(); } } diff --git a/plugins/chat/assets/javascripts/discourse/connectors/below-footer/chat-channel-message-emoji-picker-connector.hbs b/plugins/chat/assets/javascripts/discourse/connectors/below-footer/chat-channel-message-emoji-picker-connector.hbs new file mode 100644 index 00000000000..f99d5cc1a02 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/connectors/below-footer/chat-channel-message-emoji-picker-connector.hbs @@ -0,0 +1 @@ +<ChatChannelMessageEmojiPicker /> \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/connectors/below-footer/chat-message-actions-desktop-outlet.hbs b/plugins/chat/assets/javascripts/discourse/connectors/below-footer/chat-message-actions-desktop-outlet.hbs new file mode 100644 index 00000000000..fb4d97e44ae --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/connectors/below-footer/chat-message-actions-desktop-outlet.hbs @@ -0,0 +1 @@ +<ChatMessageActionsDesktop /> \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/connectors/below-footer/chat-message-actions-mobile-outlet.hbs b/plugins/chat/assets/javascripts/discourse/connectors/below-footer/chat-message-actions-mobile-outlet.hbs new file mode 100644 index 00000000000..496de999995 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/connectors/below-footer/chat-message-actions-mobile-outlet.hbs @@ -0,0 +1 @@ +<ChatMessageActionsMobile /> \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js index b34b2a4d94d..1f335138743 100644 --- a/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js @@ -60,11 +60,27 @@ export default { class: "chat-emoji-btn", icon: "discourse-emojis", position: "dropdown", + context: "channel", action() { const chatEmojiPickerManager = container.lookup( "service:chat-emoji-picker-manager" ); - chatEmojiPickerManager.startFromComposer(this.didSelectEmoji); + chatEmojiPickerManager.open({ context: "channel" }); + }, + }); + + api.registerChatComposerButton({ + label: "chat.emoji", + id: "channel-emoji", + class: "chat-emoji-btn", + icon: "discourse-emojis", + position: "dropdown", + context: "thread", + action() { + const chatEmojiPickerManager = container.lookup( + "service:chat-emoji-picker-manager" + ); + chatEmojiPickerManager.open({ context: "thread" }); }, }); diff --git a/plugins/chat/assets/javascripts/discourse/lib/chat-composer-buttons.js b/plugins/chat/assets/javascripts/discourse/lib/chat-composer-buttons.js index 94ca01e9729..629683aff2f 100644 --- a/plugins/chat/assets/javascripts/discourse/lib/chat-composer-buttons.js +++ b/plugins/chat/assets/javascripts/discourse/lib/chat-composer-buttons.js @@ -66,54 +66,60 @@ export function chatComposerButtonsDependentKeys() { ); } -export function chatComposerButtons(context, position) { +export function chatComposerButtons(composer, position, context) { return Object.values(_chatComposerButtons) - .filter( - (button) => - computeButton(context, button, "displayed") && - computeButton(context, button, "position") === position - ) + .filter((button) => { + let valid = + computeButton(composer, button, "displayed") && + computeButton(composer, button, "position") === position; + + if (button.context) { + valid = valid && computeButton(composer, button, "context") === context; + } + + return valid; + }) .map((button) => { const result = { id: button.id }; - const label = computeButton(context, button, "label"); + const label = computeButton(composer, button, "label"); result.label = label ? label - : computeButton(context, button, "translatedLabel"); + : computeButton(composer, button, "translatedLabel"); - const ariaLabel = computeButton(context, button, "ariaLabel"); + const ariaLabel = computeButton(composer, button, "ariaLabel"); if (ariaLabel) { result.ariaLabel = I18n.t(ariaLabel); } else { const translatedAriaLabel = computeButton( - context, + composer, button, "translatedAriaLabel" ); result.ariaLabel = translatedAriaLabel || result.label; } - const title = computeButton(context, button, "title"); + const title = computeButton(composer, button, "title"); result.title = title ? I18n.t(title) - : computeButton(context, button, "translatedTitle"); + : computeButton(composer, button, "translatedTitle"); result.classNames = ( - computeButton(context, button, "classNames") || [] + computeButton(composer, button, "classNames") || [] ).join(" "); - result.icon = computeButton(context, button, "icon"); - result.disabled = computeButton(context, button, "disabled"); - result.priority = computeButton(context, button, "priority"); + result.icon = computeButton(composer, button, "icon"); + result.disabled = computeButton(composer, button, "disabled"); + result.priority = computeButton(composer, button, "priority"); if (isFunction(button.action)) { result.action = () => { - button.action.apply(context); + button.action.apply(composer); }; } else { const actionName = button.action; result.action = () => { - context[actionName](); + composer[actionName](); }; } diff --git a/plugins/chat/assets/javascripts/discourse/lib/chat-message-container.js b/plugins/chat/assets/javascripts/discourse/lib/chat-message-container.js new file mode 100644 index 00000000000..2be3b6aec36 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/chat-message-container.js @@ -0,0 +1,13 @@ +import { MESSAGE_CONTEXT_THREAD } from "discourse/plugins/chat/discourse/components/chat-message"; + +export default function chatMessageContainer(id, context) { + let selector; + + 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}"]`; + } + + 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 new file mode 100644 index 00000000000..c37d86c0f9d --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/chat-message-interactor.js @@ -0,0 +1,399 @@ +import getURL from "discourse-common/lib/get-url"; +import { bind } from "discourse-common/utils/decorators"; +import showModal from "discourse/lib/show-modal"; +import ChatMessageFlag from "discourse/plugins/chat/discourse/lib/chat-message-flag"; +import Bookmark from "discourse/models/bookmark"; +import { openBookmarkModal } from "discourse/controllers/bookmark"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; +import { isTesting } from "discourse-common/config/environment"; +import { clipboardCopy } from "discourse/lib/utilities"; +import ChatMessageReaction, { + REACTIONS, +} from "discourse/plugins/chat/discourse/models/chat-message-reaction"; +import { getOwner, setOwner } from "@ember/application"; +import { tracked } from "@glimmer/tracking"; +import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; +import { MESSAGE_CONTEXT_THREAD } from "discourse/plugins/chat/discourse/components/chat-message"; +import I18n from "I18n"; + +export default class ChatMessageInteractor { + @service appEvents; + @service dialog; + @service chat; + @service chatEmojiReactionStore; + @service chatEmojiPickerManager; + @service chatChannelComposer; + @service chatChannelThreadComposer; + @service chatChannelPane; + @service chatChannelThreadPane; + @service chatApi; + @service currentUser; + @service site; + @service router; + + @tracked message = null; + @tracked context = null; + + cachedFavoritesReactions = null; + + constructor(owner, message, context) { + setOwner(this, owner); + + this.message = message; + this.context = context; + this.cachedFavoritesReactions = this.chatEmojiReactionStore.favorites; + } + + get capabilities() { + return getOwner(this).lookup("capabilities:main"); + } + + get pane() { + return this.context === MESSAGE_CONTEXT_THREAD + ? this.chatChannelThreadPane + : this.chatChannelPane; + } + + get emojiReactions() { + let favorites = this.cachedFavoritesReactions; + + // may be a {} if no defaults defined in some production builds + if (!favorites || !favorites.slice) { + return []; + } + + return favorites.slice(0, 3).map((emoji) => { + return ( + this.message.reactions.find((reaction) => reaction.emoji === emoji) || + ChatMessageReaction.create({ emoji }) + ); + }); + } + + get canEdit() { + return ( + !this.message.deletedAt && + this.currentUser.id === this.message.user.id && + this.message.channel?.canModifyMessages?.(this.currentUser) + ); + } + + get canInteractWithMessage() { + return ( + !this.message?.deletedAt && + this.message?.channel?.canModifyMessages(this.currentUser) + ); + } + + get canRestoreMessage() { + return ( + this.canDelete && + this.message?.deletedAt && + this.message.channel?.canModifyMessages?.(this.currentUser) + ); + } + + get canBookmark() { + return this.message?.channel?.canModifyMessages?.(this.currentUser); + } + + get canReply() { + return ( + this.canInteractWithMessage && this.context !== MESSAGE_CONTEXT_THREAD + ); + } + + get canReact() { + return this.canInteractWithMessage; + } + + get canFlagMessage() { + return ( + this.currentUser?.id !== this.message?.user?.id && + !this.message.channel?.isDirectMessageChannel && + this.message?.userFlagStatus === undefined && + this.message.channel?.canFlag && + !this.message?.chatWebhookEvent && + !this.message?.deletedAt + ); + } + + get canOpenThread() { + return ( + this.context !== MESSAGE_CONTEXT_THREAD && + this.message.channel?.threadingEnabled && + this.message?.threadId + ); + } + + get canRebakeMessage() { + return ( + this.currentUser?.staff && + this.message.channel?.canModifyMessages?.(this.currentUser) + ); + } + + get canDeleteMessage() { + return ( + this.canDelete && + !this.message?.deletedAt && + this.message.channel?.canModifyMessages?.(this.currentUser) + ); + } + + get canDelete() { + return this.currentUser?.id === this.message.user.id + ? this.message.channel?.canDeleteSelf + : this.message.channel?.canDeleteOthers; + } + + get composer() { + return this.context === MESSAGE_CONTEXT_THREAD + ? this.chatChannelThreadComposer + : this.chatChannelComposer; + } + + get secondaryButtons() { + const buttons = []; + + buttons.push({ + id: "copyLink", + name: I18n.t("chat.copy_link"), + icon: "link", + }); + + if (this.canEdit) { + buttons.push({ + id: "edit", + name: I18n.t("chat.edit"), + icon: "pencil-alt", + }); + } + + if (!this.pane.selectingMessages) { + buttons.push({ + id: "select", + name: I18n.t("chat.select"), + icon: "tasks", + }); + } + + if (this.canFlagMessage) { + buttons.push({ + id: "flag", + name: I18n.t("chat.flag"), + icon: "flag", + }); + } + + if (this.canDeleteMessage) { + buttons.push({ + id: "delete", + name: I18n.t("chat.delete"), + icon: "trash-alt", + }); + } + + if (this.canRestoreMessage) { + buttons.push({ + id: "restore", + name: I18n.t("chat.restore"), + icon: "undo", + }); + } + + if (this.canRebakeMessage) { + buttons.push({ + id: "rebake", + name: I18n.t("chat.rebake_message"), + icon: "sync-alt", + }); + } + + if (this.canOpenThread) { + buttons.push({ + id: "openThread", + name: I18n.t("chat.threads.open"), + icon: "puzzle-piece", + }); + } + + return buttons; + } + + select(checked = true) { + this.message.selected = checked; + this.pane.onSelectMessage(this.message); + } + + bulkSelect(checked) { + const channel = this.message.channel; + const lastSelectedIndex = channel.findIndexOfMessage( + this.pane.lastSelectedMessage + ); + const newlySelectedIndex = channel.findIndexOfMessage(this.message); + const sortedIndices = [lastSelectedIndex, newlySelectedIndex].sort( + (a, b) => a - b + ); + + for (let i = sortedIndices[0]; i <= sortedIndices[1]; i++) { + channel.messages[i].selected = checked; + } + } + + copyLink() { + const { protocol, host } = window.location; + let url = getURL(`/chat/c/-/${this.message.channelId}/${this.message.id}`); + url = url.indexOf("/") === 0 ? protocol + "//" + host + url : url; + clipboardCopy(url); + } + + @action + react(emoji, reactAction) { + if (!this.chat.userCanInteractWithChat) { + return; + } + + if (this.pane.reacting) { + return; + } + + if (this.capabilities.canVibrate && !isTesting()) { + navigator.vibrate(5); + } + + if (this.site.mobileView) { + this.chat.activeMessage = null; + } + + if (reactAction === REACTIONS.add) { + this.chatEmojiReactionStore.track(`:${emoji}:`); + } + + this.pane.reacting = true; + + this.message.react( + emoji, + reactAction, + this.currentUser, + this.currentUser.id + ); + + return this.chatApi + .publishReaction( + this.message.channelId, + this.message.id, + emoji, + reactAction + ) + .catch((errResult) => { + popupAjaxError(errResult); + this.message.react( + emoji, + REACTIONS.remove, + this.currentUser, + this.currentUser.id + ); + }) + .finally(() => { + this.pane.reacting = false; + }); + } + + @action + toggleBookmark() { + return openBookmarkModal( + this.message.bookmark || + Bookmark.createFor(this.currentUser, "Chat::Message", this.message.id), + { + onAfterSave: (savedData) => { + const bookmark = Bookmark.create(savedData); + this.message.bookmark = bookmark; + this.appEvents.trigger( + "bookmarks:changed", + savedData, + bookmark.attachedTo() + ); + }, + onAfterDelete: () => { + this.message.bookmark = null; + }, + } + ); + } + + @action + flag() { + const model = new ChatMessage(this.message.channel, this.message); + model.username = this.message.user?.username; + model.user_id = this.message.user?.id; + const controller = showModal("flag", { model }); + controller.set("flagTarget", new ChatMessageFlag()); + } + + @action + delete() { + return this.chatApi + .trashMessage(this.message.channelId, this.message.id) + .catch(popupAjaxError); + } + + @action + restore() { + return this.chatApi + .restoreMessage(this.message.channelId, this.message.id) + .catch(popupAjaxError); + } + + @action + rebake() { + return this.chatApi + .rebakeMessage(this.message.channelId, this.message.id) + .catch(popupAjaxError); + } + + @action + reply() { + this.composer.setReplyTo(this.message.id); + } + + @action + edit() { + this.composer.editButtonClicked(this.message.id); + } + + @action + openThread() { + this.router.transitionTo( + "chat.channel.thread", + ...this.message.channel.routeModels, + this.message.threadId + ); + } + + @action + openEmojiPicker(_, { target }) { + const pickerState = { + didSelectEmoji: this.selectReaction, + trigger: target, + context: "chat-channel-message", + }; + this.chatEmojiPickerManager.open(pickerState); + } + + @bind + selectReaction(emoji) { + if (!this.chat.userCanInteractWithChat) { + return; + } + + this.react(emoji, REACTIONS.add); + } + + @action + handleSecondaryButtons(id) { + this[id](this.message); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-channel.js b/plugins/chat/assets/javascripts/discourse/models/chat-channel.js index eaa0f116227..207174e7de9 100644 --- a/plugins/chat/assets/javascripts/discourse/models/chat-channel.js +++ b/plugins/chat/assets/javascripts/discourse/models/chat-channel.js @@ -66,6 +66,10 @@ export default class ChatChannel extends RestModel { threadsManager = new ChatThreadsManager(getOwner(this)); messagesManager = new ChatMessagesManager(getOwner(this)); + findIndexOfMessage(message) { + return this.messages.findIndex((m) => m.id === message.id); + } + get messages() { return this.messagesManager.messages; } @@ -90,6 +94,10 @@ export default class ChatChannel extends RestModel { return [this.slugifiedTitle, this.id]; } + get selectedMessages() { + return this.messages.filter((message) => message.selected); + } + get isDirectMessageChannel() { return this.chatableType === CHATABLE_TYPES.directMessageChannel; } @@ -186,6 +194,7 @@ ChatChannel.reopenClass({ this._remapKey(args, "chatable_type", "chatableType"); this._remapKey(args, "memberships_count", "membershipsCount"); this._remapKey(args, "last_message_sent_at", "lastMessageSentAt"); + this._remapKey(args, "threading_enabled", "threadingEnabled"); return this._super(args); }, diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-message-reaction.js b/plugins/chat/assets/javascripts/discourse/models/chat-message-reaction.js index db1b7a6cecb..fb1c6e761e4 100644 --- a/plugins/chat/assets/javascripts/discourse/models/chat-message-reaction.js +++ b/plugins/chat/assets/javascripts/discourse/models/chat-message-reaction.js @@ -2,6 +2,8 @@ import { tracked } from "@glimmer/tracking"; import User from "discourse/models/user"; import { TrackedArray } from "@ember-compat/tracked-built-ins"; +export const REACTIONS = { add: "add", remove: "remove" }; + export default class ChatMessageReaction { static create(args = {}) { return new ChatMessageReaction(args); diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-message.js b/plugins/chat/assets/javascripts/discourse/models/chat-message.js index 7475c17e1ec..e1e6b657822 100644 --- a/plugins/chat/assets/javascripts/discourse/models/chat-message.js +++ b/plugins/chat/assets/javascripts/discourse/models/chat-message.js @@ -56,19 +56,20 @@ export default class ChatMessage { this.firstOfResults = args.firstOfResults; this.staged = args.staged; this.edited = args.edited; - this.availableFlags = args.available_flags; + this.availableFlags = args.availableFlags || args.available_flags; this.hidden = args.hidden; - this.threadId = args.thread_id; - this.channelId = args.chat_channel_id; - this.chatWebhookEvent = args.chat_webhook_event; - this.createdAt = args.created_at; - this.deletedAt = args.deleted_at; + this.threadId = args.threadId || args.thread_id; + this.channelId = args.channelId || args.chat_channel_id; + this.chatWebhookEvent = args.chatWebhookEvent || args.chat_webhook_event; + this.createdAt = args.createdAt || args.created_at; + this.deletedAt = args.deletedAt || args.deleted_at; this.excerpt = args.excerpt; - this.reviewableId = args.reviewable_id; - this.userFlagStatus = args.user_flag_status; - this.inReplyTo = args.in_reply_to - ? ChatMessage.create(channel, args.in_reply_to) - : null; + 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); this.reactions = this.#initChatMessageReactionModel( diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-thread.js b/plugins/chat/assets/javascripts/discourse/models/chat-thread.js index 599bacccdfb..1ee2c07f79b 100644 --- a/plugins/chat/assets/javascripts/discourse/models/chat-thread.js +++ b/plugins/chat/assets/javascripts/discourse/models/chat-thread.js @@ -20,12 +20,10 @@ export default class ChatThread { constructor(args = {}) { this.title = args.title; this.id = args.id; + this.channelId = args.channel_id; this.status = args.status; this.originalMessageUser = this.#initUserModel(args.original_message_user); - - // TODO (martin) Not sure if ChatMessage is needed here, original_message - // only has a small subset of message stuff. this.originalMessage = args.original_message; this.originalMessage.user = this.originalMessageUser; } @@ -38,6 +36,10 @@ export default class ChatThread { this.messagesManager.messages = messages; } + get selectedMessages() { + return this.messages.filter((message) => message.selected); + } + get escapedTitle() { return escapeExpression(this.title); } diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat-channel-decorator.js b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-decorator.js index 53e16e8a6e0..383dcbc5a36 100644 --- a/plugins/chat/assets/javascripts/discourse/routes/chat-channel-decorator.js +++ b/plugins/chat/assets/javascripts/discourse/routes/chat-channel-decorator.js @@ -14,6 +14,10 @@ export default function withChatChannel(extendedClass) { this.controllerFor("chat-channel").set("targetMessageId", null); this.chat.activeChannel = model; + if (!model) { + return this.router.replaceWith("chat"); + } + let { messageId, channelTitle } = this.paramsFor(this.routeName); // messageId query param backwards-compatibility diff --git a/plugins/chat/assets/javascripts/discourse/routes/chat.js b/plugins/chat/assets/javascripts/discourse/routes/chat.js index 28f4b8cf33c..796e3e8f34e 100644 --- a/plugins/chat/assets/javascripts/discourse/routes/chat.js +++ b/plugins/chat/assets/javascripts/discourse/routes/chat.js @@ -22,6 +22,7 @@ export default class ChatRoute extends DiscourseRoute { const INTERCEPTABLE_ROUTES = [ "chat.channel", + "chat.channel.thread", "chat.channel.index", "chat.channel.near-message", "chat.channel-legacy", diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-api.js b/plugins/chat/assets/javascripts/discourse/services/chat-api.js index 60e712968f5..f459a5faab5 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-api.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-api.js @@ -296,6 +296,96 @@ 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. + * @returns {Promise} + */ + saveDraft(channelId, data) { + // TODO (martin) Change this to postRequest after moving DraftsController into Api::DraftsController + return ajax("/chat/drafts", { + type: "POST", + data: { + chat_channel_id: channelId, + data, + }, + ignoreUnsent: false, + }) + .then(() => { + this.chat.markNetworkAsReliable(); + }) + .catch((error) => { + // we ignore a draft which can't be saved because it's too big + // and only deal with network error for now + if (!error.jqXHR?.responseJSON?.errors?.length) { + this.chat.markNetworkAsUnreliable(); + } + }); + } + + /** + * Adds or removes an emoji reaction for a message inside a channel. + * @param {number} channelId - The ID of the channel. + * @param {number} messageId - The ID of the message to react on. + * @param {string} emoji - The text version of the emoji without colons, e.g. tada + * @param {string} reaction - Either "add" or "remove" + * @returns {Promise} + */ + publishReaction(channelId, messageId, emoji, reactAction) { + // TODO (martin) Not ideal, this should have a chat API controller endpoint. + return ajax(`/chat/${channelId}/react/${messageId}`, { + type: "PUT", + data: { + react_action: reactAction, + emoji, + }, + }); + } + + /** + * Restores a single deleted chat message in a channel. + * + * @param {number} channelId - The ID of the channel for the message being restored. + * @param {number} messageId - The ID of the message being restored. + */ + restoreMessage(channelId, messageId) { + // TODO (martin) Not ideal, this should have a chat API controller endpoint. + return ajax(`/chat/${channelId}/restore/${messageId}`, { + type: "PUT", + }); + } + + /** + * Rebakes the cooked HTML of a single message in a channel. + * + * @param {number} channelId - The ID of the channel for the message being restored. + * @param {number} messageId - The ID of the message being restored. + */ + rebakeMessage(channelId, messageId) { + // TODO (martin) Not ideal, this should have a chat API controller endpoint. + return ajax(`/chat/${channelId}/${messageId}/rebake`, { + type: "PUT", + }); + } + + /** + * Saves an edit to a message's contents in a channel. + * + * @param {number} channelId - The ID of the channel for the message being edited. + * @param {number} messageId - The ID of the message being edited. + * @param {object} data - Params of the edit. + * @param {string} data.new_message - The edited content of the message. + * @param {Array<number>} data.upload_ids - The uploads attached to the message after editing. + */ + editMessage(channelId, messageId, data) { + // TODO (martin) Not ideal, this should have a chat API controller endpoint. + return ajax(`/chat/${channelId}/edit/${messageId}`, { + type: "PUT", + data, + }); + } + /** * Marks messages for all of a user's chat channel memberships as read. * diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-channel-composer.js b/plugins/chat/assets/javascripts/discourse/services/chat-channel-composer.js new file mode 100644 index 00000000000..f809c07caff --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-channel-composer.js @@ -0,0 +1,131 @@ +import { debounce } from "discourse-common/utils/decorators"; +import { tracked } from "@glimmer/tracking"; +import Service, { inject as service } from "@ember/service"; + +export default class ChatChannelComposer extends Service { + @service chat; + @service chatApi; + @service chatComposerPresenceManager; + + @tracked editingMessage = null; + @tracked replyToMsg = null; + @tracked linkedComponent = null; + + 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; + } + + 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(); + } + + onComposerValueChange({ + value, + uploads, + replyToMsg, + inProgressUploadsCount, + }) { + if (!this.#model) { + return; + } + + if (!this.editingMessage && !this.#model.isDraft) { + if (typeof value !== "undefined") { + 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.uploads = uploads; + } + + if (typeof replyToMsg !== "undefined") { + this.#model.draft.replyToMsg = replyToMsg; + } + } + + if (!this.#model.isDraft) { + this.#reportReplyingPresence(value); + } + + this._persistDraft(); + } + + cancelEditing() { + this.editingMessage = null; + } + + registerFocusHandler(handlerFn) { + this.focusHandler = handlerFn; + } + + focusComposer() { + this.focusHandler(); + } + + #reportReplyingPresence(composerValue) { + if (this.#componentDeleted) { + return; + } + + if (this.#model.isDraft) { + return; + } + + const replying = !this.editingMessage && !!composerValue; + this.chatComposerPresenceManager.notifyState(this.#model.id, replying); + } + + @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; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-channel-emoji-picker-manager.js b/plugins/chat/assets/javascripts/discourse/services/chat-channel-emoji-picker-manager.js new file mode 100644 index 00000000000..fb3975ea87b --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-channel-emoji-picker-manager.js @@ -0,0 +1,3 @@ +import ChatEmojiPickerManager from "./chat-emoji-picker-manager"; + +export default class ChatChannelEmojiPickerManager extends ChatEmojiPickerManager {} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-channel-pane.js b/plugins/chat/assets/javascripts/discourse/services/chat-channel-pane.js new file mode 100644 index 00000000000..db0392e3f1c --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-channel-pane.js @@ -0,0 +1,92 @@ +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 { + @service appEvents; + @service chat; + @service chatChannelComposer; + @service chatApi; + @service chatComposerPresenceManager; + + @tracked reacting = false; + @tracked selectingMessages = false; + @tracked hoveredMessageId = false; + @tracked lastSelectedMessage = null; + @tracked sendingLoading = false; + + get selectedMessageIds() { + return this.chat.activeChannel.selectedMessages.mapBy("id"); + } + + get composerService() { + return this.chatChannelComposer; + } + + @action + cancelSelecting(selectedMessages) { + this.selectingMessages = false; + + selectedMessages.forEach((message) => { + message.selected = false; + }); + } + + onSelectMessage(message) { + this.lastSelectedMessage = message; + 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( + (message) => message.user.id === this.currentUser.id + ); + + if (!lastUserMessage) { + return; + } + + if (lastUserMessage.staged || lastUserMessage.error) { + return; + } + + this.composerService.editingMessage = lastUserMessage; + this.composerService.focusComposer(); + } +} 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 new file mode 100644 index 00000000000..904644a3971 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-channel-thread-composer.js @@ -0,0 +1,15 @@ +import ChatChannelComposer from "./chat-channel-composer"; + +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; + } +} 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 new file mode 100644 index 00000000000..839f690fb85 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-channel-thread-pane.js @@ -0,0 +1,14 @@ +import ChatChannelPane from "./chat-channel-pane"; +import { inject as service } from "@ember/service"; + +export default class ChatChannelThreadPane extends ChatChannelPane { + @service chatChannelThreadComposer; + + get selectedMessageIds() { + return this.chat.activeChannel.activeThread.selectedMessages.mapBy("id"); + } + + get composerService() { + return this.chatChannelThreadComposer; + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-drawer-router.js b/plugins/chat/assets/javascripts/discourse/services/chat-drawer-router.js index 0ddf16a033c..fd2a8573550 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-drawer-router.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-drawer-router.js @@ -2,11 +2,21 @@ import Service, { inject as service } from "@ember/service"; import { tracked } from "@glimmer/tracking"; import ChatDrawerDraftChannel from "discourse/plugins/chat/discourse/components/chat-drawer/draft-channel"; import ChatDrawerChannel from "discourse/plugins/chat/discourse/components/chat-drawer/channel"; +import ChatDrawerThread from "discourse/plugins/chat/discourse/components/chat-drawer/thread"; import ChatDrawerIndex from "discourse/plugins/chat/discourse/components/chat-drawer/index"; const COMPONENTS_MAP = { "chat.draft-channel": { name: ChatDrawerDraftChannel }, "chat.channel": { name: ChatDrawerChannel }, + "chat.channel.thread": { + name: ChatDrawerThread, + extractParams: (route) => { + return { + channelId: route.parent.params.channelId, + threadId: route.params.threadId, + }; + }, + }, chat: { name: ChatDrawerIndex }, "chat.channel.near-message": { name: ChatDrawerChannel, diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-emoji-picker-manager.js b/plugins/chat/assets/javascripts/discourse/services/chat-emoji-picker-manager.js index 95dda5430c9..280fe7422ff 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-emoji-picker-manager.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-emoji-picker-manager.js @@ -1,49 +1,42 @@ -import { headerOffset } from "discourse/lib/offset-calculator"; -import { createPopper } from "@popperjs/core"; -import Service from "@ember/service"; import { tracked } from "@glimmer/tracking"; import { ajax } from "discourse/lib/ajax"; import { popupAjaxError } from "discourse/lib/ajax-error"; import { bind } from "discourse-common/utils/decorators"; -import { later, schedule } from "@ember/runloop"; +import { later } from "@ember/runloop"; import { makeArray } from "discourse-common/lib/helpers"; import { Promise } from "rsvp"; -import { computed } from "@ember/object"; import { isTesting } from "discourse-common/config/environment"; +import { action } from "@ember/object"; +import Service, { inject as service } from "@ember/service"; const TRANSITION_TIME = isTesting() ? 0 : 125; // CSS transition time const DEFAULT_VISIBLE_SECTIONS = ["favorites", "smileys_&_emotion"]; const DEFAULT_LAST_SECTION = "favorites"; export default class ChatEmojiPickerManager extends Service { - @tracked opened = false; + @service appEvents; + @tracked closing = false; @tracked loading = false; - @tracked context = null; + @tracked picker = null; @tracked emojis = null; @tracked visibleSections = DEFAULT_VISIBLE_SECTIONS; @tracked lastVisibleSection = DEFAULT_LAST_SECTION; - @tracked initialFilter = null; @tracked element = null; - @tracked callback; - @computed("emojis.[]", "loading") get sections() { return !this.loading && this.emojis ? Object.keys(this.emojis) : []; } @bind closeExisting() { - this.callback = null; - this.opened = false; - this.initialFilter = null; this.visibleSections = DEFAULT_VISIBLE_SECTIONS; this.lastVisibleSection = DEFAULT_LAST_SECTION; + this.picker = null; } @bind close() { - this.callback = null; this.closing = true; later(() => { @@ -53,9 +46,8 @@ export default class ChatEmojiPickerManager extends Service { this.visibleSections = DEFAULT_VISIBLE_SECTIONS; this.lastVisibleSection = DEFAULT_LAST_SECTION; - this.initialFilter = null; this.closing = false; - this.opened = false; + this.picker = null; }, TRANSITION_TIME); } @@ -65,80 +57,23 @@ export default class ChatEmojiPickerManager extends Service { .uniq(); } - didSelectEmoji(emoji) { - this?.callback(emoji); - this.callback = null; - this.close(); - } + open(picker) { + this.loadEmojis(); - startFromMessageReactionList(message, callback, options = {}) { - const trigger = document.querySelector( - `.chat-message-container[data-id="${message.id}"] .chat-message-react-btn` - ); - this.startFromMessage(callback, trigger, options); - } - - startFromMessageActions(message, callback, options = {}) { - const trigger = document.querySelector( - `.chat-message-actions-container[data-id="${message.id}"] .chat-message-actions` - ); - this.startFromMessage(callback, trigger, options); - } - - startFromMessage( - callback, - trigger, - options = { filter: null, desktop: true } - ) { - this.initialFilter = options.filter; - this.context = "chat-message"; - this.element = document.querySelector(".chat-message-emoji-picker-anchor"); - this.open(callback); - this._popper?.destroy(); - - if (options.desktop) { - schedule("afterRender", () => { - this._popper = createPopper(trigger, this.element, { - placement: "top", - modifiers: [ - { - name: "eventListeners", - options: { - scroll: false, - resize: false, - }, - }, - { - name: "flip", - options: { - padding: { top: headerOffset() }, - }, - }, - ], - }); - }); + if (this.picker) { + if (this.picker.trigger === picker.trigger) { + this.closeExisting(); + } else { + this.closeExisting(); + this.picker = picker; + } + } else { + this.picker = picker; } } - startFromComposer(callback, options = { filter: null }) { - this.initialFilter = options.filter; - this.context = "chat-composer"; - this.element = document.querySelector(".chat-composer-emoji-picker-anchor"); - this.open(callback); - } - - open(callback) { - if (this.opened) { - this.closeExisting(); - } - - this._loadEmojisData(); - - this.callback = callback; - this.opened = true; - } - - _loadEmojisData() { + @action + loadEmojis() { if (this.emojis) { return Promise.resolve(); } diff --git a/plugins/chat/assets/javascripts/discourse/services/chat.js b/plugins/chat/assets/javascripts/discourse/services/chat.js index 71176926c35..d7491964c93 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat.js @@ -9,6 +9,7 @@ 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 { MESSAGE_CONTEXT_THREAD } from "discourse/plugins/chat/discourse/components/chat-message"; const CHAT_ONLINE_OPTIONS = { userUnseenTime: 300000, // 5 minutes seconds with no interaction @@ -26,6 +27,9 @@ export default class Chat extends Service { @service site; @service chatChannelsManager; + @service chatChannelPane; + @service chatChannelThreadPane; + @tracked activeChannel = null; cook = null; @@ -35,6 +39,8 @@ export default class Chat extends Service { @and("currentUser.has_chat_enabled", "siteSettings.chat_enabled") userCanChat; + @tracked _activeMessage = null; + @computed("currentUser.staff", "currentUser.groups.[]") get userCanDirectMessage() { if (!this.currentUser) { @@ -51,6 +57,32 @@ export default class Chat extends Service { ); } + @computed("activeChannel.userSilenced") + get userCanInteractWithChat() { + return !this.activeChannel?.userSilenced; + } + + get activeMessage() { + return this._activeMessage; + } + + set activeMessage(hash) { + this.chatChannelPane.hoveredMessageId = null; + this.chatChannelThreadPane.hoveredMessageId = null; + + if (hash) { + this._activeMessage = hash; + + if (hash.context === MESSAGE_CONTEXT_THREAD) { + this.chatChannelThreadPane.hoveredMessageId = hash.model.id; + } else { + this.chatChannelPane.hoveredMessageId = hash.model.id; + } + } else { + this._activeMessage = null; + } + } + init() { super.init(...arguments); diff --git a/plugins/chat/assets/javascripts/discourse/templates/connectors/below-footer/chat-emoji-picker-connector.hbs b/plugins/chat/assets/javascripts/discourse/templates/connectors/below-footer/chat-emoji-picker-connector.hbs deleted file mode 100644 index f49c1cb84c9..00000000000 --- a/plugins/chat/assets/javascripts/discourse/templates/connectors/below-footer/chat-emoji-picker-connector.hbs +++ /dev/null @@ -1,7 +0,0 @@ -{{#if - (and this.chatEmojiPickerManager.opened this.chatEmojiPickerManager.element) -}} - {{#in-element this.chatEmojiPickerManager.element}} - <ChatEmojiPicker /> - {{/in-element}} -{{/if}} \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/templates/connectors/below-footer/chat-emoji-picker-connector.js b/plugins/chat/assets/javascripts/discourse/templates/connectors/below-footer/chat-emoji-picker-connector.js deleted file mode 100644 index 8fa5cf2f402..00000000000 --- a/plugins/chat/assets/javascripts/discourse/templates/connectors/below-footer/chat-emoji-picker-connector.js +++ /dev/null @@ -1,12 +0,0 @@ -import { getOwner } from "discourse-common/lib/get-owner"; - -export default { - setupComponent(args, component) { - const container = getOwner(this); - const chatEmojiPickerManager = container.lookup( - "service:chat-emoji-picker-manager" - ); - - component.set("chatEmojiPickerManager", chatEmojiPickerManager); - }, -}; diff --git a/plugins/chat/assets/stylesheets/common/base-common.scss b/plugins/chat/assets/stylesheets/common/base-common.scss index 136b1b81897..1817ea21a10 100644 --- a/plugins/chat/assets/stylesheets/common/base-common.scss +++ b/plugins/chat/assets/stylesheets/common/base-common.scss @@ -5,7 +5,7 @@ $float-height: 530px; --full-page-border-radius: 12px; --full-page-sidebar-width: 275px; --channel-list-avatar-size: 30px; - --chat-header-offset: 65px; + --chat-header-offset: 50px; } .chat-message-move-to-channel-modal-modal { @@ -289,9 +289,6 @@ $float-height: 530px; z-index: 1; margin: 0 1px 0 0; will-change: transform; - overflow-x: hidden; - -webkit-overflow-scrolling: touch; - @include chat-scrollbar(); .join-channel-btn.in-float { diff --git a/plugins/chat/assets/stylesheets/common/chat-composer-dropdown.scss b/plugins/chat/assets/stylesheets/common/chat-composer-dropdown.scss index 870202a1e2f..93dd7af86cb 100644 --- a/plugins/chat/assets/stylesheets/common/chat-composer-dropdown.scss +++ b/plugins/chat/assets/stylesheets/common/chat-composer-dropdown.scss @@ -1,4 +1,4 @@ -.chat-composer-dropdown { +[data-theme="chat-composer-drodown"] { margin-left: 0.2rem; .tippy-content { @@ -7,7 +7,7 @@ } .chat-composer-dropdown__trigger-btn { - padding: 5px; + padding: 5px !important; // overwrite ios rule border-radius: 100%; background: var(--primary-med-or-secondary-high); border: 1px solid transparent; @@ -30,20 +30,11 @@ } .chat-composer-dropdown__list { - padding: 0; margin: 0; list-style: none; padding: 0.5rem; } -.chat-composer-dropdown__item { - padding-bottom: 0.25rem; - - &:last-child { - padding-bottom: 0; - } -} - .chat-composer-dropdown__action-btn { background: none; width: 100%; diff --git a/plugins/chat/assets/stylesheets/common/chat-composer.scss b/plugins/chat/assets/stylesheets/common/chat-composer.scss index 7cd873ec840..2077128b215 100644 --- a/plugins/chat/assets/stylesheets/common/chat-composer.scss +++ b/plugins/chat/assets/stylesheets/common/chat-composer.scss @@ -103,6 +103,8 @@ text-overflow: ellipsis; white-space: nowrap; } + + @include chat-scrollbar(); } &__unreliable-network { diff --git a/plugins/chat/assets/stylesheets/common/chat-emoji-picker.scss b/plugins/chat/assets/stylesheets/common/chat-emoji-picker.scss index 45866b708ed..878dc19a348 100644 --- a/plugins/chat/assets/stylesheets/common/chat-emoji-picker.scss +++ b/plugins/chat/assets/stylesheets/common/chat-emoji-picker.scss @@ -54,9 +54,11 @@ height: 100%; overflow-y: scroll; text-transform: capitalize; + @include chat-scrollbar(); + margin: 1px; } - &__no-reults { + &__no-results { padding: 1em; } @@ -197,12 +199,13 @@ } } -.chat-message-emoji-picker-anchor { - z-index: z("header") + 1; +.chat-channel-message-emoji-picker-connector { + position: relative; .chat-emoji-picker { border: 1px solid var(--primary-low); width: 320px; + z-index: z("header") + 1; .emoji { width: 22px; @@ -210,31 +213,3 @@ } } } - -.mobile-view { - .chat-message-emoji-picker-anchor.-opened { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - box-shadow: shadowcreatePopper("card"); - - .chat-emoji-picker { - height: 50vh; - width: 100%; - } - } -} - -.chat-composer-container.with-emoji-picker { - background: var(--primary-very-low); - - .chat-emoji-picker { - border-bottom: 1px solid var(--primary-low); - - &.closing { - height: 0; - } - } -} diff --git a/plugins/chat/assets/stylesheets/common/chat-message-actions.scss b/plugins/chat/assets/stylesheets/common/chat-message-actions.scss index 6ed3e37b13f..479b01d9ace 100644 --- a/plugins/chat/assets/stylesheets/common/chat-message-actions.scss +++ b/plugins/chat/assets/stylesheets/common/chat-message-actions.scss @@ -11,7 +11,7 @@ .chat-message-actions-container { @include unselectable; - position: relative; + z-index: z("dropdown") - 1; } .chat-message-actions { @@ -47,6 +47,10 @@ width: 2.5em; transition: background 0.2s, border-color 0.2s; + > * { + pointer-events: none; + } + &:focus { .d-icon { color: var(--primary); diff --git a/plugins/chat/assets/stylesheets/common/chat-message-left-gutter.scss b/plugins/chat/assets/stylesheets/common/chat-message-left-gutter.scss index bad68f19ce7..00be06ab2e6 100644 --- a/plugins/chat/assets/stylesheets/common/chat-message-left-gutter.scss +++ b/plugins/chat/assets/stylesheets/common/chat-message-left-gutter.scss @@ -6,7 +6,7 @@ width: var(--message-left-width); } -.chat-message-container.is-hovered .chat-message-left-gutter { +.chat-message-container:hover .chat-message-left-gutter { .chat-time { color: var(--secondary-mediumy); } diff --git a/plugins/chat/assets/stylesheets/common/chat-message.scss b/plugins/chat/assets/stylesheets/common/chat-message.scss index 9a7d7d1a39a..45cd25ac9dd 100644 --- a/plugins/chat/assets/stylesheets/common/chat-message.scss +++ b/plugins/chat/assets/stylesheets/common/chat-message.scss @@ -131,6 +131,10 @@ background: none; border: none; + > * { + pointer-events: none; + } + .d-icon { color: var(--primary-high); } @@ -181,26 +185,44 @@ } .chat-messages-container { - .not-mobile-device & .chat-message:hover, - .chat-message.chat-message-selected { - background: var(--primary-very-low); - } - - .chat-message.chat-message-bookmarked { - background: var(--highlight-bg); - - &:hover { - background: var(--highlight-medium); + .chat-message { + &.chat-message-bookmarked { + background: var(--highlight-bg); } - } - .not-mobile-device & .chat-message-reaction-list .chat-message-react-btn { - display: none; - } - - .not-mobile-device & .chat-message:hover { .chat-message-reaction-list .chat-message-react-btn { - display: inline-block; + display: none; + } + + .touch & { + &:active { + background: var(--primary-very-low); + } + + &.chat-message-bookmarked { + &:active { + background: var(--highlight-medium); + } + } + } + + .no-touch & { + &:hover, + &:active { + background: var(--primary-very-low); + } + + &:hover { + .chat-message-react-btn { + display: inline-block; + } + } + + &.chat-message-bookmarked { + &:hover { + background: var(--highlight-medium); + } + } } } } @@ -222,15 +244,6 @@ font-style: italic; } -.chat-message-container.is-hovered, -.chat-message.chat-message-selected { - background: var(--primary-very-low); -} - -.chat-message.chat-message-bookmarked { - background: var(--highlight-bg); -} - .has-full-page-chat .chat-message .onebox:not(img), .chat-drawer-container .chat-message .onebox { margin: 0.5em 0; diff --git a/plugins/chat/assets/stylesheets/common/chat-side-panel.scss b/plugins/chat/assets/stylesheets/common/chat-side-panel.scss index 0839fd90178..09e2e22c3e0 100644 --- a/plugins/chat/assets/stylesheets/common/chat-side-panel.scss +++ b/plugins/chat/assets/stylesheets/common/chat-side-panel.scss @@ -14,7 +14,7 @@ grid-area: threads; min-height: 100%; box-sizing: border-box; - border-left: 1px solid var(--primary-medium); + border-left: 1px solid var(--primary-low); &__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 91b7bab73bb..e998b7de1af 100644 --- a/plugins/chat/assets/stylesheets/common/chat-thread.scss +++ b/plugins/chat/assets/stylesheets/common/chat-thread.scss @@ -1,11 +1,31 @@ .chat-thread { display: flex; flex-direction: column; - padding-block: 1rem; height: 100%; - box-sizing: border-box; &__header { + height: var(--chat-header-offset); + min-height: var(--chat-header-offset); + border-bottom: 1px solid var(--primary-low); + border-top: 1px solid var(--primary-low); + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: space-between; + padding-inline: 1.5rem; + } + + &__body { + overflow-y: scroll; + @include chat-scrollbar(); + margin: 2px; + padding-right: 2px; + box-sizing: border-box; + flex-grow: 1; + overscroll-behavior: contain; + display: flex; + flex-direction: column-reverse; + will-change: transform; } &__close { @@ -15,41 +35,4 @@ color: var(--primary-medium); } } - - &__info { - padding-inline: 1.5rem; - padding-bottom: 1rem; - border-bottom: 1px solid var(--primary-low); - } - - &__om { - margin-top: 0; - } - - &__omu { - display: flex; - flex-direction: row; - align-items: center; - - .chat-message-avatar { - width: var(--message-left-width); - } - } - - &__started-by { - margin-right: 0.5rem; - } - - &__title { - display: flex; - align-items: center; - justify-content: space-between; - } - - &__messages { - flex-grow: 1; - overflow: hidden; - overflow-y: scroll; - padding-inline: 1.5rem; - } } diff --git a/plugins/chat/assets/stylesheets/mobile/chat-emoji-picker.scss b/plugins/chat/assets/stylesheets/mobile/chat-emoji-picker.scss new file mode 100644 index 00000000000..e55856ee67e --- /dev/null +++ b/plugins/chat/assets/stylesheets/mobile/chat-emoji-picker.scss @@ -0,0 +1,26 @@ +.chat-channel-message-emoji-picker-connector { + position: relative; + + .chat-emoji-picker { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 50vh; + width: 100%; + box-shadow: shadow("card"); + z-index: z("header") + 2; + max-width: 100vw; + + &__backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: var(--primary); + opacity: 0.8; + z-index: z("header") + 1; + } + } +} diff --git a/plugins/chat/assets/stylesheets/mobile/index.scss b/plugins/chat/assets/stylesheets/mobile/index.scss index 752b6567d00..1fa0be669d6 100644 --- a/plugins/chat/assets/stylesheets/mobile/index.scss +++ b/plugins/chat/assets/stylesheets/mobile/index.scss @@ -5,3 +5,4 @@ @import "chat-message-actions"; @import "chat-message"; @import "chat-selection-manager"; +@import "chat-emoji-picker"; diff --git a/plugins/chat/config/locales/client.en.yml b/plugins/chat/config/locales/client.en.yml index dc8bae74cb2..503dc4de407 100644 --- a/plugins/chat/config/locales/client.en.yml +++ b/plugins/chat/config/locales/client.en.yml @@ -532,8 +532,9 @@ en: search_placeholder: "Search by emoji name and alias..." no_results: "No results" + thread: + label: Thread threads: - op_said: "OP said:" started_by: "Started by" open: "Open Thread" 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 1d5ce7a615f..7384b2d7d17 100644 --- a/plugins/chat/spec/system/page_objects/chat/chat_channel.rb +++ b/plugins/chat/spec/system/page_objects/chat/chat_channel.rb @@ -75,13 +75,13 @@ module PageObjects def select_message(message) hover_message(message) click_more_button - find("[data-value='selectMessage']").click + find("[data-value='select']").click end def delete_message(message) hover_message(message) click_more_button - find("[data-value='deleteMessage']").click + find("[data-value='delete']").click end def open_edit_message(message) 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 e49a1070162..f5bb23bcfd5 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 @@ -30,6 +30,10 @@ module PageObjects def maximize find("#{VISIBLE_DRAWER} .chat-drawer-header__full-screen-btn").click end + + def has_open_thread?(thread) + has_css?("#{VISIBLE_DRAWER} .chat-thread[data-id='#{thread.id}']") + end end end end diff --git a/plugins/chat/spec/system/react_to_message_spec.rb b/plugins/chat/spec/system/react_to_message_spec.rb index 30bf15c9a56..3589d747d1a 100644 --- a/plugins/chat/spec/system/react_to_message_spec.rb +++ b/plugins/chat/spec/system/react_to_message_spec.rb @@ -2,6 +2,7 @@ RSpec.describe "React to message", type: :system, js: true do fab!(:current_user) { Fabricate(:user) } + fab!(:other_user) { Fabricate(:user) } fab!(:category_channel_1) { Fabricate(:category_channel) } fab!(:message_1) { Fabricate(:chat_message, chat_channel: category_channel_1) } @@ -11,11 +12,12 @@ RSpec.describe "React to message", type: :system, js: true do before do chat_system_bootstrap category_channel_1.add(current_user) + category_channel_1.add(other_user) end context "when other user has reacted" do fab!(:reaction_1) do - Chat::MessageReactor.new(Fabricate(:user), category_channel_1).react!( + Chat::MessageReactor.new(other_user, category_channel_1).react!( message_id: message_1.id, react_action: :add, emoji: "female_detective", @@ -48,7 +50,7 @@ RSpec.describe "React to message", type: :system, js: true do context "when current user reacts" do fab!(:reaction_1) do - Chat::MessageReactor.new(Fabricate(:user), category_channel_1).react!( + Chat::MessageReactor.new(other_user, category_channel_1).react!( message_id: message_1.id, react_action: :add, emoji: "female_detective", @@ -62,14 +64,14 @@ RSpec.describe "React to message", type: :system, js: true do chat.visit_channel(category_channel_1) channel.hover_message(message_1) find(".chat-message-react-btn").click - find(".chat-emoji-picker [data-emoji=\"nerd_face\"]").click + find(".chat-emoji-picker [data-emoji=\"grimacing\"]").click - expect(channel).to have_reaction(message_1, reaction_1.emoji) + expect(channel).to have_reaction(message_1, "grimacing") end context "when current user has multiple sessions" do it "adds reaction on each session" do - reaction = OpenStruct.new(emoji: "nerd_face") + reaction = OpenStruct.new(emoji: "grimacing") using_session(:tab_1) do sign_in(current_user) diff --git a/plugins/chat/spec/system/single_thread_spec.rb b/plugins/chat/spec/system/single_thread_spec.rb index 52f1b2e5d39..4022d5276b3 100644 --- a/plugins/chat/spec/system/single_thread_spec.rb +++ b/plugins/chat/spec/system/single_thread_spec.rb @@ -7,6 +7,7 @@ describe "Single thread in side panel", type: :system, js: true do let(:channel_page) { PageObjects::Pages::ChatChannel.new } let(:side_panel) { PageObjects::Pages::ChatSidePanel.new } let(:open_thread) { PageObjects::Pages::ChatThread.new } + let(:chat_drawer_page) { PageObjects::Pages::ChatDrawer.new } before do chat_system_bootstrap(current_user, [channel]) @@ -49,19 +50,27 @@ describe "Single thread in side panel", type: :system, js: true do before { SiteSetting.enable_experimental_chat_threaded_discussions = true } + it "opens the single thread in the drawer from the message actions menu" do + visit("/latest") + chat_page.open_from_header + chat_drawer_page.open_channel(channel) + channel_page.open_message_thread(thread.chat_messages.order(:created_at).last) + expect(chat_drawer_page).to have_open_thread(thread) + end + it "opens the side panel for a single thread from the message actions menu" do chat_page.visit_channel(channel) channel_page.open_message_thread(thread.original_message) expect(side_panel).to have_open_thread(thread) end - it "shows the excerpt of the thread original message" do + xit "shows the excerpt of the thread original message" do chat_page.visit_channel(channel) channel_page.open_message_thread(thread.original_message) expect(open_thread).to have_header_content(thread.excerpt) end - it "shows the avatar and username of the original message user" do + xit "shows the avatar and username of the original message user" do chat_page.visit_channel(channel) channel_page.open_message_thread(thread.original_message) expect(open_thread.omu).to have_css(".chat-user-avatar img.avatar") diff --git a/plugins/chat/spec/system/transcript_spec.rb b/plugins/chat/spec/system/transcript_spec.rb index 7a34e992317..2f53d751f1a 100644 --- a/plugins/chat/spec/system/transcript_spec.rb +++ b/plugins/chat/spec/system/transcript_spec.rb @@ -21,7 +21,7 @@ RSpec.describe "Quoting chat message transcripts", type: :system, js: true do chat_channel_page.message_by_id(message.id).hover expect(page).to have_css(".chat-message-actions .more-buttons") find(".chat-message-actions .more-buttons").click - find(".select-kit-row[data-value=\"selectMessage\"]").click + find(".select-kit-row[data-value=\"select\"]").click end end @@ -209,7 +209,7 @@ RSpec.describe "Quoting chat message transcripts", type: :system, js: true do mobile: true do chat_page.visit_channel(chat_channel_1) - chat_channel_page.click_message_action_mobile(message_1, "selectMessage") + chat_channel_page.click_message_action_mobile(message_1, "select") click_selection_button("quote") expect(topic_page).to have_expanded_composer 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 411aea8ca5a..718b40e4a9c 100644 --- a/plugins/chat/test/javascripts/components/chat-composer-placeholder-test.js +++ b/plugins/chat/test/javascripts/components/chat-composer-placeholder-test.js @@ -4,6 +4,7 @@ import hbs from "htmlbars-inline-precompile"; import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; import { module, test } from "qunit"; import { render } from "@ember/test-helpers"; +import pretender from "discourse/tests/helpers/create-pretender"; module( "Discourse Chat | Component | chat-composer placeholder", @@ -11,6 +12,8 @@ module( setupRenderingTest(hooks); test("direct message to self shows Jot something down", async function (assert) { + pretender.get("/chat/emojis.json", () => [200, [], {}]); + this.currentUser.set("id", 1); this.set( "chatChannel", @@ -31,6 +34,8 @@ module( }); test("direct message to multiple folks shows their names", async function (assert) { + pretender.get("/chat/emojis.json", () => [200, [], {}]); + this.set( "chatChannel", ChatChannel.create({ @@ -54,6 +59,8 @@ 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({ diff --git a/plugins/chat/test/javascripts/components/chat-emoji-picker-test.js b/plugins/chat/test/javascripts/components/chat-emoji-picker-test.js index 6856da9c522..4e020d1f1b5 100644 --- a/plugins/chat/test/javascripts/components/chat-emoji-picker-test.js +++ b/plugins/chat/test/javascripts/components/chat-emoji-picker-test.js @@ -68,7 +68,7 @@ module("Discourse Chat | Component | chat-emoji-picker", function (hooks) { this.chatEmojiPickerManager = this.container.lookup( "service:chat-emoji-picker-manager" ); - this.chatEmojiPickerManager.startFromComposer(() => {}); + this.chatEmojiPickerManager.open(() => {}); this.chatEmojiPickerManager.addVisibleSections([ "smileys_&_emotion", "people_&_body", @@ -164,10 +164,13 @@ module("Discourse Chat | Component | chat-emoji-picker", function (hooks) { test("When selecting an emoji", async function (assert) { let selection; - this.chatEmojiPickerManager.didSelectEmoji = (emoji) => { + this.didSelectEmoji = (emoji) => { selection = emoji; }; - await render(hbs`<ChatEmojiPicker />`); + + await render( + hbs`<ChatEmojiPicker @didSelectEmoji={{this.didSelectEmoji}} />` + ); await click('img.emoji[data-emoji="grinning"]'); assert.strictEqual(selection, "grinning"); @@ -241,10 +244,13 @@ module("Discourse Chat | Component | chat-emoji-picker", function (hooks) { test("When selecting a toned an emoji", async function (assert) { let selection; - this.chatEmojiPickerManager.didSelectEmoji = (emoji) => { + this.didSelectEmoji = (emoji) => { selection = emoji; }; - await render(hbs`<ChatEmojiPicker />`); + + await render( + hbs`<ChatEmojiPicker @didSelectEmoji={{this.didSelectEmoji}} />` + ); this.emojiReactionStore.diversity = 1; await click('img.emoji[data-emoji="man_rowing_boat"]'); diff --git a/plugins/chat/test/javascripts/components/chat-message-reaction-test.js b/plugins/chat/test/javascripts/components/chat-message-reaction-test.js index 4a640b949dc..4c3b6e8fab3 100644 --- a/plugins/chat/test/javascripts/components/chat-message-reaction-test.js +++ b/plugins/chat/test/javascripts/components/chat-message-reaction-test.js @@ -55,7 +55,7 @@ module("Discourse Chat | Component | chat-message-reaction", function (hooks) { }); await render(hbs` - <ChatMessageReaction class="show" @reaction={{hash emoji="heart" count=this.count}} @react={{this.react}} /> + <ChatMessageReaction class="show" @reaction={{hash emoji="heart" count=this.count}} @onReaction={{this.react}} /> `); assert.false(exists(".chat-message-reaction .count")); diff --git a/plugins/chat/test/javascripts/components/chat-message-test.js b/plugins/chat/test/javascripts/components/chat-message-test.js index 1e85fbc289f..afb43b0a575 100644 --- a/plugins/chat/test/javascripts/components/chat-message-test.js +++ b/plugins/chat/test/javascripts/components/chat-message-test.js @@ -21,7 +21,6 @@ module("Discourse Chat | Component | chat-message", function (hooks) { unread_count: 0, muted: false, }, - canInteractWithChat: true, canDeleteSelf: true, canDeleteOthers: true, canFlag: true, @@ -46,14 +45,7 @@ module("Discourse Chat | Component | chat-message", function (hooks) { ) ), chatChannel, - setReplyTo: () => {}, - replyMessageClicked: () => {}, - editButtonClicked: () => {}, afterExpand: () => {}, - selectingMessages: false, - onStartSelectingMessages: () => {}, - onSelectMessage: () => {}, - bulkSelectMessages: () => {}, onHoverMessage: () => {}, messageDidEnterViewport: () => {}, messageDidLeaveViewport: () => {}, @@ -63,16 +55,7 @@ module("Discourse Chat | Component | chat-message", function (hooks) { const template = hbs` <ChatMessage @message={{this.message}} - @canInteractWithChat={{this.canInteractWithChat}} @channel={{this.chatChannel}} - @setReplyTo={{this.setReplyTo}} - @replyMessageClicked={{this.replyMessageClicked}} - @editButtonClicked={{this.editButtonClicked}} - @selectingMessages={{this.selectingMessages}} - @onStartSelectingMessages={{this.onStartSelectingMessages}} - @onSelectMessage={{this.onSelectMessage}} - @bulkSelectMessages={{this.bulkSelectMessages}} - @onHoverMessage={{this.onHoverMessage}} @messageDidEnterViewport={{this.messageDidEnterViewport}} @messageDidLeaveViewport={{this.messageDidLeaveViewport}} /> diff --git a/plugins/chat/test/javascripts/unit/services/chat-emoji-picker-manager-test.js b/plugins/chat/test/javascripts/unit/services/chat-emoji-picker-manager-test.js index cb86fedae3b..1276368faa9 100644 --- a/plugins/chat/test/javascripts/unit/services/chat-emoji-picker-manager-test.js +++ b/plugins/chat/test/javascripts/unit/services/chat-emoji-picker-manager-test.js @@ -22,46 +22,6 @@ module( this.manager.close(); }); - test("startFromMessageReactionList", async function (assert) { - const callback = () => {}; - this.manager.startFromMessageReactionList({ id: 1 }, callback); - - assert.ok(this.manager.loading); - assert.ok(this.manager.opened); - assert.strictEqual(this.manager.context, "chat-message"); - assert.strictEqual(this.manager.callback, callback); - assert.deepEqual(this.manager.visibleSections, [ - "favorites", - "smileys_&_emotion", - ]); - assert.strictEqual(this.manager.lastVisibleSection, "favorites"); - - await settled(); - - assert.deepEqual(this.manager.emojis, emojisReponse()); - assert.strictEqual(this.manager.loading, false); - }); - - test("startFromMessageActions", async function (assert) { - const callback = () => {}; - this.manager.startFromMessageReactionList({ id: 1 }, callback); - - assert.ok(this.manager.loading); - assert.ok(this.manager.opened); - assert.strictEqual(this.manager.context, "chat-message"); - assert.strictEqual(this.manager.callback, callback); - assert.deepEqual(this.manager.visibleSections, [ - "favorites", - "smileys_&_emotion", - ]); - assert.strictEqual(this.manager.lastVisibleSection, "favorites"); - - await settled(); - - assert.deepEqual(this.manager.emojis, emojisReponse()); - assert.strictEqual(this.manager.loading, false); - }); - test("addVisibleSections", async function (assert) { this.manager.addVisibleSections(["favorites", "objects"]); @@ -75,7 +35,7 @@ module( test("sections", async function (assert) { assert.deepEqual(this.manager.sections, []); - this.manager.startFromComposer(() => {}); + this.manager.open({}); assert.deepEqual(this.manager.sections, []); @@ -84,14 +44,12 @@ module( assert.deepEqual(this.manager.sections, ["favorites"]); }); - test("startFromComposer", async function (assert) { - const callback = () => {}; - this.manager.startFromComposer(callback); + test("open", async function (assert) { + this.manager.open({ context: "chat-composer" }); assert.ok(this.manager.loading); - assert.ok(this.manager.opened); - assert.strictEqual(this.manager.context, "chat-composer"); - assert.strictEqual(this.manager.callback, callback); + assert.ok(this.manager.picker); + assert.strictEqual(this.manager.picker.context, "chat-composer"); assert.deepEqual(this.manager.visibleSections, [ "favorites", "smileys_&_emotion", @@ -104,28 +62,16 @@ module( assert.strictEqual(this.manager.loading, false); }); - test("startFromComposer with filter option", async function (assert) { - const callback = () => {}; - this.manager.startFromComposer(callback, { filter: "foofilter" }); - await settled(); - - assert.strictEqual(this.manager.initialFilter, "foofilter"); - }); - test("closeExisting", async function (assert) { - const callback = () => { - return; - }; - - this.manager.startFromComposer(() => {}); + this.manager.open({ context: "channel-composer", trigger: "foo" }); this.manager.addVisibleSections("objects"); this.manager.lastVisibleSection = "objects"; - this.manager.startFromComposer(callback); + this.manager.open({ context: "thread-composer", trigger: "bar" }); assert.strictEqual( - this.manager.callback, - callback, - "it resets the callback to latest picker" + this.manager.picker.context, + "thread-composer", + "it resets the picker to latest picker" ); assert.deepEqual( this.manager.visibleSections, @@ -139,39 +85,21 @@ module( ); }); - test("didSelectEmoji", async function (assert) { - let value; - const callback = (emoji) => { - value = emoji.name; - }; - this.manager.startFromComposer(callback); - this.manager.didSelectEmoji({ name: "joy" }); - - assert.notOk(this.manager.callback); - assert.strictEqual(value, "joy"); - - await settled(); - - assert.notOk(this.manager.opened, "it closes the picker after selection"); - }); - test("close", async function (assert) { - this.manager.startFromComposer(() => {}); + this.manager.open({ context: "channel-composer" }); - assert.ok(this.manager.opened); - assert.ok(this.manager.callback); + assert.ok(this.manager.picker); this.manager.addVisibleSections("objects"); this.manager.lastVisibleSection = "objects"; this.manager.close(); - assert.notOk(this.manager.callback); assert.ok(this.manager.closing); - assert.ok(this.manager.opened); + assert.ok(this.manager.picker); await settled(); - assert.notOk(this.manager.opened); + assert.notOk(this.manager.picker); assert.notOk(this.manager.closing); assert.deepEqual( this.manager.visibleSections,