DEV: Refactoring chat message actions for ChatMessage component usage in thread panel (#20756)

This commit is a major overhaul of how chat message actions work, to make it so they are reusable between the main chat channel and the chat thread panel, as well as many improvements and fixes for the thread panel.

There are now several new classes and concepts:

* ChatMessageInteractor -  This is initialized from the ChatMessage, ChatMessageActionsDesktop, and ChatMessageActionsMobile components. This handles permissions about what actions can be done for each
message based on the context (thread or channel), handles the actions themselves (e.g. copyLink, delete, edit),
and interacts with the pane of the current context to modify the UI
* ChatChannelThreadPane and ChatChannelPane services - This represents the UI context which contains the
messages, and are mostly used for state management for things like message selection.
* ChatChannelThreadComposer and ChatChannelComposer - This handles interaction between the pane, the
message actions, and the composer, dealing with reply and edit message state.
* Scrolling logic for the messages has now been moved to a helper so it can be shared between the main channel pane and the thread pane
* Various improvements with the emoji picker on both mobile and desktop. The DOM node of each component is now located outside of the message which prevents a large range of issues.

The thread panel now also works in the chat drawer, and the thread messages have less
actions than the main panel, since some do not make sense there (e.g. moving messages to
a different channel). The thread panel title, excerpt, and message sender have also been removed
for now to save space.

This gives us a solid base to keep expanding on and fixing up threads. Subsequent PRs will
make the thread MessageBus subscriptions work and disable echo mode
for the initial release of threads.

Co-authored-by: Joffrey JAFFEUX <j.jaffeux@gmail.com>
This commit is contained in:
Martin Brennan
2023-04-06 23:19:52 +10:00
committed by GitHub
parent cee06bdc77
commit ea548292bc
71 changed files with 2169 additions and 1887 deletions

View File

@ -0,0 +1,7 @@
<ChatEmojiPicker
@context="chat-channel-message"
@didInsert={{this.didInsert}}
@willDestroy={{this.willDestroy}}
@didSelectEmoji={{this.didSelectEmoji}}
@class="hidden"
/>

View File

@ -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();
}
}

View File

@ -1,26 +1,32 @@
{{#if @buttons.length}} {{#if @buttons.length}}
<DPopover <DButton
@class="chat-composer-dropdown" @disabled={{@isDisabled}}
@options={{hash arrow=null}} @class="chat-composer-dropdown__trigger-btn btn-flat btn-icon"
as |state| @title="chat.composer.toggle_toolbar"
> @icon={{if @hasActivePanel "times" "plus"}}
<FlatButton @action={{this.toggleExpand}}
@disabled={{@isDisabled}} {{did-insert this.setupTrigger}}
@class="chat-composer-dropdown__trigger-btn d-popover-trigger" />
@title="chat.composer.toggle_toolbar"
@icon={{if state.isExpanded "times" "plus"}} {{#if this.isExpanded}}
/> <ul
<ul class="chat-composer-dropdown__list"> class="chat-composer-dropdown__list"
{{did-insert this.setupPanel}}
{{will-destroy this.teardownPanel}}
>
{{#each @buttons as |button|}} {{#each @buttons as |button|}}
<li class="chat-composer-dropdown__item {{button.id}}"> <li class={{concat-class "chat-composer-dropdown__item" button.id}}>
<DButton <DButton
@class={{concat "chat-composer-dropdown__action-btn " button.id}} @class={{concat-class
"chat-composer-dropdown__action-btn"
button.id
}}
@icon={{button.icon}} @icon={{button.icon}}
@action={{button.action}} @action={{(fn this.onButtonClick button)}}
@label={{button.label}} @label={{button.label}}
/> />
</li> </li>
{{/each}} {{/each}}
</ul> </ul>
</DPopover> {{/if}}
{{/if}} {{/if}}

View File

@ -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();
}
}

View File

@ -1,45 +1,35 @@
{{#if this.replyToMsg}} {{#if this.composerService.replyToMsg}}
<ChatComposerMessageDetails <ChatComposerMessageDetails
@message={{this.replyToMsg}} @message={{this.composerService.replyToMsg}}
@icon="reply" @icon="reply"
@action={{action "cancelReplyTo"}} @action={{action "cancelReplyTo"}}
/> />
{{/if}} {{/if}}
{{#if this.editingMessage}} {{#if this.composerService.editingMessage}}
<ChatComposerMessageDetails <ChatComposerMessageDetails
@message={{this.editingMessage}} @message={{this.composerService.editingMessage}}
@icon="pencil-alt" @icon="pencil-alt"
@action={{action "cancelEditing"}} @action={{action "cancelEditing"}}
/> />
{{/if}} {{/if}}
<div class="chat-composer-emoji-picker-anchor"></div>
<div <div
role="region" role="region"
aria-label={{i18n "chat.aria_roles.composer"}} aria-label={{i18n "chat.aria_roles.composer"}}
class="chat-composer {{if this.disableComposer 'is-disabled'}}" class="chat-composer {{if this.disableComposer 'is-disabled'}}"
{{did-update this.updateEditingMessage this.composerService.editingMessage}}
> >
{{#if
(and <ChatComposerDropdown
this.chatEmojiPickerManager.opened @buttons={{this.dropdownButtons}}
(eq this.chatEmojiPickerManager.context "chat-composer") @isDisabled={{this.disableComposer}}
) @hasActivePanel={{and
}} this.chatEmojiPickerManager.picker
<DButton (eq this.chatEmojiPickerManager.picker.context @context)
@icon="times" }}
@action={{this.chatEmojiPickerManager.close}} @onCloseActivePanel={{this.chatEmojiPickerManager.close}}
@class="chat-composer__close-emoji-picker-btn btn-flat" />
/>
{{else}}
{{#unless this.disableComposer}}
<ChatComposerDropdown
@buttons={{this.dropdownButtons}}
@isDisabled={{this.disableComposer}}
/>
{{/unless}}
{{/if}}
<DTextarea <DTextarea
@value={{readonly this.value}} @value={{readonly this.value}}
@ -82,7 +72,7 @@
@onUploadChanged={{this.uploadsChanged}} @onUploadChanged={{this.uploadsChanged}}
@existingUploads={{or @existingUploads={{or
this.chatChannel.draft.uploads this.chatChannel.draft.uploads
this.editingMessage.uploads this.composerService.editingMessage.uploads
}} }}
/> />
{{/if}} {{/if}}
@ -92,3 +82,8 @@
<ChatReplyingIndicator @chatChannel={{this.chatChannel}} /> <ChatReplyingIndicator @chatChannel={{this.chatChannel}} />
</div> </div>
{{/unless}} {{/unless}}
<ChatEmojiPicker
@context={{@context}}
@didSelectEmoji={{this.didSelectEmoji}}
/>

View File

@ -15,7 +15,7 @@ import { findRawTemplate } from "discourse-common/lib/raw-templates";
import { emojiSearch, isSkinTonableEmoji } from "pretty-text/emoji"; import { emojiSearch, isSkinTonableEmoji } from "pretty-text/emoji";
import { emojiUrlFor } from "discourse/lib/text"; import { emojiUrlFor } from "discourse/lib/text";
import { inject as service } from "@ember/service"; 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 { SKIP } from "discourse/lib/autocomplete";
import { Promise } from "rsvp"; import { Promise } from "rsvp";
import { translations } from "pretty-text/emoji/data"; import { translations } from "pretty-text/emoji/data";
@ -32,12 +32,9 @@ export default Component.extend(TextareaTextManipulation, {
chat: service(), chat: service(),
classNames: ["chat-composer-container"], classNames: ["chat-composer-container"],
classNameBindings: ["emojiPickerVisible:with-emoji-picker"], classNameBindings: ["emojiPickerVisible:with-emoji-picker"],
userSilenced: readOnly("chatChannel.userSilenced"),
chatEmojiReactionStore: service("chat-emoji-reaction-store"), chatEmojiReactionStore: service("chat-emoji-reaction-store"),
chatEmojiPickerManager: service("chat-emoji-picker-manager"), chatEmojiPickerManager: service("chat-emoji-picker-manager"),
chatStateManager: service("chat-state-manager"), chatStateManager: service("chat-state-manager"),
editingMessage: null,
onValueChange: null,
timer: null, timer: null,
value: "", value: "",
inProgressUploads: null, inProgressUploads: null,
@ -50,12 +47,12 @@ export default Component.extend(TextareaTextManipulation, {
@discourseComputed(...chatComposerButtonsDependentKeys()) @discourseComputed(...chatComposerButtonsDependentKeys())
inlineButtons() { inlineButtons() {
return chatComposerButtons(this, "inline"); return chatComposerButtons(this, "inline", this.context);
}, },
@discourseComputed(...chatComposerButtonsDependentKeys()) @discourseComputed(...chatComposerButtonsDependentKeys())
dropdownButtons() { dropdownButtons() {
return chatComposerButtons(this, "dropdown"); return chatComposerButtons(this, "dropdown", this.context);
}, },
@discourseComputed("chatEmojiPickerManager.{opened,context}") @discourseComputed("chatEmojiPickerManager.{opened,context}")
@ -71,7 +68,6 @@ export default Component.extend(TextareaTextManipulation, {
init() { init() {
this._super(...arguments); this._super(...arguments);
this.appEvents.on("chat-composer:reply-to-set", this, "_replyToMsgChanged");
this.appEvents.on( this.appEvents.on(
"upload-mixin:chat-composer-uploader:in-progress-uploads", "upload-mixin:chat-composer-uploader:in-progress-uploads",
this, this,
@ -82,6 +78,10 @@ export default Component.extend(TextareaTextManipulation, {
inProgressUploads: [], inProgressUploads: [],
_uploads: [], _uploads: [],
}); });
this.composerService?.registerFocusHandler(() => {
this._focusTextArea();
});
}, },
didInsertElement() { didInsertElement() {
@ -92,7 +92,6 @@ export default Component.extend(TextareaTextManipulation, {
this._applyUserAutocomplete(this._$textarea); this._applyUserAutocomplete(this._$textarea);
this._applyCategoryHashtagAutocomplete(this._$textarea); this._applyCategoryHashtagAutocomplete(this._$textarea);
this._applyEmojiAutocomplete(this._$textarea); this._applyEmojiAutocomplete(this._$textarea);
this.appEvents.on("chat:focus-composer", this, "_focusTextArea");
this.appEvents.on("chat:insert-text", this, "insertText"); this.appEvents.on("chat:insert-text", this, "insertText");
this._focusTextArea(); this._focusTextArea();
@ -134,11 +133,6 @@ export default Component.extend(TextareaTextManipulation, {
willDestroyElement() { willDestroyElement() {
this._super(...arguments); this._super(...arguments);
this.appEvents.off(
"chat-composer:reply-to-set",
this,
"_replyToMsgChanged"
);
this.appEvents.off( this.appEvents.off(
"upload-mixin:chat-composer-uploader:in-progress-uploads", "upload-mixin:chat-composer-uploader:in-progress-uploads",
this, this,
@ -147,7 +141,6 @@ export default Component.extend(TextareaTextManipulation, {
cancel(this.timer); cancel(this.timer);
this.appEvents.off("chat:focus-composer", this, "_focusTextArea");
this.appEvents.off("chat:insert-text", this, "insertText"); this.appEvents.off("chat:insert-text", this, "insertText");
this.appEvents.off("chat:modify-selection", this, "_modifySelection"); this.appEvents.off("chat:modify-selection", this, "_modifySelection");
this.appEvents.off( this.appEvents.off(
@ -192,19 +185,19 @@ export default Component.extend(TextareaTextManipulation, {
if ( if (
event.key === "ArrowUp" && event.key === "ArrowUp" &&
this._messageIsEmpty() && this._messageIsEmpty() &&
!this.editingMessage !this.composerService?.editingMessage
) { ) {
event.preventDefault(); event.preventDefault();
this.onEditLastMessageRequested(); this.paneService?.editLastMessageRequested();
} }
if (event.keyCode === 27) { if (event.keyCode === 27) {
// keyCode for 'Escape' // keyCode for 'Escape'
if (this.replyToMsg) { if (this.composerService?.replyToMsg) {
this.set("value", ""); this.set("value", "");
this._replyToMsgChanged(null); this.composerService?.setReplyTo(null);
return false; return false;
} else if (this.editingMessage) { } else if (this.composerService?.editingMessage) {
this.set("value", ""); this.set("value", "");
this.cancelEditing(); this.cancelEditing();
return false; return false;
@ -218,34 +211,36 @@ export default Component.extend(TextareaTextManipulation, {
this._super(...arguments); this._super(...arguments);
if ( if (
!this.editingMessage && !this.composerService?.editingMessage &&
this.chatChannel?.draft && this.chatChannel?.draft &&
this.chatChannel?.canModifyMessages(this.currentUser) this.chatChannel?.canModifyMessages(this.currentUser)
) { ) {
// uses uploads from draft here... // uses uploads from draft here...
this.setProperties({ this.set("value", this.chatChannel.draft.message);
value: this.chatChannel.draft.message, this.composerService?.setReplyTo(this.chatChannel.draft.replyToMsg);
replyToMsg: this.chatChannel.draft.replyToMsg,
});
this._captureMentions(); this._captureMentions();
this._syncUploads(this.chatChannel.draft.uploads); 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(); 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 // 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 // for chat-composer-uploads to preload in existing uploads for drafts
// and for when messages are being edited. // 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 @action
onTextareaInput(value) { onTextareaInput(value) {
this.set("value", value); this.set("value", value);
@ -299,7 +289,7 @@ export default Component.extend(TextareaTextManipulation, {
@bind @bind
_handleTextareaInput() { _handleTextareaInput() {
this.onValueChange?.({ value: this.value }); this.composerService?.onComposerValueChange?.({ value: this.value });
}, },
@bind @bind
@ -324,6 +314,18 @@ export default Component.extend(TextareaTextManipulation, {
const code = `:${emoji}:`; const code = `:${emoji}:`;
this.chatEmojiReactionStore.track(code); this.chatEmojiReactionStore.track(code);
this.addText(this.getSelected(), 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 @action
@ -420,8 +422,9 @@ export default Component.extend(TextareaTextManipulation, {
return `${v.code}:`; return `${v.code}:`;
} else { } else {
$textarea.autocomplete({ cancel: true }); $textarea.autocomplete({ cancel: true });
this.chatEmojiPickerManager.startFromComposer(this.emojiSelected, { this.chatEmojiPickerManager.open({
filter: v.term, context: this.context,
initialFilter: v.term,
}); });
return ""; return "";
} }
@ -551,18 +554,21 @@ export default Component.extend(TextareaTextManipulation, {
@discourseComputed( @discourseComputed(
"chatChannel.{id,chatable.users.[]}", "chatChannel.{id,chatable.users.[]}",
"canInteractWithChat" "chat.userCanInteractWithChat"
) )
disableComposer(channel, canInteractWithChat) { disableComposer(channel, userCanInteractWithChat) {
return ( return (
(channel.isDraft && isEmpty(channel?.chatable?.users)) || (channel.isDraft && isEmpty(channel?.chatable?.users)) ||
!canInteractWithChat || !userCanInteractWithChat ||
!channel.canModifyMessages(this.currentUser) !channel.canModifyMessages(this.currentUser)
); );
}, },
@discourseComputed("userSilenced", "chatChannel.{chatable.users.[],id}") @discourseComputed(
placeholder(userSilenced, chatChannel) { "chatChannel.{chatable.users.[],id}",
"chat.userCanInteractWithChat"
)
placeholder(chatChannel, userCanInteractWithChat) {
if (!chatChannel.canModifyMessages(this.currentUser)) { if (!chatChannel.canModifyMessages(this.currentUser)) {
return I18n.t( return I18n.t(
`chat.placeholder_new_message_disallowed.${chatChannel.status}` `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"); return I18n.t("chat.placeholder_silenced");
} else { } else {
return this.messageRecipient(chatChannel); return this.messageRecipient(chatChannel);
@ -612,7 +618,7 @@ export default Component.extend(TextareaTextManipulation, {
@discourseComputed( @discourseComputed(
"value", "value",
"loading", "paneService.sendingLoading",
"disableComposer", "disableComposer",
"inProgressUploads.[]" "inProgressUploads.[]"
) )
@ -636,23 +642,30 @@ export default Component.extend(TextareaTextManipulation, {
return; return;
} }
this.editingMessage this.composerService?.editingMessage
? this.internalEditMessage() ? this.internalEditMessage()
: this.internalSendMessage(); : this.internalSendMessage();
}, },
@action @action
internalSendMessage() { 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 @action
internalEditMessage() { internalEditMessage() {
return this.editMessage( return this.paneService
this.editingMessage, ?.editMessage(this.value, this._uploads)
this.value, .then(this.reset);
this._uploads
).then(this.reset);
}, },
_messageIsValid() { _messageIsValid() {
@ -691,19 +704,21 @@ export default Component.extend(TextareaTextManipulation, {
this._captureMentions(); this._captureMentions();
this._syncUploads([]); this._syncUploads([]);
this._focusTextArea({ ensureAtEnd: true, resizeTextarea: true }); 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 @action
cancelReplyTo() { cancelReplyTo() {
this.set("replyToMsg", null); this.composerService?.setReplyTo(null);
this.setInReplyToMsg(null);
this.onValueChange?.({ replyToMsg: null });
}, },
@action @action
cancelEditing() { cancelEditing() {
this.onCancelEditing(); this.composerService?.cancelEditing();
this._focusTextArea({ ensureAtEnd: true, resizeTextarea: true }); this._focusTextArea({ ensureAtEnd: true, resizeTextarea: true });
}, },
@ -721,7 +736,10 @@ export default Component.extend(TextareaTextManipulation, {
@action @action
uploadsChanged(uploads, { inProgressUploadsCount }) { uploadsChanged(uploads, { inProgressUploadsCount }) {
this.set("_uploads", cloneJSON(uploads)); this.set("_uploads", cloneJSON(uploads));
this.onValueChange?.({ uploads: this._uploads, inProgressUploadsCount }); this.composerService?.onComposerValueChange?.({
uploads: this._uploads,
inProgressUploadsCount,
});
}, },
@action @action

View File

@ -1,7 +1,7 @@
{{#if this.chatStateManager.isDrawerActive}} {{#if this.chatStateManager.isDrawerActive}}
<div <div
data-chat-channel-id={{this.chat.activeChannel.id}} data-chat-channel-id={{this.chat.activeChannel.id}}
data-chat-thread-id={{this.chat.activeChannel.activeThread.id}}
class={{concat-class class={{concat-class
"chat-drawer" "chat-drawer"
(if this.chatStateManager.isDrawerExpanded "is-expanded") (if this.chatStateManager.isDrawerExpanded "is-expanded")

View File

@ -22,6 +22,7 @@ export default Component.extend({
didInsertElement() { didInsertElement() {
this._super(...arguments); this._super(...arguments);
if (!this.chat.userCanChat) { if (!this.chat.userCanChat) {
return; return;
} }
@ -46,6 +47,7 @@ export default Component.extend({
willDestroyElement() { willDestroyElement() {
this._super(...arguments); this._super(...arguments);
if (!this.chat.userCanChat) { if (!this.chat.userCanChat) {
return; return;
} }

View File

@ -0,0 +1,23 @@
<ChatDrawer::Header>
<ChatDrawer::Header::LeftActions />
<ChatDrawer::Header::ChannelTitle
@channel={{this.chat.activeChannel}}
@drawerActions={{@drawerActions}}
/>
<ChatDrawer::Header::RightActions @drawerActions={{@drawerActions}} />
</ChatDrawer::Header>
{{#if this.chatStateManager.isDrawerExpanded}}
<div
class="chat-drawer-content"
{{did-insert this.fetchChannelAndThread}}
{{did-update this.fetchChannelAndThread @params.channelId}}
{{did-update this.fetchChannelAndThread @params.threadId}}
>
{{#if this.chat.activeChannel.activeThread}}
<ChatThread />
{{/if}}
</div>
{{/if}}

View File

@ -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;
});
});
}
}

View File

@ -1,175 +1,137 @@
{{! template-lint-disable no-invalid-interactive }} {{! template-lint-disable no-invalid-interactive }}
{{! template-lint-disable no-nested-interactive }} {{! template-lint-disable no-nested-interactive }}
{{! template-lint-disable no-down-event-binding }} {{! template-lint-disable no-down-event-binding }}
<div
class={{concat-class {{#if (eq this.chatEmojiPickerManager.picker.context @context)}}
"chat-emoji-picker" <div
(if this.chatEmojiPickerManager.closing "closing") class={{concat-class
}} "chat-emoji-picker"
{{did-insert this.addClickOutsideEventListener}} @class
{{will-destroy this.removeClickOutsideEventListener}} (if this.chatEmojiPickerManager.closing "closing")
{{on "keydown" this.trapKeyDownEvents}} }}
> {{did-insert this.addClickOutsideEventListener}}
<div class="chat-emoji-picker__filter-container"> {{did-insert this.chatEmojiPickerManager.loadEmojis}}
<DcFilterInput {{did-insert (if @didInsert @didInsert (noop))}}
@class="chat-emoji-picker__filter" {{will-destroy (if @willDestroy @willDestroy (noop))}}
@value={{this.chatEmojiPickerManager.initialFilter}} {{will-destroy this.removeClickOutsideEventListener}}
@filterAction={{action this.didInputFilter value="target.value"}} {{on "keydown" this.trapKeyDownEvents}}
@icons={{hash left="search"}} >
placeholder={{i18n "chat.emoji_picker.search_placeholder"}} <div class="chat-emoji-picker__filter-container">
autofocus={{true}} <DcFilterInput
{{did-insert this.focusFilter}} @class="chat-emoji-picker__filter"
{{did-insert @value={{this.chatEmojiPickerManager.picker.initialFilter}}
(fn this.didInputFilter this.chatEmojiPickerManager.initialFilter) @filterAction={{action this.didInputFilter value="target.value"}}
}} @icons={{hash left="search"}}
> placeholder={{i18n "chat.emoji_picker.search_placeholder"}}
<div autofocus={{true}}
class="chat-emoji-picker__fitzpatrick-scale" {{did-insert (if this.site.desktopView this.focusFilter (noop))}}
role="toolbar" {{did-insert
{{on "keyup" this.didNavigateFitzpatrickScale}} (fn
this.didInputFilter this.chatEmojiPickerManager.picker.initialFilter
)
}}
> >
{{#if this.isExpandedFitzpatrickScale}}
{{#each this.fitzpatrickModifiers as |fitzpatrick|}}
{{#if
(not (eq fitzpatrick.scale this.chatEmojiReactionStore.diversity))
}}
<button
type="button"
title={{concat "t" fitzpatrick.scale}}
tabindex="-1"
class={{concat-class
"chat-emoji-picker__fitzpatrick-modifier-btn"
(concat "t" fitzpatrick.scale)
}}
{{on
"keyup"
(fn this.didRequestFitzpatrickScale fitzpatrick.scale)
}}
{{on
"click"
(fn this.didRequestFitzpatrickScale fitzpatrick.scale)
}}
>
{{d-icon "check"}}
</button>
{{/if}}
{{/each}}
{{/if}}
<button
type="button"
title={{concat "t" this.fitzpatrick.scale}}
class={{concat-class
"chat-emoji-picker__fitzpatrick-modifier-btn current"
(concat "t" this.chatEmojiReactionStore.diversity)
}}
{{on "keyup" this.didToggleFitzpatrickScale}}
{{on "click" this.didToggleFitzpatrickScale}}
></button>
</div>
</DcFilterInput>
</div>
{{#if this.chatEmojiPickerManager.sections.length}}
{{#if (not (gte this.filteredEmojis.length 0))}}
<div class="chat-emoji-picker__sections-nav">
<div <div
class="chat-emoji-picker__sections-nav__indicator" class="chat-emoji-picker__fitzpatrick-scale"
style={{this.navIndicatorStyle}} role="toolbar"
></div> {{on "keyup" this.didNavigateFitzpatrickScale}}
>
{{#if this.isExpandedFitzpatrickScale}}
{{#each this.fitzpatrickModifiers as |fitzpatrick|}}
{{#each-in this.groups as |section emojis|}} {{#if
<DButton (not
class={{concat-class (eq fitzpatrick.scale this.chatEmojiReactionStore.diversity)
"btn-flat" )
"chat-emoji-picker__section-btn"
(if
(eq this.chatEmojiPickerManager.lastVisibleSection section)
"active"
)
}}
tabindex="-1"
style={{this.navBtnStyle}}
@action={{fn this.didRequestSection section}}
data-section={{section}}
>
{{#if (eq section "favorites")}}
{{replace-emoji ":star:"}}
{{else}}
<img
width="18"
height="18"
class="emoji"
src={{emojis.firstObject.url}}
/>
{{/if}}
</DButton>
{{/each-in}}
</div>
{{/if}}
<div
class="chat-emoji-picker__scrollable-content"
{{chat/emoji-picker-scroll-listener}}
>
<div
class="chat-emoji-picker__sections"
{{on "click" this.didSelectEmoji}}
{{on "keydown" this.onSectionsKeyDown}}
role="button"
>
{{#if (gte this.filteredEmojis.length 0)}}
<div class="chat-emoji-picker__section filtered">
{{#each this.filteredEmojis as |emoji|}}
<img
width="32"
height="32"
class="emoji"
src={{tonable-emoji-url
emoji
this.chatEmojiReactionStore.diversity
}}
tabindex="0"
data-emoji={{emoji.name}}
data-tonable={{if emoji.tonable "true"}}
alt={{emoji.name}}
title={{tonable-emoji-title
emoji
this.chatEmojiReactionStore.diversity
}}
loading="lazy"
/>
{{else}}
<p class="chat-emoji-picker__no-reults">
{{i18n "chat.emoji_picker.no_results"}}
</p>
{{/each}}
</div>
{{/if}}
{{#each-in this.groups as |section emojis|}}
<div
class={{concat-class
"chat-emoji-picker__section"
(if (gte this.filteredEmojis.length 0) "hidden")
}}
data-section={{section}}
role="region"
aria-label={{i18n
(concat "chat.emoji_picker." section)
translatedFallback=section
}}
>
<h2 class="chat-emoji-picker__section-title">
{{i18n
(concat "chat.emoji_picker." section)
translatedFallback=section
}} }}
</h2> <button
<div class="chat-emoji-picker__section-emojis"> type="button"
{{! we always want the first emoji for tabbing}} title={{concat "t" fitzpatrick.scale}}
{{#let emojis.firstObject as |emoji|}} tabindex="-1"
class={{concat-class
"chat-emoji-picker__fitzpatrick-modifier-btn"
(concat "t" fitzpatrick.scale)
}}
{{on
"keyup"
(fn this.didRequestFitzpatrickScale fitzpatrick.scale)
}}
{{on
"click"
(fn this.didRequestFitzpatrickScale fitzpatrick.scale)
}}
>
{{d-icon "check"}}
</button>
{{/if}}
{{/each}}
{{/if}}
<button
type="button"
title={{concat "t" this.fitzpatrick.scale}}
class={{concat-class
"chat-emoji-picker__fitzpatrick-modifier-btn current"
(concat "t" this.chatEmojiReactionStore.diversity)
}}
{{on "keyup" this.didToggleFitzpatrickScale}}
{{on "click" this.didToggleFitzpatrickScale}}
></button>
</div>
</DcFilterInput>
</div>
{{#if this.chatEmojiPickerManager.sections.length}}
{{#if (not (gte this.filteredEmojis.length 0))}}
<div class="chat-emoji-picker__sections-nav">
<div
class="chat-emoji-picker__sections-nav__indicator"
style={{this.navIndicatorStyle}}
></div>
{{#each-in this.groups as |section emojis|}}
<DButton
class={{concat-class
"btn-flat"
"chat-emoji-picker__section-btn"
(if
(eq this.chatEmojiPickerManager.lastVisibleSection section)
"active"
)
}}
tabindex="-1"
style={{this.navBtnStyle}}
@action={{fn this.didRequestSection section}}
data-section={{section}}
>
{{#if (eq section "favorites")}}
{{replace-emoji ":star:"}}
{{else}}
<img
width="18"
height="18"
class="emoji"
src={{emojis.firstObject.url}}
/>
{{/if}}
</DButton>
{{/each-in}}
</div>
{{/if}}
<div
class="chat-emoji-picker__scrollable-content"
{{chat/emoji-picker-scroll-listener}}
>
<div
class="chat-emoji-picker__sections"
{{on "click" this.didSelectEmoji}}
{{on "keydown" this.onSectionsKeyDown}}
role="button"
>
{{#if (gte this.filteredEmojis.length 0)}}
<div class="chat-emoji-picker__section filtered">
{{#each this.filteredEmojis as |emoji|}}
<img <img
width="32" width="32"
height="32" height="32"
@ -187,53 +149,101 @@
this.chatEmojiReactionStore.diversity this.chatEmojiReactionStore.diversity
}} }}
loading="lazy" loading="lazy"
{{on "focus" this.didFocusFirstEmoji}}
/> />
{{/let}} {{else}}
<p class="chat-emoji-picker__no-results">
{{#if {{i18n "chat.emoji_picker.no_results"}}
(includes this.chatEmojiPickerManager.visibleSections section) </p>
}} {{/each}}
{{#each emojis as |emoji index|}}
{{! first emoji has already been rendered, we don't want to re render or would lose focus}}
{{#if (gt index 0)}}
<img
width="32"
height="32"
class="emoji"
src={{tonable-emoji-url
emoji
this.chatEmojiReactionStore.diversity
}}
tabindex="-1"
data-emoji={{emoji.name}}
data-tonable={{if emoji.tonable "true"}}
alt={{emoji.name}}
title={{tonable-emoji-title
emoji
this.chatEmojiReactionStore.diversity
}}
loading="lazy"
/>
{{/if}}
{{/each}}
{{/if}}
</div> </div>
</div> {{/if}}
{{/each-in}}
</div>
</div>
{{else}}
<div class="spinner medium"></div>
{{/if}}
</div>
{{#if {{#each-in this.groups as |section emojis|}}
(and <div
this.chatEmojiPickerManager.opened class={{concat-class
this.site.mobileView "chat-emoji-picker__section"
(eq this.chatEmojiPickerManager.context "chat-message") (if (gte this.filteredEmojis.length 0) "hidden")
) }}
}} data-section={{section}}
<div class="chat-emoji-picker__backdrop"></div> role="region"
aria-label={{i18n
(concat "chat.emoji_picker." section)
translatedFallback=section
}}
>
<h2 class="chat-emoji-picker__section-title">
{{i18n
(concat "chat.emoji_picker." section)
translatedFallback=section
}}
</h2>
<div class="chat-emoji-picker__section-emojis">
{{! we always want the first emoji for tabbing}}
{{#let emojis.firstObject as |emoji|}}
<img
width="32"
height="32"
class="emoji"
src={{tonable-emoji-url
emoji
this.chatEmojiReactionStore.diversity
}}
tabindex="0"
data-emoji={{emoji.name}}
data-tonable={{if emoji.tonable "true"}}
alt={{emoji.name}}
title={{tonable-emoji-title
emoji
this.chatEmojiReactionStore.diversity
}}
loading="lazy"
{{on "focus" this.didFocusFirstEmoji}}
/>
{{/let}}
{{#if
(includes this.chatEmojiPickerManager.visibleSections section)
}}
{{#each emojis as |emoji index|}}
{{! first emoji has already been rendered, we don't want to re render or would lose focus}}
{{#if (gt index 0)}}
<img
width="32"
height="32"
class="emoji"
src={{tonable-emoji-url
emoji
this.chatEmojiReactionStore.diversity
}}
tabindex="-1"
data-emoji={{emoji.name}}
data-tonable={{if emoji.tonable "true"}}
alt={{emoji.name}}
title={{tonable-emoji-title
emoji
this.chatEmojiReactionStore.diversity
}}
loading="lazy"
/>
{{/if}}
{{/each}}
{{/if}}
</div>
</div>
{{/each-in}}
</div>
</div>
{{else}}
<div class="spinner medium"></div>
{{/if}}
</div>
{{#if
(and
this.site.mobileView
(eq this.chatEmojiPickerManager.picker.context "chat-channel-message")
)
}}
<div class="chat-emoji-picker__backdrop"></div>
{{/if}}
{{/if}} {{/if}}

View File

@ -1,4 +1,4 @@
import Component from "@ember/component"; import Component from "@glimmer/component";
import { htmlSafe } from "@ember/template"; import { htmlSafe } from "@ember/template";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
@ -40,9 +40,11 @@ export default class ChatEmojiPicker extends Component {
@service chatEmojiPickerManager; @service chatEmojiPickerManager;
@service emojiPickerScrollObserver; @service emojiPickerScrollObserver;
@service chatEmojiReactionStore; @service chatEmojiReactionStore;
@service capabilities;
@service site;
@tracked filteredEmojis = null; @tracked filteredEmojis = null;
@tracked isExpandedFitzpatrickScale = false; @tracked isExpandedFitzpatrickScale = false;
tagName = "";
fitzpatrickModifiers = FITZPATRICK_MODIFIERS; fitzpatrickModifiers = FITZPATRICK_MODIFIERS;
@ -163,7 +165,7 @@ export default class ChatEmojiPicker extends Component {
} }
} }
this.toggleProperty("isExpandedFitzpatrickScale"); this.isExpandedFitzpatrickScale = !this.isExpandedFitzpatrickScale;
} }
@action @action
@ -210,7 +212,9 @@ export default class ChatEmojiPicker extends Component {
@action @action
focusFilter(target) { focusFilter(target) {
target.focus(); schedule("afterRender", () => {
target?.focus();
});
} }
debouncedDidInputFilter(filter = "") { debouncedDidInputFilter(filter = "") {
@ -347,8 +351,7 @@ export default class ChatEmojiPicker extends Component {
emoji = `${emoji}:t${diversity}`; emoji = `${emoji}:t${diversity}`;
} }
this.chatEmojiPickerManager.didSelectEmoji(emoji); this.args.didSelectEmoji?.(emoji);
this.appEvents.trigger("chat:focus-composer");
} }
} }

View File

@ -2,7 +2,7 @@
class={{concat-class class={{concat-class
"chat-live-pane" "chat-live-pane"
(if this.loading "loading") (if this.loading "loading")
(if this.sendingLoading "sending-loading") (if this.chatChannelPane.sendingLoading "sending-loading")
(unless this.loadedOnce "not-loaded-once") (unless this.loadedOnce "not-loaded-once")
}} }}
{{did-insert this.setupListeners}} {{did-insert this.setupListeners}}
@ -23,47 +23,26 @@
<ChatMentionWarnings /> <ChatMentionWarnings />
<div class="chat-message-actions-mobile-anchor"></div>
<div
class={{concat-class
"chat-message-emoji-picker-anchor"
(if
(and
this.chatEmojiPickerManager.opened
(eq this.chatEmojiPickerManager.context "chat-message")
)
"-opened"
)
}}
></div>
<div <div
class="chat-messages-scroll chat-messages-container" class="chat-messages-scroll chat-messages-container"
{{on "scroll" this.computeScrollState passive=true}} {{on "scroll" this.computeScrollState passive=true}}
{{chat/on-scroll this.resetIdle (hash delay=500)}} {{chat/on-scroll this.resetIdle (hash delay=500)}}
{{chat/on-scroll this.computeArrow (hash delay=150)}} {{chat/on-scroll this.computeArrow (hash delay=150)}}
{{did-insert this.setScrollable}}
> >
<div class="chat-message-actions-desktop-anchor"></div> <div
<div class="chat-messages-container" {{chat/on-resize this.didResizePane}}> class="chat-messages-container"
{{chat/on-resize this.didResizePane (hash delay=10)}}
>
{{#if this.loadedOnce}} {{#if this.loadedOnce}}
{{#each @channel.messages key="id" as |message|}} {{#each @channel.messages key="id" as |message|}}
<ChatMessage <ChatMessage
@message={{message}} @message={{message}}
@canInteractWithChat={{this.canInteractWithChat}}
@channel={{@channel}} @channel={{@channel}}
@setReplyTo={{this.setReplyTo}}
@replyMessageClicked={{this.replyMessageClicked}}
@editButtonClicked={{this.editButtonClicked}}
@selectingMessages={{this.selectingMessages}}
@onStartSelectingMessages={{this.onStartSelectingMessages}}
@onSelectMessage={{this.onSelectMessage}}
@bulkSelectMessages={{this.bulkSelectMessages}}
@isHovered={{eq message.id this.hoveredMessageId}}
@onHoverMessage={{this.onHoverMessage}}
@resendStagedMessage={{this.resendStagedMessage}} @resendStagedMessage={{this.resendStagedMessage}}
@messageDidEnterViewport={{this.messageDidEnterViewport}} @messageDidEnterViewport={{this.messageDidEnterViewport}}
@messageDidLeaveViewport={{this.messageDidLeaveViewport}} @messageDidLeaveViewport={{this.messageDidLeaveViewport}}
@context="channel"
/> />
{{/each}} {{/each}}
{{else}} {{else}}
@ -86,26 +65,25 @@
@channel={{@channel}} @channel={{@channel}}
/> />
{{#if this.selectingMessages}} {{#if this.chatChannelPane.selectingMessages}}
<ChatSelectionManager <ChatSelectionManager
@selectedMessageIds={{this.selectedMessageIds}} @selectedMessageIds={{this.chatChannelPane.selectedMessageIds}}
@chatChannel={{@channel}} @chatChannel={{@channel}}
@cancelSelecting={{this.cancelSelecting}} @cancelSelecting={{action
this.chatChannelPane.cancelSelecting
@channel.selectedMessages
}}
@context="channel"
/> />
{{else}} {{else}}
{{#if (or @channel.isDraft @channel.isFollowing)}} {{#if (or @channel.isDraft @channel.isFollowing)}}
<ChatComposer <ChatComposer
@canInteractWithChat={{this.canInteractWithChat}}
@sendMessage={{this.sendMessage}} @sendMessage={{this.sendMessage}}
@editMessage={{this.editMessage}}
@setReplyTo={{this.setReplyTo}}
@loading={{this.sendingLoading}}
@editingMessage={{readonly this.editingMessage}}
@onCancelEditing={{this.cancelEditing}} @onCancelEditing={{this.cancelEditing}}
@setInReplyToMsg={{this.setInReplyToMsg}}
@onEditLastMessageRequested={{this.editLastMessageRequested}}
@onValueChange={{this.composerValueChanged}}
@chatChannel={{@channel}} @chatChannel={{@channel}}
@composerService={{this.chatChannelComposer}}
@paneService={{this.chatChannelPane}}
@context="channel"
/> />
{{else}} {{else}}
<ChatChannelPreviewCard @channel={{@channel}} /> <ChatChannelPreviewCard @channel={{@channel}} />

View File

@ -32,6 +32,8 @@ export default class ChatLivePane extends Component {
@service chatEmojiPickerManager; @service chatEmojiPickerManager;
@service chatComposerPresenceManager; @service chatComposerPresenceManager;
@service chatStateManager; @service chatStateManager;
@service chatChannelComposer;
@service chatChannelPane;
@service chatApi; @service chatApi;
@service currentUser; @service currentUser;
@service appEvents; @service appEvents;
@ -41,28 +43,26 @@ export default class ChatLivePane extends Component {
@tracked loading = false; @tracked loading = false;
@tracked loadingMorePast = false; @tracked loadingMorePast = false;
@tracked loadingMoreFuture = false; @tracked loadingMoreFuture = false;
@tracked hoveredMessageId = null;
@tracked sendingLoading = false; @tracked sendingLoading = false;
@tracked selectingMessages = false;
@tracked showChatQuoteSuccess = false; @tracked showChatQuoteSuccess = false;
@tracked includeHeader = true; @tracked includeHeader = true;
@tracked editingMessage = null;
@tracked replyToMsg = null;
@tracked hasNewMessages = false; @tracked hasNewMessages = false;
@tracked needsArrow = false; @tracked needsArrow = false;
@tracked loadedOnce = false; @tracked loadedOnce = false;
scrollable = null;
_loadedChannelId = null; _loadedChannelId = null;
_scrollerEl = null;
_lastSelectedMessage = null;
_mentionWarningsSeen = {}; _mentionWarningsSeen = {};
_unreachableGroupMentions = []; _unreachableGroupMentions = [];
_overMembersLimitGroupMentions = []; _overMembersLimitGroupMentions = [];
@action @action
setupListeners(element) { setScrollable(element) {
this._scrollerEl = element.querySelector(".chat-messages-scroll"); this.scrollable = element;
}
@action
setupListeners() {
document.addEventListener("scroll", this._forceBodyScroll, { document.addEventListener("scroll", this._forceBodyScroll, {
passive: true, passive: true,
}); });
@ -102,8 +102,8 @@ export default class ChatLivePane extends Component {
if (this._loadedChannelId !== this.args.channel?.id) { if (this._loadedChannelId !== this.args.channel?.id) {
this._unsubscribeToUpdates(this._loadedChannelId); this._unsubscribeToUpdates(this._loadedChannelId);
this.selectingMessages = false; this.chatChannelPane.selectingMessages = false;
this.cancelEditing(); this.chatChannelComposer.cancelEditing();
this._loadedChannelId = this.args.channel?.id; this._loadedChannelId = this.args.channel?.id;
} }
@ -239,7 +239,8 @@ export default class ChatLivePane extends Component {
.then((results) => { .then((results) => {
if ( if (
this._selfDeleted || this._selfDeleted ||
this.args.channel.id !== results.meta.channel_id this.args.channel.id !== results.meta.channel_id ||
!this.scrollable
) { ) {
return; return;
} }
@ -372,11 +373,15 @@ export default class ChatLivePane extends Component {
} }
schedule("afterRender", () => { schedule("afterRender", () => {
const messageEl = this._scrollerEl.querySelector( if (this._selfDeleted) {
return;
}
const messageEl = this.scrollable.querySelector(
`.chat-message-container[data-id='${messageId}']` `.chat-message-container[data-id='${messageId}']`
); );
if (!messageEl || this._selfDeleted) { if (!messageEl) {
return; return;
} }
@ -428,13 +433,13 @@ export default class ChatLivePane extends Component {
return; return;
} }
const element = this._scrollerEl.querySelector( const element = this.scrollable.querySelector(
`[data-id='${lastUnreadVisibleMessage.id}']` `[data-id='${lastUnreadVisibleMessage.id}']`
); );
// if the last visible message is not fully visible, we don't want to mark it as read // 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 // attempt to mark previous one as read
if (!this.#isBottomOfMessageVisible(element, this._scrollerEl)) { if (!this.#isBottomOfMessageVisible(element, this.scrollable)) {
lastUnreadVisibleMessage = lastUnreadVisibleMessage.previousMessage; lastUnreadVisibleMessage = lastUnreadVisibleMessage.previousMessage;
if ( 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 @action
scrollToLatestMessage() { scrollToLatestMessage() {
schedule("afterRender", () => { schedule("afterRender", () => {
@ -485,13 +473,21 @@ export default class ChatLivePane extends Component {
@action @action
computeArrow() { computeArrow() {
this.needsArrow = Math.abs(this._scrollerEl.scrollTop) >= 250; if (!this.scrollable) {
return;
}
this.needsArrow = Math.abs(this.scrollable.scrollTop) >= 250;
} }
@action @action
computeScrollState() { computeScrollState() {
cancel(this.onScrollEndedHandler); cancel(this.onScrollEndedHandler);
if (!this.scrollable) {
return;
}
if (this.#isAtTop()) { if (this.#isAtTop()) {
this.fetchMoreMessages({ direction: PAST }); this.fetchMoreMessages({ direction: PAST });
this.onScrollEnded(); 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() { get _selfDeleted() {
return this.isDestroying || this.isDestroyed; return this.isDestroying || this.isDestroyed;
} }
@ -731,11 +729,11 @@ export default class ChatLivePane extends Component {
sendMessage(message, uploads = []) { sendMessage(message, uploads = []) {
resetIdle(); resetIdle();
if (this.sendingLoading) { if (this.chatChannelPane.sendingLoading) {
return; return;
} }
this.sendingLoading = true; this.chatChannelPane.sendingLoading = true;
this.args.channel.draft = ChatMessageDraft.create(); this.args.channel.draft = ChatMessageDraft.create();
// TODO: all send message logic is due for massive refactoring // TODO: all send message logic is due for massive refactoring
@ -758,8 +756,8 @@ export default class ChatLivePane extends Component {
return; return;
} }
this.loading = false; this.loading = false;
this.sendingLoading = false; this.chatChannelPane.sendingLoading = false;
this._resetAfterSend(); this.chatChannelPane.resetAfterSend();
this.scrollToLatestMessage(); this.scrollToLatestMessage();
}); });
} }
@ -771,8 +769,8 @@ export default class ChatLivePane extends Component {
user: this.currentUser, user: this.currentUser,
}); });
if (this.replyToMsg) { if (this.chatChannelComposer.replyToMsg) {
stagedMessage.inReplyTo = this.replyToMsg; stagedMessage.inReplyTo = this.chatChannelComposer.replyToMsg;
} }
this.args.channel.messagesManager.addMessages([stagedMessage]); this.args.channel.messagesManager.addMessages([stagedMessage]);
@ -797,8 +795,8 @@ export default class ChatLivePane extends Component {
if (this._selfDeleted) { if (this._selfDeleted) {
return; return;
} }
this.sendingLoading = false; this.chatChannelPane.sendingLoading = false;
this._resetAfterSend(); this.chatChannelPane.resetAfterSend();
}); });
} }
@ -836,12 +834,12 @@ export default class ChatLivePane extends Component {
} }
} }
this._resetAfterSend(); this.chatChannelPane.resetAfterSend();
} }
@action @action
resendStagedMessage(stagedMessage) { resendStagedMessage(stagedMessage) {
this.sendingLoading = true; this.chatChannelPane.sendingLoading = true;
stagedMessage.error = null; stagedMessage.error = null;
@ -864,154 +862,14 @@ export default class ChatLivePane extends Component {
if (this._selfDeleted) { if (this._selfDeleted) {
return; 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() { get chatProgressBarContainer() {
return document.querySelector("#chat-progress-bar-container"); 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 @action
onCloseFullScreen() { onCloseFullScreen() {
this.chatStateManager.prefersDrawer(); 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) { _unsubscribeToUpdates(channelId) {
if (!channelId) { if (!channelId) {
return; 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 @action
addAutoFocusEventListener() { addAutoFocusEventListener() {
document.addEventListener("keydown", this._autoFocus); document.addEventListener("keydown", this._autoFocus);
@ -1277,14 +970,14 @@ export default class ChatLivePane extends Component {
return; return;
} }
event.preventDefault();
event.stopPropagation();
const composer = document.querySelector(".chat-composer-input"); const composer = document.querySelector(".chat-composer-input");
if (composer && !this.args.channel.isDraft) { if (composer && !this.args.channel.isDraft) {
this.appEvents.trigger("chat:insert-text", key);
composer.focus(); composer.focus();
return;
} }
event.preventDefault();
event.stopPropagation();
} }
@action @action
@ -1292,12 +985,66 @@ export default class ChatLivePane extends Component {
throttle(this, this._computeDatesSeparators, 50, false); 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() { _computeDatesSeparators() {
schedule("afterRender", () => { schedule("afterRender", () => {
if (this._selfDeleted) {
return;
}
if (!this.scrollable) {
return;
}
const dates = [ const dates = [
...this._scrollerEl.querySelectorAll(".chat-message-separator-date"), ...this.scrollable.querySelectorAll(".chat-message-separator-date"),
].reverse(); ].reverse();
const height = this._scrollerEl.querySelector( const height = this.scrollable.querySelector(
".chat-messages-container" ".chat-messages-container"
).clientHeight; ).clientHeight;
@ -1336,17 +1083,29 @@ export default class ChatLivePane extends Component {
} }
#isAtBottom() { #isAtBottom() {
return Math.abs(this._scrollerEl.scrollTop) <= 2; if (!this.scrollable) {
return false;
}
return Math.abs(this.scrollable.scrollTop) <= 2;
} }
#isTowardsBottom() { #isTowardsBottom() {
return Math.abs(this._scrollerEl.scrollTop) <= 50; if (!this.scrollable) {
return false;
}
return Math.abs(this.scrollable.scrollTop) <= 50;
} }
#isAtTop() { #isAtTop() {
if (!this.scrollable) {
return false;
}
return ( return (
Math.abs(this._scrollerEl.scrollTop) >= Math.abs(this.scrollable.scrollTop) >=
this._scrollerEl.scrollHeight - this._scrollerEl.offsetHeight - 2 this.scrollable.scrollHeight - this.scrollable.offsetHeight - 2
); );
} }

View File

@ -1,63 +1,72 @@
<div {{#if (and this.site.desktopView this.chat.activeMessage.model.id)}}
class="chat-message-actions-container" <div
data-id={{@message.id}} {{did-insert this.setupPopper}}
{{did-insert this.attachPopper}} {{did-update this.setupPopper this.chat.activeMessage.model.id}}
{{will-destroy this.destroyPopper}} {{will-destroy this.teardownPopper}}
> class="chat-message-actions-container"
<div class="chat-message-actions"> data-id={{this.message.id}}
{{#if this.chatStateManager.isFullPageActive}} >
{{#each @emojiReactions key="emoji" as |reaction|}} <div class="chat-message-actions">
<ChatMessageReaction {{#if this.chatStateManager.isFullPageActive}}
@reaction={{reaction}} {{#each
@react={{@messageActions.react}} this.messageInteractor.emojiReactions
@showCount={{false}} key="emoji"
as |reaction|
}}
<ChatMessageReaction
@reaction={{reaction}}
@onReaction={{this.messageInteractor.react}}
@message={{this.message}}
@showCount={{false}}
/>
{{/each}}
{{/if}}
{{#if this.messageInteractor.canInteractWithMessage}}
<DButton
@class="btn-flat react-btn"
@action={{this.messageInteractor.openEmojiPicker}}
@icon="discourse-emojis"
@title="chat.react"
@forwardEvent={{true}}
/> />
{{/each}} {{/if}}
{{/if}}
{{#if @messageCapabilities.canReact}} {{#if this.messageInteractor.canBookmark}}
<DButton <DButton
@class="btn-flat react-btn" @class="btn-flat bookmark-btn"
@action={{@messageActions.startReactionForMessageActions}} @action={{this.messageInteractor.toggleBookmark}}
@icon="discourse-emojis" >
@title="chat.react" <BookmarkIcon @bookmark={{this.message.bookmark}} />
/> </DButton>
{{/if}} {{/if}}
{{#if @messageCapabilities.canBookmark}} {{#if this.messageInteractor.canReply}}
<DButton <DButton
@class="btn-flat bookmark-btn" @class="btn-flat reply-btn"
@action={{@messageActions.toggleBookmark}} @action={{this.messageInteractor.reply}}
> @icon="reply"
<BookmarkIcon @bookmark={{@message.bookmark}} /> @title="chat.reply"
</DButton> />
{{/if}} {{/if}}
{{#if @messageCapabilities.canReply}} {{#if this.messageInteractor.canOpenThread}}
<DButton <DButton
@class="btn-flat reply-btn" @class="btn-flat chat-message-thread-btn"
@action={{@messageActions.reply}} @action={{this.messageInteractor.openThread}}
@icon="reply" @icon="puzzle-piece"
@title="chat.reply" @title="chat.threads.open"
/> />
{{/if}} {{/if}}
{{#if @messageCapabilities.hasThread}} {{#if this.messageInteractor.secondaryButtons.length}}
<DButton <DropdownSelectBox
@class="btn-flat chat-message-thread-btn" @class="more-buttons"
@action={{@messageActions.openThread}} @options={{hash icon="ellipsis-v" placement="left"}}
@icon="puzzle-piece" @content={{this.messageInteractor.secondaryButtons}}
@title="chat.threads.open" @onChange={{action this.messageInteractor.handleSecondaryButtons}}
/> />
{{/if}} {{/if}}
</div>
{{#if @secondaryButtons.length}}
<DropdownSelectBox
@class="more-buttons"
@options={{hash icon="ellipsis-v" placement="left"}}
@content={{@secondaryButtons}}
@onChange={{action "handleSecondaryButtons"}}
/>
{{/if}}
</div> </div>
</div> {{/if}}

View File

@ -1,34 +1,48 @@
import Component from "@glimmer/component"; 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 { 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; const MSG_ACTIONS_VERTICAL_PADDING = -10;
export default class ChatMessageActionsDesktop extends Component { export default class ChatMessageActionsDesktop extends Component {
@service chat;
@service chatStateManager; @service chatStateManager;
@service chatEmojiPickerManager;
@service site;
popper = null; popper = null;
@action get message() {
destroyPopper() { return this.chat.activeMessage.model;
this.popper?.destroy(); }
this.popper = null;
get context() {
return this.chat.activeMessage.context;
}
get messageInteractor() {
const activeMessage = this.chat.activeMessage;
return new ChatMessageInteractor(
getOwner(this),
activeMessage.model,
activeMessage.context
);
} }
@action @action
attachPopper() { setupPopper(element) {
this.destroyPopper(); this.popper?.destroy();
schedule("afterRender", () => { schedule("afterRender", () => {
this.popper = createPopper( this.popper = createPopper(
document.querySelector( chatMessageContainer(this.message.id, this.context),
`.chat-message-container[data-id="${this.args.message.id}"]` element,
),
document.querySelector(
`.chat-message-actions-container[data-id="${this.args.message.id}"] .chat-message-actions`
),
{ {
placement: "top-end", placement: "top-end",
strategy: "fixed", strategy: "fixed",
@ -46,7 +60,7 @@ export default class ChatMessageActionsDesktop extends Component {
} }
@action @action
handleSecondaryButtons(id) { teardownPopper() {
this.args.messageActions?.[id]?.(); this.popper?.destroy();
} }
} }

View File

@ -1,91 +1,91 @@
<div {{#if (and this.site.mobileView this.chat.activeMessage)}}
class={{concat-class
"chat-message-actions-backdrop"
(if this.showFadeIn "fade-in")
}}
{{did-insert this.fadeAndVibrate}}
>
<div <div
role="button" class={{concat-class
class="collapse-area" "chat-message-actions-backdrop"
{{on "touchstart" this.collapseMenu passive=true}} (if this.showFadeIn "fade-in")
}}
{{did-insert this.fadeAndVibrate}}
> >
</div> <div
role="button"
<div class="chat-message-actions"> class="collapse-area"
<div class="selected-message-container"> {{on "touchstart" this.collapseMenu passive=true}}
<div class="selected-message"> >
<ChatUserAvatar @user={{@message.user}} />
<span
{{on "touchstart" this.expandReply passive=true}}
role="button"
class={{concat-class
"selected-message-reply"
(if this.hasExpandedReply "is-expanded")
}}
>
{{@message.message}}
</span>
</div>
</div> </div>
<ul class="secondary-actions"> <div class="chat-message-actions">
{{#each @secondaryButtons as |button|}} <div class="selected-message-container">
<li class="chat-message-action-item" data-id={{button.id}}> <div class="selected-message">
<DButton <ChatUserAvatar @user={{this.message.user}} />
@class="chat-message-action" <span
@translatedLabel={{button.name}} {{on "touchstart" this.expandReply passive=true}}
@icon={{button.icon}} role="button"
@actionParam={{button.id}} class={{concat-class
@action={{action "selected-message-reply"
this.actAndCloseMenu (if this.hasExpandedReply "is-expanded")
(get @messageActions button.id)
}} }}
/>
</li>
{{/each}}
</ul>
{{#if (or @messageCapabilities.canReact @messageCapabilities.canReply)}}
<div class="main-actions">
{{#if @messageCapabilities.canReact}}
{{#each @emojiReactions as |reaction|}}
<ChatMessageReaction
@reaction={{reaction}}
@react={{@messageActions.react}}
@showCount={{false}}
/>
{{/each}}
<DButton
@class="btn-flat react-btn"
@action={{action
this.actAndCloseMenu
@messageActions.startReactionForMessageActions
}}
@icon="discourse-emojis"
@title="chat.react"
/>
{{/if}}
{{#if @messageCapabilities.canBookmark}}
<DButton
@class="btn-flat bookmark-btn"
@action={{@messageActions.toggleBookmark}}
> >
<BookmarkIcon @bookmark={{@message.bookmark}} /> {{this.message.message}}
</DButton> </span>
{{/if}} </div>
{{#if @messageCapabilities.canReply}}
<DButton
@class="chat-message-action reply-btn btn-flat"
@action={{action "actAndCloseMenu" @messageActions.reply}}
@icon="reply"
@title="chat.reply"
/>
{{/if}}
</div> </div>
{{/if}}
<ul class="secondary-actions">
{{#each this.messageInteractor.secondaryButtons as |button|}}
<li class="chat-message-action-item" data-id={{button.id}}>
<DButton
@class="chat-message-action"
@translatedLabel={{button.name}}
@icon={{button.icon}}
@actionParam={{button.id}}
@action={{action this.actAndCloseMenu button.id}}
/>
</li>
{{/each}}
</ul>
{{#if
(or this.messageInteractor.canReact this.messageInteractor.canReply)
}}
<div class="main-actions">
{{#if this.messageInteractor.canReact}}
{{#each this.messageInteractor.emojiReactions as |reaction|}}
<ChatMessageReaction
@reaction={{reaction}}
@onReaction={{this.messageInteractor.react}}
@message={{this.message}}
@showCount={{false}}
/>
{{/each}}
<DButton
@class="btn-flat react-btn"
@action={{this.openEmojiPicker}}
@icon="discourse-emojis"
@title="chat.react"
@forwardEvent={{true}}
/>
{{/if}}
{{#if this.messageInteractor.canBookmark}}
<DButton
@class="btn-flat bookmark-btn"
@action={{action this.actAndCloseMenu "toggleBookmark"}}
>
<BookmarkIcon @bookmark={{this.message.bookmark}} />
</DButton>
{{/if}}
{{#if this.messageInteractor.canReply}}
<DButton
@class="chat-message-action reply-btn btn-flat"
@action={{action this.actAndCloseMenu "reply"}}
@icon="reply"
@title="chat.reply"
/>
{{/if}}
</div>
{{/if}}
</div>
</div> </div>
</div> {{/if}}

View File

@ -1,4 +1,6 @@
import Component from "@glimmer/component"; 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 { tracked } from "@glimmer/tracking";
import discourseLater from "discourse-common/lib/later"; import discourseLater from "discourse-common/lib/later";
import { action } from "@ember/object"; import { action } from "@ember/object";
@ -6,6 +8,8 @@ import { isTesting } from "discourse-common/config/environment";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
export default class ChatMessageActionsMobile extends Component { export default class ChatMessageActionsMobile extends Component {
@service chat;
@service site;
@service capabilities; @service capabilities;
@tracked hasExpandedReply = false; @tracked hasExpandedReply = false;
@ -13,6 +17,20 @@ export default class ChatMessageActionsMobile extends Component {
messageActions = null; 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 @action
fadeAndVibrate() { fadeAndVibrate() {
discourseLater(this.#addFadeIn.bind(this)); discourseLater(this.#addFadeIn.bind(this));
@ -35,8 +53,14 @@ export default class ChatMessageActionsMobile extends Component {
} }
@action @action
actAndCloseMenu(fn) { actAndCloseMenu(fnId) {
fn?.(); this.messageInteractor[fnId]();
this.#onCloseMenu();
}
@action
openEmojiPicker(_, event) {
this.messageInteractor.openEmojiPicker(_, event);
this.#onCloseMenu(); this.#onCloseMenu();
} }
@ -52,7 +76,7 @@ export default class ChatMessageActionsMobile extends Component {
// by ensuring we are not hovering any message anymore // by ensuring we are not hovering any message anymore
// we also ensure the menu is fully removed // we also ensure the menu is fully removed
this.args.onHoverMessage?.(null); this.chat.activeMessage = null;
}, 200); }, 200);
} }

View File

@ -28,7 +28,7 @@ export default class ChatMessageInReplyToIndicator extends Component {
get hasThread() { get hasThread() {
return ( return (
this.args.message?.channel?.get("threading_enabled") && this.args.message?.channel?.threadingEnabled &&
this.args.message?.threadId this.args.message?.threadId
); );
} }

View File

@ -52,10 +52,11 @@ export default class ChatMessageReaction extends Component {
@action @action
handleClick() { handleClick() {
this.args.react?.( this.args.onReaction?.(
this.args.reaction.emoji, this.args.reaction.emoji,
this.args.reaction.reacted ? "remove" : "add" this.args.reaction.reacted ? "remove" : "add"
); );
return false; return false;
} }

View File

@ -1,57 +1,23 @@
{{! template-lint-disable no-invalid-interactive }} {{! template-lint-disable no-invalid-interactive }}
<ChatMessageSeparatorDate @message={{@message}} /> {{#if (eq @context "channel")}}
<ChatMessageSeparatorNew @message={{@message}} /> <ChatMessageSeparatorDate @message={{@message}} />
<ChatMessageSeparatorNew @message={{@message}} />
{{#if
(and
this.showActions this.site.mobileView this.chatMessageActionsMobileAnchor
)
}}
{{#in-element this.chatMessageActionsMobileAnchor}}
<ChatMessageActionsMobile
@message={{@message}}
@emojiReactions={{this.emojiReactions}}
@secondaryButtons={{this.secondaryButtons}}
@messageActions={{this.messageActions}}
@messageCapabilities={{this.messageCapabilities}}
@onHoverMessage={{@onHoverMessage}}
/>
{{/in-element}}
{{/if}}
{{#if
(and
this.showActions this.site.desktopView this.chatMessageActionsDesktopAnchor
)
}}
{{#in-element this.chatMessageActionsDesktopAnchor}}
<ChatMessageActionsDesktop
@message={{@message}}
@emojiReactions={{this.emojiReactions}}
@secondaryButtons={{this.secondaryButtons}}
@messageActions={{this.messageActions}}
@messageCapabilities={{this.messageCapabilities}}
/>
{{/in-element}}
{{/if}} {{/if}}
<div <div
{{will-destroy this.teardownChatMessage}} {{will-destroy this.teardownChatMessage}}
{{did-insert this.setMessageActionsAnchors}}
{{did-insert this.decorateCookedMessage}} {{did-insert this.decorateCookedMessage}}
{{did-update this.decorateCookedMessage @message.id}} {{did-update this.decorateCookedMessage @message.id}}
{{did-update this.decorateCookedMessage @message.version}} {{did-update this.decorateCookedMessage @message.version}}
{{on "touchmove" this.handleTouchMove passive=true}} {{on "touchmove" this.handleTouchMove passive=true}}
{{on "touchstart" this.handleTouchStart passive=true}} {{on "touchstart" this.handleTouchStart passive=true}}
{{on "touchend" this.handleTouchEnd passive=true}} {{on "touchend" this.handleTouchEnd passive=true}}
{{on "mouseenter" (fn @onHoverMessage @message (hash desktopOnly=true))}} {{on "mouseenter" this.onMouseEnter}}
{{on "mousemove" (fn @onHoverMessage @message (hash desktopOnly=true))}} {{on "mouseleave" this.onMouseLeave}}
{{on "mouseleave" (fn @onHoverMessage null (hash desktopOnly=true))}}
class={{concat-class class={{concat-class
"chat-message-container" "chat-message-container"
(if @isHovered "is-hovered") (if this.pane.selectingMessages "selecting-messages")
(if @selectingMessages "selecting-messages")
(if @message.highlighted "highlighted") (if @message.highlighted "highlighted")
}} }}
data-id={{@message.id}} data-id={{@message.id}}
@ -63,7 +29,7 @@
}} }}
> >
{{#if this.show}} {{#if this.show}}
{{#if @selectingMessages}} {{#if this.pane.selectingMessages}}
<Input <Input
@type="checkbox" @type="checkbox"
class="chat-message-selector" class="chat-message-selector"
@ -98,7 +64,6 @@
(if this.hideUserInfo "user-info-hidden") (if this.hideUserInfo "user-info-hidden")
(if @message.error "errored") (if @message.error "errored")
(if @message.bookmark "chat-message-bookmarked") (if @message.bookmark "chat-message-bookmarked")
(if @isHovered "chat-message-selected")
}} }}
> >
{{#unless this.hideReplyToInfo}} {{#unless this.hideReplyToInfo}}
@ -132,18 +97,20 @@
{{#each @message.reactions as |reaction|}} {{#each @message.reactions as |reaction|}}
<ChatMessageReaction <ChatMessageReaction
@reaction={{reaction}} @reaction={{reaction}}
@react={{this.react}} @onReaction={{this.messageInteractor.react}}
@message={{@message}}
@showTooltip={{true}} @showTooltip={{true}}
/> />
{{/each}} {{/each}}
{{#if @canInteractWithChat}} {{#if this.chat.userCanInteractWithChat}}
{{#unless this.site.mobileView}} {{#unless this.site.mobileView}}
<DButton <DButton
@class="chat-message-react-btn" @class="chat-message-react-btn"
@action={{this.startReactionForReactionList}} @action={{this.messageInteractor.openEmojiPicker}}
@icon="discourse-emojis" @icon="discourse-emojis"
@title="chat.react" @title="chat.react"
@forwardEvent={{true}}
/> />
{{/unless}} {{/unless}}
{{/if}} {{/if}}

View File

@ -1,22 +1,17 @@
import Bookmark from "discourse/models/bookmark";
import { openBookmarkModal } from "discourse/controllers/bookmark";
import { isTesting } from "discourse-common/config/environment"; import { isTesting } from "discourse-common/config/environment";
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import I18n from "I18n"; import I18n from "I18n";
import getURL from "discourse-common/lib/get-url";
import optionalService from "discourse/lib/optional-service"; import optionalService from "discourse/lib/optional-service";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { cancel, schedule } from "@ember/runloop"; import { cancel, schedule } from "@ember/runloop";
import { clipboardCopy } from "discourse/lib/utilities";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import { popupAjaxError } from "discourse/lib/ajax-error";
import discourseLater from "discourse-common/lib/later"; import discourseLater from "discourse-common/lib/later";
import isZoomed from "discourse/plugins/chat/discourse/lib/zoom-check"; import isZoomed from "discourse/plugins/chat/discourse/lib/zoom-check";
import showModal from "discourse/lib/show-modal"; import { getOwner } from "discourse-common/lib/get-owner";
import ChatMessageFlag from "discourse/plugins/chat/discourse/lib/chat-message-flag"; import ChatMessageInteractor from "discourse/plugins/chat/discourse/lib/chat-message-interactor";
import { tracked } from "@glimmer/tracking"; import discourseDebounce from "discourse-common/lib/debounce";
import ChatMessageReaction from "discourse/plugins/chat/discourse/models/chat-message-reaction"; import { bind } from "discourse-common/utils/decorators";
let _chatMessageDecorators = []; let _chatMessageDecorators = [];
@ -29,8 +24,7 @@ export function resetChatMessageDecorators() {
} }
export const MENTION_KEYWORDS = ["here", "all"]; export const MENTION_KEYWORDS = ["here", "all"];
export const MESSAGE_CONTEXT_THREAD = "thread";
export const REACTIONS = { add: "add", remove: "remove" };
export default class ChatMessage extends Component { export default class ChatMessage extends Component {
@service site; @service site;
@ -42,21 +36,25 @@ export default class ChatMessage extends Component {
@service chatApi; @service chatApi;
@service chatEmojiReactionStore; @service chatEmojiReactionStore;
@service chatEmojiPickerManager; @service chatEmojiPickerManager;
@service chatChannelPane;
@service chatChannelThreadPane;
@service chatChannelsManager; @service chatChannelsManager;
@service router; @service router;
@tracked chatMessageActionsMobileAnchor = null;
@tracked chatMessageActionsDesktopAnchor = null;
@optionalService adminTools; @optionalService adminTools;
cachedFavoritesReactions = null; get pane() {
reacting = false; return this.args.context === MESSAGE_CONTEXT_THREAD
? this.chatChannelThreadPane
: this.chatChannelPane;
}
constructor() { get messageInteractor() {
super(...arguments); return new ChatMessageInteractor(
getOwner(this),
this.cachedFavoritesReactions = this.chatEmojiReactionStore.favorites; this.args.message,
this.args.context
);
} }
get deletedAndCollapsed() { get deletedAndCollapsed() {
@ -72,15 +70,17 @@ export default class ChatMessage extends Component {
} }
@action @action
setMessageActionsAnchors() { expand() {
schedule("afterRender", () => { this.args.message.expanded = true;
this.chatMessageActionsDesktopAnchor = document.querySelector( }
".chat-message-actions-desktop-anchor"
); @action
this.chatMessageActionsMobileAnchor = document.querySelector( toggleChecked(event) {
".chat-message-actions-mobile-anchor" if (event.shiftKey) {
); this.messageInteractor.bulkSelect(event.target.checked);
}); }
this.messageInteractor.select(event.target.checked);
} }
@action @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() { get show() {
return ( return (
!this.args.message?.deletedAt || !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 @action
handleTouchStart() { handleTouchStart() {
// if zoomed don't track long press // if zoomed don't track long press
@ -232,24 +176,20 @@ export default class ChatMessage extends Component {
return; return;
} }
if (!this.args.isHovered) { // when testing this must be triggered immediately because there
// when testing this must be triggered immediately because there // is no concept of "long press" there, the Ember `tap` test helper
// is no concept of "long press" there, the Ember `tap` test helper // does send the touchstart/touchend events but immediately, see
// does send the touchstart/touchend events but immediately, see // https://github.com/emberjs/ember-test-helpers/blob/master/API.md#tap
// https://github.com/emberjs/ember-test-helpers/blob/master/API.md#tap if (isTesting()) {
if (isTesting()) { this._handleLongPress();
this._handleLongPress();
}
this._isPressingHandler = discourseLater(this._handleLongPress, 500);
} }
this._isPressingHandler = discourseLater(this._handleLongPress, 500);
} }
@action @action
handleTouchMove() { handleTouchMove() {
if (!this.args.isHovered) { cancel(this._isPressingHandler);
cancel(this._isPressingHandler);
}
} }
@action @action
@ -267,7 +207,7 @@ export default class ChatMessage extends Component {
document.activeElement.blur(); document.activeElement.blur();
document.querySelector(".chat-composer-input")?.blur(); document.querySelector(".chat-composer-input")?.blur();
this.args.onHoverMessage?.(this.args.message); this._setActiveMessage();
} }
get hideUserInfo() { get hideUserInfo() {
@ -297,81 +237,12 @@ export default class ChatMessage extends Component {
get hideReplyToInfo() { get hideReplyToInfo() {
return ( return (
this.args.context === MESSAGE_CONTEXT_THREAD ||
this.args.message?.inReplyTo?.id === 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() { get mentionWarning() {
return this.args.message.mentionWarning; return this.args.message.mentionWarning;
} }
@ -447,261 +318,4 @@ export default class ChatMessage extends Component {
dismissMentionWarning() { dismissMentionWarning() {
this.args.message.mentionWarning = null; 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,
})
);
});
}
} }

View File

@ -10,11 +10,13 @@ import { schedule } from "@ember/runloop";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import getURL from "discourse-common/lib/get-url"; import getURL from "discourse-common/lib/get-url";
import { bind } from "discourse-common/utils/decorators"; 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; @service router;
tagName = ""; tagName = "";
chatChannel = null; chatChannel = null;
context = null;
selectedMessageIds = null; selectedMessageIds = null;
chatCopySuccess = false; chatCopySuccess = false;
showChatCopySuccess = false; showChatCopySuccess = false;
@ -28,7 +30,9 @@ export default class AdminCustomizeColorsShowController extends Component {
@computed("chatChannel.isDirectMessageChannel", "chatChannel.canModerate") @computed("chatChannel.isDirectMessageChannel", "chatChannel.canModerate")
get showMoveMessageButton() { get showMoveMessageButton() {
return ( return (
!this.chatChannel.isDirectMessageChannel && this.chatChannel.canModerate this.context !== MESSAGE_CONTEXT_THREAD &&
!this.chatChannel.isDirectMessageChannel &&
this.chatChannel.canModerate
); );
} }

View File

@ -2,61 +2,58 @@
class={{concat-class "chat-thread" (if this.loading "loading")}} class={{concat-class "chat-thread" (if this.loading "loading")}}
data-id={{this.thread.id}} data-id={{this.thread.id}}
{{did-insert this.loadMessages}} {{did-insert this.loadMessages}}
{{did-update this.thread.id this.loadMessages}}
> >
<div class="chat-thread__header"> <div class="chat-thread__header">
<div class="chat-thread__info"> <span class="chat-thread__label">{{i18n "chat.thread.label"}}</span>
<div class="chat-thread__title"> <LinkTo
<h2>{{this.title}}</h2> class="chat-thread__close"
@route="chat.channel"
@models={{this.chat.activeChannel.routeModels}}
>
{{d-icon "times"}}
</LinkTo>
</div>
<LinkTo <div class="chat-thread__body" {{did-insert this.setScrollable}}>
class="chat-thread__close" <div
@route="chat.channel" class="chat-thread__messages chat-messages-container"
@models={{this.chat.activeChannel.routeModels}} {{chat/on-resize this.didResizePane (hash delay=10)}}
> >
{{d-icon "times"}} {{#each this.thread.messages key="id" as |message|}}
</LinkTo> <ChatMessage
</div> @message={{message}}
@channel={{this.channel}}
<p class="chat-thread__om"> @resendStagedMessage={{this.resendStagedMessage}}
{{replace-emoji this.thread.originalMessage.excerpt}} @messageDidEnterViewport={{this.messageDidEnterViewport}}
</p> @messageDidLeaveViewport={{this.messageDidLeaveViewport}}
@context="thread"
<div class="chat-thread__omu">
<span class="chat-thread__started-by">{{i18n
"chat.threads.started_by"
}}</span>
<ChatMessageAvatar
class="chat-thread__omu-avatar"
@message={{this.thread.originalMessage}}
/> />
<span {{/each}}
class="chat-thread__omu-username" {{#if (or this.loading this.loadingMoreFuture)}}
>{{this.thread.originalMessageUser.username}}</span> <ChatSkeleton />
</div> {{/if}}
</div> </div>
</div> </div>
<div class="chat-thread__messages">
<ul>
{{#each this.thread.messages as |message|}}
<li><strong>{{message.user.username}}</strong>: {{message.message}}</li>
{{/each}}
</ul>
{{#if (or this.loading this.loadingMoreFuture)}}
<ChatSkeleton />
{{/if}}
</div>
<ChatComposer {{#if this.chatChannelThreadPane.selectingMessages}}
@canInteractWithChat="true" <ChatSelectionManager
@sendMessage={{this.sendMessage}} @selectedMessageIds={{this.chatChannelThreadPane.selectedMessageIds}}
@editMessage={{this.editMessage}} @chatChannel={{this.chat.activeChannel}}
@setReplyTo={{this.setReplyTo}} @cancelSelecting={{action
@loading={{this.sendingLoading}} this.chatChannelThreadPane.cancelSelecting
@editingMessage={{readonly this.editingMessage}} this.chat.activeChannel.selectedMessages
@onCancelEditing={{this.cancelEditing}} }}
@setInReplyToMsg={{this.setInReplyToMsg}} @context="thread"
@onEditLastMessageRequested={{this.editLastMessageRequested}} />
@onValueChange={{this.composerValueChanged}} {{else}}
@chatChannel={{this.channel}} <ChatComposer
/> @sendMessage={{this.sendMessage}}
@onCancelEditing={{this.cancelEditing}}
@chatChannel={{this.channel}}
@composerService={{this.chatChannelThreadComposer}}
@paneService={{this.chatChannelThreadPane}}
@context="thread"
/>
{{/if}}
</div> </div>

View File

@ -6,8 +6,9 @@ import { action } from "@ember/object";
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message"; import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
import { bind, debounce } from "discourse-common/utils/decorators"; import { bind, debounce } from "discourse-common/utils/decorators";
import I18n from "I18n";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import { schedule } from "@ember/runloop";
import discourseLater from "discourse-common/lib/later";
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
@ -18,11 +19,16 @@ export default class ChatThreadPanel extends Component {
@service router; @service router;
@service chatApi; @service chatApi;
@service chatComposerPresenceManager; @service chatComposerPresenceManager;
@service chatChannelThreadComposer;
@service chatChannelThreadPane;
@service appEvents; @service appEvents;
@service capabilities;
@tracked loading; @tracked loading;
@tracked loadingMorePast; @tracked loadingMorePast;
scrollable = null;
get thread() { get thread() {
return this.channel.activeThread; return this.channel.activeThread;
} }
@ -31,12 +37,9 @@ export default class ChatThreadPanel extends Component {
return this.chat.activeChannel; return this.chat.activeChannel;
} }
get title() { @action
if (this.thread.title) { setScrollable(element) {
this.thread.escapedTitle; this.scrollable = element;
}
return I18n.t("chat.threads.op_said");
} }
@action @action
@ -53,6 +56,11 @@ export default class ChatThreadPanel extends Component {
// } // }
} }
@action
didResizePane() {
this.forceRendering();
}
get _selfDeleted() { get _selfDeleted() {
return this.isDestroying || this.isDestroyed; return this.isDestroying || this.isDestroyed;
} }
@ -93,9 +101,6 @@ export default class ChatThreadPanel extends Component {
const [messages, meta] = this.afterFetchCallback(this.channel, results); const [messages, meta] = this.afterFetchCallback(this.channel, results);
this.thread.messagesManager.addMessages(messages); this.thread.messagesManager.addMessages(messages);
// TODO (martin) ECHO MODE
this.channel.messagesManager.addMessages(messages);
// TODO (martin) details needed for thread?? // TODO (martin) details needed for thread??
this.thread.details = meta; this.thread.details = meta;
@ -127,7 +132,6 @@ export default class ChatThreadPanel extends Component {
@bind @bind
afterFetchCallback(channel, results) { afterFetchCallback(channel, results) {
const messages = []; const messages = [];
let foundFirstNew = false;
results.chat_messages.forEach((messageData) => { results.chat_messages.forEach((messageData) => {
// If a message has been hidden it is because the current user is ignoring // 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); 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)); messages.push(ChatMessage.create(channel, messageData));
}); });
@ -165,11 +159,11 @@ export default class ChatThreadPanel extends Component {
sendMessage(message, uploads = []) { sendMessage(message, uploads = []) {
// TODO (martin) For desktop notifications // TODO (martin) For desktop notifications
// resetIdle() // resetIdle()
if (this.sendingLoading) { if (this.chatChannelThreadPane.sendingLoading) {
return; return;
} }
this.sendingLoading = true; this.chatChannelThreadPane.sendingLoading = true;
this.channel.draft = ChatMessageDraft.create(); this.channel.draft = ChatMessageDraft.create();
// TODO (martin) Handling case when channel is not followed???? IDK if we // 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, thread_id: stagedMessage.threadId,
}) })
.then(() => { .then(() => {
// TODO (martin) Scrolling!! this.scrollToBottom();
// this.scrollToBottom();
}) })
.catch((error) => { .catch((error) => {
this.#onSendError(stagedMessage.stagedId, error); this.#onSendError(stagedMessage.stagedId, error);
@ -209,35 +202,70 @@ export default class ChatThreadPanel extends Component {
if (this._selfDeleted) { if (this._selfDeleted) {
return; return;
} }
this.sendingLoading = false; this.chatChannelThreadPane.sendingLoading = false;
this.#resetAfterSend(); 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 @action
editMessage() {} scrollToBottom() {
// editMessage(chatMessage, newContent, uploads) {} if (!this.scrollable) {
return;
}
@action this.scrollable.scrollTop = -1;
setReplyTo() {} this.forceRendering(() => {
// setReplyTo(messageId) {} this.scrollable.scrollTop = 0;
});
}
@action // since -webkit-overflow-scrolling: touch can't be used anymore to disable momentum scrolling
setInReplyToMsg(inReplyMsg) { // we now use this hack to disable it
this.replyToMsg = inReplyMsg; @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 @action
cancelEditing() { resendStagedMessage() {}
this.editingMessage = null; // resendStagedMessage(stagedMessage) {}
@action
messageDidEnterViewport(message) {
message.visible = true;
} }
@action @action
editLastMessageRequested() {} messageDidLeaveViewport(message) {
message.visible = false;
@action }
composerValueChanged() {}
// composerValueChanged(value, uploads, replyToMsg) {}
#handleErrors(error) { #handleErrors(error) {
switch (error?.jqXHR?.status) { switch (error?.jqXHR?.status) {
@ -262,17 +290,6 @@ export default class ChatThreadPanel extends Component {
} }
} }
this.#resetAfterSend(); this.chatChannelThreadPane.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);
} }
} }

View File

@ -0,0 +1 @@
<ChatMessageActionsDesktop />

View File

@ -60,11 +60,27 @@ export default {
class: "chat-emoji-btn", class: "chat-emoji-btn",
icon: "discourse-emojis", icon: "discourse-emojis",
position: "dropdown", position: "dropdown",
context: "channel",
action() { action() {
const chatEmojiPickerManager = container.lookup( const chatEmojiPickerManager = container.lookup(
"service:chat-emoji-picker-manager" "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" });
}, },
}); });

View File

@ -66,54 +66,60 @@ export function chatComposerButtonsDependentKeys() {
); );
} }
export function chatComposerButtons(context, position) { export function chatComposerButtons(composer, position, context) {
return Object.values(_chatComposerButtons) return Object.values(_chatComposerButtons)
.filter( .filter((button) => {
(button) => let valid =
computeButton(context, button, "displayed") && computeButton(composer, button, "displayed") &&
computeButton(context, button, "position") === position computeButton(composer, button, "position") === position;
)
if (button.context) {
valid = valid && computeButton(composer, button, "context") === context;
}
return valid;
})
.map((button) => { .map((button) => {
const result = { id: button.id }; const result = { id: button.id };
const label = computeButton(context, button, "label"); const label = computeButton(composer, button, "label");
result.label = label result.label = 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) { if (ariaLabel) {
result.ariaLabel = I18n.t(ariaLabel); result.ariaLabel = I18n.t(ariaLabel);
} else { } else {
const translatedAriaLabel = computeButton( const translatedAriaLabel = computeButton(
context, composer,
button, button,
"translatedAriaLabel" "translatedAriaLabel"
); );
result.ariaLabel = translatedAriaLabel || result.label; result.ariaLabel = translatedAriaLabel || result.label;
} }
const title = computeButton(context, button, "title"); const title = computeButton(composer, button, "title");
result.title = title result.title = title
? I18n.t(title) ? I18n.t(title)
: computeButton(context, button, "translatedTitle"); : computeButton(composer, button, "translatedTitle");
result.classNames = ( result.classNames = (
computeButton(context, button, "classNames") || [] computeButton(composer, button, "classNames") || []
).join(" "); ).join(" ");
result.icon = computeButton(context, button, "icon"); result.icon = computeButton(composer, button, "icon");
result.disabled = computeButton(context, button, "disabled"); result.disabled = computeButton(composer, button, "disabled");
result.priority = computeButton(context, button, "priority"); result.priority = computeButton(composer, button, "priority");
if (isFunction(button.action)) { if (isFunction(button.action)) {
result.action = () => { result.action = () => {
button.action.apply(context); button.action.apply(composer);
}; };
} else { } else {
const actionName = button.action; const actionName = button.action;
result.action = () => { result.action = () => {
context[actionName](); composer[actionName]();
}; };
} }

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -66,6 +66,10 @@ export default class ChatChannel extends RestModel {
threadsManager = new ChatThreadsManager(getOwner(this)); threadsManager = new ChatThreadsManager(getOwner(this));
messagesManager = new ChatMessagesManager(getOwner(this)); messagesManager = new ChatMessagesManager(getOwner(this));
findIndexOfMessage(message) {
return this.messages.findIndex((m) => m.id === message.id);
}
get messages() { get messages() {
return this.messagesManager.messages; return this.messagesManager.messages;
} }
@ -90,6 +94,10 @@ export default class ChatChannel extends RestModel {
return [this.slugifiedTitle, this.id]; return [this.slugifiedTitle, this.id];
} }
get selectedMessages() {
return this.messages.filter((message) => message.selected);
}
get isDirectMessageChannel() { get isDirectMessageChannel() {
return this.chatableType === CHATABLE_TYPES.directMessageChannel; return this.chatableType === CHATABLE_TYPES.directMessageChannel;
} }
@ -186,6 +194,7 @@ ChatChannel.reopenClass({
this._remapKey(args, "chatable_type", "chatableType"); this._remapKey(args, "chatable_type", "chatableType");
this._remapKey(args, "memberships_count", "membershipsCount"); this._remapKey(args, "memberships_count", "membershipsCount");
this._remapKey(args, "last_message_sent_at", "lastMessageSentAt"); this._remapKey(args, "last_message_sent_at", "lastMessageSentAt");
this._remapKey(args, "threading_enabled", "threadingEnabled");
return this._super(args); return this._super(args);
}, },

View File

@ -2,6 +2,8 @@ import { tracked } from "@glimmer/tracking";
import User from "discourse/models/user"; import User from "discourse/models/user";
import { TrackedArray } from "@ember-compat/tracked-built-ins"; import { TrackedArray } from "@ember-compat/tracked-built-ins";
export const REACTIONS = { add: "add", remove: "remove" };
export default class ChatMessageReaction { export default class ChatMessageReaction {
static create(args = {}) { static create(args = {}) {
return new ChatMessageReaction(args); return new ChatMessageReaction(args);

View File

@ -56,19 +56,20 @@ export default class ChatMessage {
this.firstOfResults = args.firstOfResults; this.firstOfResults = args.firstOfResults;
this.staged = args.staged; this.staged = args.staged;
this.edited = args.edited; this.edited = args.edited;
this.availableFlags = args.available_flags; this.availableFlags = args.availableFlags || args.available_flags;
this.hidden = args.hidden; this.hidden = args.hidden;
this.threadId = args.thread_id; this.threadId = args.threadId || args.thread_id;
this.channelId = args.chat_channel_id; this.channelId = args.channelId || args.chat_channel_id;
this.chatWebhookEvent = args.chat_webhook_event; this.chatWebhookEvent = args.chatWebhookEvent || args.chat_webhook_event;
this.createdAt = args.created_at; this.createdAt = args.createdAt || args.created_at;
this.deletedAt = args.deleted_at; this.deletedAt = args.deletedAt || args.deleted_at;
this.excerpt = args.excerpt; this.excerpt = args.excerpt;
this.reviewableId = args.reviewable_id; this.reviewableId = args.reviewableId || args.reviewable_id;
this.userFlagStatus = args.user_flag_status; this.userFlagStatus = args.userFlagStatus || args.user_flag_status;
this.inReplyTo = args.in_reply_to this.inReplyTo =
? ChatMessage.create(channel, args.in_reply_to) args.inReplyTo || args.in_reply_to
: null; ? ChatMessage.create(channel, args.in_reply_to)
: null;
this.message = args.message; this.message = args.message;
this.cooked = args.cooked || ChatMessage.cookFunction(this.message); this.cooked = args.cooked || ChatMessage.cookFunction(this.message);
this.reactions = this.#initChatMessageReactionModel( this.reactions = this.#initChatMessageReactionModel(

View File

@ -20,12 +20,10 @@ export default class ChatThread {
constructor(args = {}) { constructor(args = {}) {
this.title = args.title; this.title = args.title;
this.id = args.id; this.id = args.id;
this.channelId = args.channel_id;
this.status = args.status; this.status = args.status;
this.originalMessageUser = this.#initUserModel(args.original_message_user); 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 = args.original_message;
this.originalMessage.user = this.originalMessageUser; this.originalMessage.user = this.originalMessageUser;
} }
@ -38,6 +36,10 @@ export default class ChatThread {
this.messagesManager.messages = messages; this.messagesManager.messages = messages;
} }
get selectedMessages() {
return this.messages.filter((message) => message.selected);
}
get escapedTitle() { get escapedTitle() {
return escapeExpression(this.title); return escapeExpression(this.title);
} }

View File

@ -14,6 +14,10 @@ export default function withChatChannel(extendedClass) {
this.controllerFor("chat-channel").set("targetMessageId", null); this.controllerFor("chat-channel").set("targetMessageId", null);
this.chat.activeChannel = model; this.chat.activeChannel = model;
if (!model) {
return this.router.replaceWith("chat");
}
let { messageId, channelTitle } = this.paramsFor(this.routeName); let { messageId, channelTitle } = this.paramsFor(this.routeName);
// messageId query param backwards-compatibility // messageId query param backwards-compatibility

View File

@ -22,6 +22,7 @@ export default class ChatRoute extends DiscourseRoute {
const INTERCEPTABLE_ROUTES = [ const INTERCEPTABLE_ROUTES = [
"chat.channel", "chat.channel",
"chat.channel.thread",
"chat.channel.index", "chat.channel.index",
"chat.channel.near-message", "chat.channel.near-message",
"chat.channel-legacy", "chat.channel-legacy",

View File

@ -296,6 +296,96 @@ export default class ChatApi extends Service {
); );
} }
/**
* Saves a draft for the channel, which includes message contents and uploads.
* @param {number} channelId - The ID of the channel.
* @param {object} data - The draft data, see ChatMessageDraft.toJSON() for more details.
* @returns {Promise}
*/
saveDraft(channelId, data) {
// TODO (martin) Change this to postRequest after moving DraftsController into Api::DraftsController
return ajax("/chat/drafts", {
type: "POST",
data: {
chat_channel_id: channelId,
data,
},
ignoreUnsent: false,
})
.then(() => {
this.chat.markNetworkAsReliable();
})
.catch((error) => {
// we ignore a draft which can't be saved because it's too big
// and only deal with network error for now
if (!error.jqXHR?.responseJSON?.errors?.length) {
this.chat.markNetworkAsUnreliable();
}
});
}
/**
* Adds or removes an emoji reaction for a message inside a channel.
* @param {number} channelId - The ID of the channel.
* @param {number} messageId - The ID of the message to react on.
* @param {string} emoji - The text version of the emoji without colons, e.g. tada
* @param {string} reaction - Either "add" or "remove"
* @returns {Promise}
*/
publishReaction(channelId, messageId, emoji, reactAction) {
// TODO (martin) Not ideal, this should have a chat API controller endpoint.
return ajax(`/chat/${channelId}/react/${messageId}`, {
type: "PUT",
data: {
react_action: reactAction,
emoji,
},
});
}
/**
* Restores a single deleted chat message in a channel.
*
* @param {number} channelId - The ID of the channel for the message being restored.
* @param {number} messageId - The ID of the message being restored.
*/
restoreMessage(channelId, messageId) {
// TODO (martin) Not ideal, this should have a chat API controller endpoint.
return ajax(`/chat/${channelId}/restore/${messageId}`, {
type: "PUT",
});
}
/**
* Rebakes the cooked HTML of a single message in a channel.
*
* @param {number} channelId - The ID of the channel for the message being restored.
* @param {number} messageId - The ID of the message being restored.
*/
rebakeMessage(channelId, messageId) {
// TODO (martin) Not ideal, this should have a chat API controller endpoint.
return ajax(`/chat/${channelId}/${messageId}/rebake`, {
type: "PUT",
});
}
/**
* Saves an edit to a message's contents in a channel.
*
* @param {number} channelId - The ID of the channel for the message being edited.
* @param {number} messageId - The ID of the message being edited.
* @param {object} data - Params of the edit.
* @param {string} data.new_message - The edited content of the message.
* @param {Array<number>} data.upload_ids - The uploads attached to the message after editing.
*/
editMessage(channelId, messageId, data) {
// TODO (martin) Not ideal, this should have a chat API controller endpoint.
return ajax(`/chat/${channelId}/edit/${messageId}`, {
type: "PUT",
data,
});
}
/** /**
* Marks messages for all of a user's chat channel memberships as read. * Marks messages for all of a user's chat channel memberships as read.
* *

View File

@ -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;
}
}

View File

@ -0,0 +1,3 @@
import ChatEmojiPickerManager from "./chat-emoji-picker-manager";
export default class ChatChannelEmojiPickerManager extends ChatEmojiPickerManager {}

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -2,11 +2,21 @@ import Service, { inject as service } from "@ember/service";
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import ChatDrawerDraftChannel from "discourse/plugins/chat/discourse/components/chat-drawer/draft-channel"; import ChatDrawerDraftChannel from "discourse/plugins/chat/discourse/components/chat-drawer/draft-channel";
import ChatDrawerChannel from "discourse/plugins/chat/discourse/components/chat-drawer/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"; import ChatDrawerIndex from "discourse/plugins/chat/discourse/components/chat-drawer/index";
const COMPONENTS_MAP = { const COMPONENTS_MAP = {
"chat.draft-channel": { name: ChatDrawerDraftChannel }, "chat.draft-channel": { name: ChatDrawerDraftChannel },
"chat.channel": { name: ChatDrawerChannel }, "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: { name: ChatDrawerIndex },
"chat.channel.near-message": { "chat.channel.near-message": {
name: ChatDrawerChannel, name: ChatDrawerChannel,

View File

@ -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 { tracked } from "@glimmer/tracking";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
import { bind } from "discourse-common/utils/decorators"; 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 { makeArray } from "discourse-common/lib/helpers";
import { Promise } from "rsvp"; import { Promise } from "rsvp";
import { computed } from "@ember/object";
import { isTesting } from "discourse-common/config/environment"; 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 TRANSITION_TIME = isTesting() ? 0 : 125; // CSS transition time
const DEFAULT_VISIBLE_SECTIONS = ["favorites", "smileys_&_emotion"]; const DEFAULT_VISIBLE_SECTIONS = ["favorites", "smileys_&_emotion"];
const DEFAULT_LAST_SECTION = "favorites"; const DEFAULT_LAST_SECTION = "favorites";
export default class ChatEmojiPickerManager extends Service { export default class ChatEmojiPickerManager extends Service {
@tracked opened = false; @service appEvents;
@tracked closing = false; @tracked closing = false;
@tracked loading = false; @tracked loading = false;
@tracked context = null; @tracked picker = null;
@tracked emojis = null; @tracked emojis = null;
@tracked visibleSections = DEFAULT_VISIBLE_SECTIONS; @tracked visibleSections = DEFAULT_VISIBLE_SECTIONS;
@tracked lastVisibleSection = DEFAULT_LAST_SECTION; @tracked lastVisibleSection = DEFAULT_LAST_SECTION;
@tracked initialFilter = null;
@tracked element = null; @tracked element = null;
@tracked callback;
@computed("emojis.[]", "loading")
get sections() { get sections() {
return !this.loading && this.emojis ? Object.keys(this.emojis) : []; return !this.loading && this.emojis ? Object.keys(this.emojis) : [];
} }
@bind @bind
closeExisting() { closeExisting() {
this.callback = null;
this.opened = false;
this.initialFilter = null;
this.visibleSections = DEFAULT_VISIBLE_SECTIONS; this.visibleSections = DEFAULT_VISIBLE_SECTIONS;
this.lastVisibleSection = DEFAULT_LAST_SECTION; this.lastVisibleSection = DEFAULT_LAST_SECTION;
this.picker = null;
} }
@bind @bind
close() { close() {
this.callback = null;
this.closing = true; this.closing = true;
later(() => { later(() => {
@ -53,9 +46,8 @@ export default class ChatEmojiPickerManager extends Service {
this.visibleSections = DEFAULT_VISIBLE_SECTIONS; this.visibleSections = DEFAULT_VISIBLE_SECTIONS;
this.lastVisibleSection = DEFAULT_LAST_SECTION; this.lastVisibleSection = DEFAULT_LAST_SECTION;
this.initialFilter = null;
this.closing = false; this.closing = false;
this.opened = false; this.picker = null;
}, TRANSITION_TIME); }, TRANSITION_TIME);
} }
@ -65,80 +57,23 @@ export default class ChatEmojiPickerManager extends Service {
.uniq(); .uniq();
} }
didSelectEmoji(emoji) { open(picker) {
this?.callback(emoji); this.loadEmojis();
this.callback = null;
this.close();
}
startFromMessageReactionList(message, callback, options = {}) { if (this.picker) {
const trigger = document.querySelector( if (this.picker.trigger === picker.trigger) {
`.chat-message-container[data-id="${message.id}"] .chat-message-react-btn` this.closeExisting();
); } else {
this.startFromMessage(callback, trigger, options); this.closeExisting();
} this.picker = picker;
}
startFromMessageActions(message, callback, options = {}) { } else {
const trigger = document.querySelector( this.picker = picker;
`.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() },
},
},
],
});
});
} }
} }
startFromComposer(callback, options = { filter: null }) { @action
this.initialFilter = options.filter; loadEmojis() {
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() {
if (this.emojis) { if (this.emojis) {
return Promise.resolve(); return Promise.resolve();
} }

View File

@ -9,6 +9,7 @@ import { and } from "@ember/object/computed";
import { computed } from "@ember/object"; import { computed } from "@ember/object";
import discourseLater from "discourse-common/lib/later"; import discourseLater from "discourse-common/lib/later";
import ChatMessageDraft from "discourse/plugins/chat/discourse/models/chat-message-draft"; 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 = { const CHAT_ONLINE_OPTIONS = {
userUnseenTime: 300000, // 5 minutes seconds with no interaction userUnseenTime: 300000, // 5 minutes seconds with no interaction
@ -26,6 +27,9 @@ export default class Chat extends Service {
@service site; @service site;
@service chatChannelsManager; @service chatChannelsManager;
@service chatChannelPane;
@service chatChannelThreadPane;
@tracked activeChannel = null; @tracked activeChannel = null;
cook = null; cook = null;
@ -35,6 +39,8 @@ export default class Chat extends Service {
@and("currentUser.has_chat_enabled", "siteSettings.chat_enabled") userCanChat; @and("currentUser.has_chat_enabled", "siteSettings.chat_enabled") userCanChat;
@tracked _activeMessage = null;
@computed("currentUser.staff", "currentUser.groups.[]") @computed("currentUser.staff", "currentUser.groups.[]")
get userCanDirectMessage() { get userCanDirectMessage() {
if (!this.currentUser) { 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() { init() {
super.init(...arguments); super.init(...arguments);

View File

@ -1,7 +0,0 @@
{{#if
(and this.chatEmojiPickerManager.opened this.chatEmojiPickerManager.element)
}}
{{#in-element this.chatEmojiPickerManager.element}}
<ChatEmojiPicker />
{{/in-element}}
{{/if}}

View File

@ -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);
},
};

View File

@ -5,7 +5,7 @@ $float-height: 530px;
--full-page-border-radius: 12px; --full-page-border-radius: 12px;
--full-page-sidebar-width: 275px; --full-page-sidebar-width: 275px;
--channel-list-avatar-size: 30px; --channel-list-avatar-size: 30px;
--chat-header-offset: 65px; --chat-header-offset: 50px;
} }
.chat-message-move-to-channel-modal-modal { .chat-message-move-to-channel-modal-modal {
@ -289,9 +289,6 @@ $float-height: 530px;
z-index: 1; z-index: 1;
margin: 0 1px 0 0; margin: 0 1px 0 0;
will-change: transform; will-change: transform;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
@include chat-scrollbar(); @include chat-scrollbar();
.join-channel-btn.in-float { .join-channel-btn.in-float {

View File

@ -1,4 +1,4 @@
.chat-composer-dropdown { [data-theme="chat-composer-drodown"] {
margin-left: 0.2rem; margin-left: 0.2rem;
.tippy-content { .tippy-content {
@ -7,7 +7,7 @@
} }
.chat-composer-dropdown__trigger-btn { .chat-composer-dropdown__trigger-btn {
padding: 5px; padding: 5px !important; // overwrite ios rule
border-radius: 100%; border-radius: 100%;
background: var(--primary-med-or-secondary-high); background: var(--primary-med-or-secondary-high);
border: 1px solid transparent; border: 1px solid transparent;
@ -30,20 +30,11 @@
} }
.chat-composer-dropdown__list { .chat-composer-dropdown__list {
padding: 0;
margin: 0; margin: 0;
list-style: none; list-style: none;
padding: 0.5rem; padding: 0.5rem;
} }
.chat-composer-dropdown__item {
padding-bottom: 0.25rem;
&:last-child {
padding-bottom: 0;
}
}
.chat-composer-dropdown__action-btn { .chat-composer-dropdown__action-btn {
background: none; background: none;
width: 100%; width: 100%;

View File

@ -103,6 +103,8 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
@include chat-scrollbar();
} }
&__unreliable-network { &__unreliable-network {

View File

@ -54,9 +54,11 @@
height: 100%; height: 100%;
overflow-y: scroll; overflow-y: scroll;
text-transform: capitalize; text-transform: capitalize;
@include chat-scrollbar();
margin: 1px;
} }
&__no-reults { &__no-results {
padding: 1em; padding: 1em;
} }
@ -197,12 +199,13 @@
} }
} }
.chat-message-emoji-picker-anchor { .chat-channel-message-emoji-picker-connector {
z-index: z("header") + 1; position: relative;
.chat-emoji-picker { .chat-emoji-picker {
border: 1px solid var(--primary-low); border: 1px solid var(--primary-low);
width: 320px; width: 320px;
z-index: z("header") + 1;
.emoji { .emoji {
width: 22px; 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;
}
}
}

View File

@ -11,7 +11,7 @@
.chat-message-actions-container { .chat-message-actions-container {
@include unselectable; @include unselectable;
position: relative; z-index: z("dropdown") - 1;
} }
.chat-message-actions { .chat-message-actions {
@ -47,6 +47,10 @@
width: 2.5em; width: 2.5em;
transition: background 0.2s, border-color 0.2s; transition: background 0.2s, border-color 0.2s;
> * {
pointer-events: none;
}
&:focus { &:focus {
.d-icon { .d-icon {
color: var(--primary); color: var(--primary);

View File

@ -6,7 +6,7 @@
width: var(--message-left-width); width: var(--message-left-width);
} }
.chat-message-container.is-hovered .chat-message-left-gutter { .chat-message-container:hover .chat-message-left-gutter {
.chat-time { .chat-time {
color: var(--secondary-mediumy); color: var(--secondary-mediumy);
} }

View File

@ -131,6 +131,10 @@
background: none; background: none;
border: none; border: none;
> * {
pointer-events: none;
}
.d-icon { .d-icon {
color: var(--primary-high); color: var(--primary-high);
} }
@ -181,26 +185,44 @@
} }
.chat-messages-container { .chat-messages-container {
.not-mobile-device & .chat-message:hover, .chat-message {
.chat-message.chat-message-selected { &.chat-message-bookmarked {
background: var(--primary-very-low); background: var(--highlight-bg);
}
.chat-message.chat-message-bookmarked {
background: var(--highlight-bg);
&:hover {
background: var(--highlight-medium);
} }
}
.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 { .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; 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), .has-full-page-chat .chat-message .onebox:not(img),
.chat-drawer-container .chat-message .onebox { .chat-drawer-container .chat-message .onebox {
margin: 0.5em 0; margin: 0.5em 0;

View File

@ -14,7 +14,7 @@
grid-area: threads; grid-area: threads;
min-height: 100%; min-height: 100%;
box-sizing: border-box; box-sizing: border-box;
border-left: 1px solid var(--primary-medium); border-left: 1px solid var(--primary-low);
&__list { &__list {
flex-grow: 1; flex-grow: 1;

View File

@ -1,11 +1,31 @@
.chat-thread { .chat-thread {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-block: 1rem;
height: 100%; height: 100%;
box-sizing: border-box;
&__header { &__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 { &__close {
@ -15,41 +35,4 @@
color: var(--primary-medium); 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;
}
} }

View File

@ -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;
}
}
}

View File

@ -5,3 +5,4 @@
@import "chat-message-actions"; @import "chat-message-actions";
@import "chat-message"; @import "chat-message";
@import "chat-selection-manager"; @import "chat-selection-manager";
@import "chat-emoji-picker";

View File

@ -532,8 +532,9 @@ en:
search_placeholder: "Search by emoji name and alias..." search_placeholder: "Search by emoji name and alias..."
no_results: "No results" no_results: "No results"
thread:
label: Thread
threads: threads:
op_said: "OP said:"
started_by: "Started by" started_by: "Started by"
open: "Open Thread" open: "Open Thread"

View File

@ -75,13 +75,13 @@ module PageObjects
def select_message(message) def select_message(message)
hover_message(message) hover_message(message)
click_more_button click_more_button
find("[data-value='selectMessage']").click find("[data-value='select']").click
end end
def delete_message(message) def delete_message(message)
hover_message(message) hover_message(message)
click_more_button click_more_button
find("[data-value='deleteMessage']").click find("[data-value='delete']").click
end end
def open_edit_message(message) def open_edit_message(message)

View File

@ -30,6 +30,10 @@ module PageObjects
def maximize def maximize
find("#{VISIBLE_DRAWER} .chat-drawer-header__full-screen-btn").click find("#{VISIBLE_DRAWER} .chat-drawer-header__full-screen-btn").click
end end
def has_open_thread?(thread)
has_css?("#{VISIBLE_DRAWER} .chat-thread[data-id='#{thread.id}']")
end
end end
end end
end end

View File

@ -2,6 +2,7 @@
RSpec.describe "React to message", type: :system, js: true do RSpec.describe "React to message", type: :system, js: true do
fab!(:current_user) { Fabricate(:user) } fab!(:current_user) { Fabricate(:user) }
fab!(:other_user) { Fabricate(:user) }
fab!(:category_channel_1) { Fabricate(:category_channel) } fab!(:category_channel_1) { Fabricate(:category_channel) }
fab!(:message_1) { Fabricate(:chat_message, chat_channel: category_channel_1) } 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 before do
chat_system_bootstrap chat_system_bootstrap
category_channel_1.add(current_user) category_channel_1.add(current_user)
category_channel_1.add(other_user)
end end
context "when other user has reacted" do context "when other user has reacted" do
fab!(:reaction_1) 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, message_id: message_1.id,
react_action: :add, react_action: :add,
emoji: "female_detective", emoji: "female_detective",
@ -48,7 +50,7 @@ RSpec.describe "React to message", type: :system, js: true do
context "when current user reacts" do context "when current user reacts" do
fab!(:reaction_1) 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, message_id: message_1.id,
react_action: :add, react_action: :add,
emoji: "female_detective", emoji: "female_detective",
@ -62,14 +64,14 @@ RSpec.describe "React to message", type: :system, js: true do
chat.visit_channel(category_channel_1) chat.visit_channel(category_channel_1)
channel.hover_message(message_1) channel.hover_message(message_1)
find(".chat-message-react-btn").click 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 end
context "when current user has multiple sessions" do context "when current user has multiple sessions" do
it "adds reaction on each session" do it "adds reaction on each session" do
reaction = OpenStruct.new(emoji: "nerd_face") reaction = OpenStruct.new(emoji: "grimacing")
using_session(:tab_1) do using_session(:tab_1) do
sign_in(current_user) sign_in(current_user)

View File

@ -7,6 +7,7 @@ describe "Single thread in side panel", type: :system, js: true do
let(:channel_page) { PageObjects::Pages::ChatChannel.new } let(:channel_page) { PageObjects::Pages::ChatChannel.new }
let(:side_panel) { PageObjects::Pages::ChatSidePanel.new } let(:side_panel) { PageObjects::Pages::ChatSidePanel.new }
let(:open_thread) { PageObjects::Pages::ChatThread.new } let(:open_thread) { PageObjects::Pages::ChatThread.new }
let(:chat_drawer_page) { PageObjects::Pages::ChatDrawer.new }
before do before do
chat_system_bootstrap(current_user, [channel]) 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 } 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 it "opens the side panel for a single thread from the message actions menu" do
chat_page.visit_channel(channel) chat_page.visit_channel(channel)
channel_page.open_message_thread(thread.original_message) channel_page.open_message_thread(thread.original_message)
expect(side_panel).to have_open_thread(thread) expect(side_panel).to have_open_thread(thread)
end 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) chat_page.visit_channel(channel)
channel_page.open_message_thread(thread.original_message) channel_page.open_message_thread(thread.original_message)
expect(open_thread).to have_header_content(thread.excerpt) expect(open_thread).to have_header_content(thread.excerpt)
end 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) chat_page.visit_channel(channel)
channel_page.open_message_thread(thread.original_message) channel_page.open_message_thread(thread.original_message)
expect(open_thread.omu).to have_css(".chat-user-avatar img.avatar") expect(open_thread.omu).to have_css(".chat-user-avatar img.avatar")

View File

@ -21,7 +21,7 @@ RSpec.describe "Quoting chat message transcripts", type: :system, js: true do
chat_channel_page.message_by_id(message.id).hover chat_channel_page.message_by_id(message.id).hover
expect(page).to have_css(".chat-message-actions .more-buttons") expect(page).to have_css(".chat-message-actions .more-buttons")
find(".chat-message-actions .more-buttons").click 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
end end
@ -209,7 +209,7 @@ RSpec.describe "Quoting chat message transcripts", type: :system, js: true do
mobile: true do mobile: true do
chat_page.visit_channel(chat_channel_1) 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") click_selection_button("quote")
expect(topic_page).to have_expanded_composer expect(topic_page).to have_expanded_composer

View File

@ -4,6 +4,7 @@ import hbs from "htmlbars-inline-precompile";
import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel"; import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel";
import { module, test } from "qunit"; import { module, test } from "qunit";
import { render } from "@ember/test-helpers"; import { render } from "@ember/test-helpers";
import pretender from "discourse/tests/helpers/create-pretender";
module( module(
"Discourse Chat | Component | chat-composer placeholder", "Discourse Chat | Component | chat-composer placeholder",
@ -11,6 +12,8 @@ module(
setupRenderingTest(hooks); setupRenderingTest(hooks);
test("direct message to self shows Jot something down", async function (assert) { test("direct message to self shows Jot something down", async function (assert) {
pretender.get("/chat/emojis.json", () => [200, [], {}]);
this.currentUser.set("id", 1); this.currentUser.set("id", 1);
this.set( this.set(
"chatChannel", "chatChannel",
@ -31,6 +34,8 @@ module(
}); });
test("direct message to multiple folks shows their names", async function (assert) { test("direct message to multiple folks shows their names", async function (assert) {
pretender.get("/chat/emojis.json", () => [200, [], {}]);
this.set( this.set(
"chatChannel", "chatChannel",
ChatChannel.create({ ChatChannel.create({
@ -54,6 +59,8 @@ module(
}); });
test("message to channel shows send message to channel name", async function (assert) { test("message to channel shows send message to channel name", async function (assert) {
pretender.get("/chat/emojis.json", () => [200, [], {}]);
this.set( this.set(
"chatChannel", "chatChannel",
ChatChannel.create({ ChatChannel.create({

View File

@ -68,7 +68,7 @@ module("Discourse Chat | Component | chat-emoji-picker", function (hooks) {
this.chatEmojiPickerManager = this.container.lookup( this.chatEmojiPickerManager = this.container.lookup(
"service:chat-emoji-picker-manager" "service:chat-emoji-picker-manager"
); );
this.chatEmojiPickerManager.startFromComposer(() => {}); this.chatEmojiPickerManager.open(() => {});
this.chatEmojiPickerManager.addVisibleSections([ this.chatEmojiPickerManager.addVisibleSections([
"smileys_&_emotion", "smileys_&_emotion",
"people_&_body", "people_&_body",
@ -164,10 +164,13 @@ module("Discourse Chat | Component | chat-emoji-picker", function (hooks) {
test("When selecting an emoji", async function (assert) { test("When selecting an emoji", async function (assert) {
let selection; let selection;
this.chatEmojiPickerManager.didSelectEmoji = (emoji) => { this.didSelectEmoji = (emoji) => {
selection = emoji; selection = emoji;
}; };
await render(hbs`<ChatEmojiPicker />`);
await render(
hbs`<ChatEmojiPicker @didSelectEmoji={{this.didSelectEmoji}} />`
);
await click('img.emoji[data-emoji="grinning"]'); await click('img.emoji[data-emoji="grinning"]');
assert.strictEqual(selection, "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) { test("When selecting a toned an emoji", async function (assert) {
let selection; let selection;
this.chatEmojiPickerManager.didSelectEmoji = (emoji) => { this.didSelectEmoji = (emoji) => {
selection = emoji; selection = emoji;
}; };
await render(hbs`<ChatEmojiPicker />`);
await render(
hbs`<ChatEmojiPicker @didSelectEmoji={{this.didSelectEmoji}} />`
);
this.emojiReactionStore.diversity = 1; this.emojiReactionStore.diversity = 1;
await click('img.emoji[data-emoji="man_rowing_boat"]'); await click('img.emoji[data-emoji="man_rowing_boat"]');

View File

@ -55,7 +55,7 @@ module("Discourse Chat | Component | chat-message-reaction", function (hooks) {
}); });
await render(hbs` await render(hbs`
<ChatMessageReaction class="show" @reaction={{hash emoji="heart" count=this.count}} @react={{this.react}} /> <ChatMessageReaction class="show" @reaction={{hash emoji="heart" count=this.count}} @onReaction={{this.react}} />
`); `);
assert.false(exists(".chat-message-reaction .count")); assert.false(exists(".chat-message-reaction .count"));

View File

@ -21,7 +21,6 @@ module("Discourse Chat | Component | chat-message", function (hooks) {
unread_count: 0, unread_count: 0,
muted: false, muted: false,
}, },
canInteractWithChat: true,
canDeleteSelf: true, canDeleteSelf: true,
canDeleteOthers: true, canDeleteOthers: true,
canFlag: true, canFlag: true,
@ -46,14 +45,7 @@ module("Discourse Chat | Component | chat-message", function (hooks) {
) )
), ),
chatChannel, chatChannel,
setReplyTo: () => {},
replyMessageClicked: () => {},
editButtonClicked: () => {},
afterExpand: () => {}, afterExpand: () => {},
selectingMessages: false,
onStartSelectingMessages: () => {},
onSelectMessage: () => {},
bulkSelectMessages: () => {},
onHoverMessage: () => {}, onHoverMessage: () => {},
messageDidEnterViewport: () => {}, messageDidEnterViewport: () => {},
messageDidLeaveViewport: () => {}, messageDidLeaveViewport: () => {},
@ -63,16 +55,7 @@ module("Discourse Chat | Component | chat-message", function (hooks) {
const template = hbs` const template = hbs`
<ChatMessage <ChatMessage
@message={{this.message}} @message={{this.message}}
@canInteractWithChat={{this.canInteractWithChat}}
@channel={{this.chatChannel}} @channel={{this.chatChannel}}
@setReplyTo={{this.setReplyTo}}
@replyMessageClicked={{this.replyMessageClicked}}
@editButtonClicked={{this.editButtonClicked}}
@selectingMessages={{this.selectingMessages}}
@onStartSelectingMessages={{this.onStartSelectingMessages}}
@onSelectMessage={{this.onSelectMessage}}
@bulkSelectMessages={{this.bulkSelectMessages}}
@onHoverMessage={{this.onHoverMessage}}
@messageDidEnterViewport={{this.messageDidEnterViewport}} @messageDidEnterViewport={{this.messageDidEnterViewport}}
@messageDidLeaveViewport={{this.messageDidLeaveViewport}} @messageDidLeaveViewport={{this.messageDidLeaveViewport}}
/> />

View File

@ -22,46 +22,6 @@ module(
this.manager.close(); 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) { test("addVisibleSections", async function (assert) {
this.manager.addVisibleSections(["favorites", "objects"]); this.manager.addVisibleSections(["favorites", "objects"]);
@ -75,7 +35,7 @@ module(
test("sections", async function (assert) { test("sections", async function (assert) {
assert.deepEqual(this.manager.sections, []); assert.deepEqual(this.manager.sections, []);
this.manager.startFromComposer(() => {}); this.manager.open({});
assert.deepEqual(this.manager.sections, []); assert.deepEqual(this.manager.sections, []);
@ -84,14 +44,12 @@ module(
assert.deepEqual(this.manager.sections, ["favorites"]); assert.deepEqual(this.manager.sections, ["favorites"]);
}); });
test("startFromComposer", async function (assert) { test("open", async function (assert) {
const callback = () => {}; this.manager.open({ context: "chat-composer" });
this.manager.startFromComposer(callback);
assert.ok(this.manager.loading); assert.ok(this.manager.loading);
assert.ok(this.manager.opened); assert.ok(this.manager.picker);
assert.strictEqual(this.manager.context, "chat-composer"); assert.strictEqual(this.manager.picker.context, "chat-composer");
assert.strictEqual(this.manager.callback, callback);
assert.deepEqual(this.manager.visibleSections, [ assert.deepEqual(this.manager.visibleSections, [
"favorites", "favorites",
"smileys_&_emotion", "smileys_&_emotion",
@ -104,28 +62,16 @@ module(
assert.strictEqual(this.manager.loading, false); 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) { test("closeExisting", async function (assert) {
const callback = () => { this.manager.open({ context: "channel-composer", trigger: "foo" });
return;
};
this.manager.startFromComposer(() => {});
this.manager.addVisibleSections("objects"); this.manager.addVisibleSections("objects");
this.manager.lastVisibleSection = "objects"; this.manager.lastVisibleSection = "objects";
this.manager.startFromComposer(callback); this.manager.open({ context: "thread-composer", trigger: "bar" });
assert.strictEqual( assert.strictEqual(
this.manager.callback, this.manager.picker.context,
callback, "thread-composer",
"it resets the callback to latest picker" "it resets the picker to latest picker"
); );
assert.deepEqual( assert.deepEqual(
this.manager.visibleSections, 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) { test("close", async function (assert) {
this.manager.startFromComposer(() => {}); this.manager.open({ context: "channel-composer" });
assert.ok(this.manager.opened); assert.ok(this.manager.picker);
assert.ok(this.manager.callback);
this.manager.addVisibleSections("objects"); this.manager.addVisibleSections("objects");
this.manager.lastVisibleSection = "objects"; this.manager.lastVisibleSection = "objects";
this.manager.close(); this.manager.close();
assert.notOk(this.manager.callback);
assert.ok(this.manager.closing); assert.ok(this.manager.closing);
assert.ok(this.manager.opened); assert.ok(this.manager.picker);
await settled(); await settled();
assert.notOk(this.manager.opened); assert.notOk(this.manager.picker);
assert.notOk(this.manager.closing); assert.notOk(this.manager.closing);
assert.deepEqual( assert.deepEqual(
this.manager.visibleSections, this.manager.visibleSections,