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 @@ + \ 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}} - - -
    + + + {{#if this.isExpanded}} +
      {{#each @buttons as |button|}} -
    • +
    • {{/each}}
    - + {{/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}} {{/if}} -{{#if this.editingMessage}} +{{#if this.composerService.editingMessage}} {{/if}} -
    -
    - {{#if - (and - this.chatEmojiPickerManager.opened - (eq this.chatEmojiPickerManager.context "chat-composer") - ) - }} - - {{else}} - {{#unless this.disableComposer}} - - {{/unless}} - {{/if}} + + {{/if}} @@ -91,4 +81,9 @@
    -{{/unless}} \ No newline at end of file +{{/unless}} + + \ 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}} -
    + + + + + + + +{{#if this.chatStateManager.isDrawerExpanded}} +
    + {{#if this.chat.activeChannel.activeThread}} + + {{/if}} +
    +{{/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 }} -
    -
    - - - - {{#if this.chatEmojiPickerManager.sections.length}} - {{#if (not (gte this.filteredEmojis.length 0))}} -
    + 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|}} - - {{#if (eq section "favorites")}} - {{replace-emoji ":star:"}} - {{else}} - - {{/if}} - - {{/each-in}} -
    - {{/if}} - -
    -
    - {{#if (gte this.filteredEmojis.length 0)}} -
    - {{#each this.filteredEmojis as |emoji|}} - {{emoji.name}} - {{else}} -

    - {{i18n "chat.emoji_picker.no_results"}} -

    - {{/each}} -
    - {{/if}} - - {{#each-in this.groups as |section emojis|}} -
    -

    - {{i18n - (concat "chat.emoji_picker." section) - translatedFallback=section + {{#if + (not + (eq fitzpatrick.scale this.chatEmojiReactionStore.diversity) + ) }} -

    -
    - {{! we always want the first emoji for tabbing}} - {{#let emojis.firstObject as |emoji|}} + + {{/if}} + {{/each}} + {{/if}} + + +
    + +
    + + {{#if this.chatEmojiPickerManager.sections.length}} + {{#if (not (gte this.filteredEmojis.length 0))}} +
    +
    + + {{#each-in this.groups as |section emojis|}} + + {{#if (eq section "favorites")}} + {{replace-emoji ":star:"}} + {{else}} + + {{/if}} + + {{/each-in}} +
    + {{/if}} + +
    +
    + {{#if (gte this.filteredEmojis.length 0)}} +
    + {{#each this.filteredEmojis as |emoji|}} - {{/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)}} - {{emoji.name}} - {{/if}} - {{/each}} - {{/if}} + {{else}} +

    + {{i18n "chat.emoji_picker.no_results"}} +

    + {{/each}}
    -
    - {{/each-in}} -
    -
    - {{else}} -
    - {{/if}} -
    + {{/if}} -{{#if - (and - this.chatEmojiPickerManager.opened - this.site.mobileView - (eq this.chatEmojiPickerManager.context "chat-message") - ) -}} -
    + {{#each-in this.groups as |section emojis|}} +
    +

    + {{i18n + (concat "chat.emoji_picker." section) + translatedFallback=section + }} +

    +
    + {{! we always want the first emoji for tabbing}} + {{#let emojis.firstObject as |emoji|}} + {{emoji.name}} + {{/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)}} + {{emoji.name}} + {{/if}} + {{/each}} + {{/if}} +
    +
    + {{/each-in}} +
    +
    + {{else}} +
    + {{/if}} +
    + + {{#if + (and + this.site.mobileView + (eq this.chatEmojiPickerManager.picker.context "chat-channel-message") + ) + }} +
    + {{/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 @@ -
    - -
    -
    -
    -
    +
    {{#if this.loadedOnce}} {{#each @channel.messages key="id" as |message|}} {{/each}} {{else}} @@ -86,26 +65,25 @@ @channel={{@channel}} /> - {{#if this.selectingMessages}} + {{#if this.chatChannelPane.selectingMessages}} {{else}} {{#if (or @channel.isDraft @channel.isFollowing)}} {{else}} 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 @@ -
    -
    - {{#if this.chatStateManager.isFullPageActive}} - {{#each @emojiReactions key="emoji" as |reaction|}} - +
    + {{#if this.chatStateManager.isFullPageActive}} + {{#each + this.messageInteractor.emojiReactions + key="emoji" + as |reaction| + }} + + {{/each}} + {{/if}} + + {{#if this.messageInteractor.canInteractWithMessage}} + - {{/each}} - {{/if}} + {{/if}} - {{#if @messageCapabilities.canReact}} - - {{/if}} + {{#if this.messageInteractor.canBookmark}} + + + + {{/if}} - {{#if @messageCapabilities.canBookmark}} - - - - {{/if}} + {{#if this.messageInteractor.canReply}} + + {{/if}} - {{#if @messageCapabilities.canReply}} - - {{/if}} + {{#if this.messageInteractor.canOpenThread}} + + {{/if}} - {{#if @messageCapabilities.hasThread}} - - {{/if}} - - {{#if @secondaryButtons.length}} - - {{/if}} + {{#if this.messageInteractor.secondaryButtons.length}} + + {{/if}} +
    -
    \ 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 @@ -
    +{{#if (and this.site.mobileView this.chat.activeMessage)}}
    -
    - -
    -
    -
    - - - {{@message.message}} - -
    +
    -
      - {{#each @secondaryButtons as |button|}} -
    • - +
      +
      + + -
    • - {{/each}} -
    - - {{#if (or @messageCapabilities.canReact @messageCapabilities.canReply)}} -
    - {{#if @messageCapabilities.canReact}} - {{#each @emojiReactions as |reaction|}} - - {{/each}} - - - {{/if}} - - {{#if @messageCapabilities.canBookmark}} - - - - {{/if}} - - {{#if @messageCapabilities.canReply}} - - {{/if}} + {{this.message.message}} + +
    - {{/if}} + +
      + {{#each this.messageInteractor.secondaryButtons as |button|}} +
    • + +
    • + {{/each}} +
    + + {{#if + (or this.messageInteractor.canReact this.messageInteractor.canReply) + }} +
    + {{#if this.messageInteractor.canReact}} + {{#each this.messageInteractor.emojiReactions as |reaction|}} + + {{/each}} + + + {{/if}} + + {{#if this.messageInteractor.canBookmark}} + + + + {{/if}} + + {{#if this.messageInteractor.canReply}} + + {{/if}} +
    + {{/if}} +
    -
    \ 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 }} - - - -{{#if - (and - this.showActions this.site.mobileView this.chatMessageActionsMobileAnchor - ) -}} - {{#in-element this.chatMessageActionsMobileAnchor}} - - {{/in-element}} -{{/if}} - -{{#if - (and - this.showActions this.site.desktopView this.chatMessageActionsDesktopAnchor - ) -}} - {{#in-element this.chatMessageActionsDesktopAnchor}} - - {{/in-element}} +{{#if (eq @context "channel")}} + + {{/if}}
    {{#if this.show}} - {{#if @selectingMessages}} + {{#if this.pane.selectingMessages}} {{#unless this.hideReplyToInfo}} @@ -132,18 +97,20 @@ {{#each @message.reactions as |reaction|}} {{/each}} - {{#if @canInteractWithChat}} + {{#if this.chat.userCanInteractWithChat}} {{#unless this.site.mobileView}} {{/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}} >
    -
    -
    -

    {{this.title}}

    + {{i18n "chat.thread.label"}} + + {{d-icon "times"}} + +
    - - {{d-icon "times"}} - -
    - -

    - {{replace-emoji this.thread.originalMessage.excerpt}} -

    - -
    - {{i18n - "chat.threads.started_by" - }} - +
    + {{#each this.thread.messages key="id" as |message|}} + - {{this.thread.originalMessageUser.username}} -
    + {{/each}} + {{#if (or this.loading this.loadingMoreFuture)}} + + {{/if}}
    -
    -
      - {{#each this.thread.messages as |message|}} -
    • {{message.user.username}}: {{message.message}}
    • - {{/each}} -
    - {{#if (or this.loading this.loadingMoreFuture)}} - - {{/if}} -
    - + {{#if this.chatChannelThreadPane.selectingMessages}} + + {{else}} + + {{/if}}
    \ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-thread.js b/plugins/chat/assets/javascripts/discourse/components/chat-thread.js index 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 @@ + \ 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 @@ + \ 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 @@ + \ 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} 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}} - - {{/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``); + + await render( + hbs`` + ); 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``); + + await render( + hbs`` + ); 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` - + `); 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` 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,