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:
parent
cee06bdc77
commit
ea548292bc
plugins/chat
assets
javascripts/discourse
components
chat-channel-message-emoji-picker.hbschat-channel-message-emoji-picker.jschat-composer-dropdown.hbschat-composer-dropdown.jschat-composer.hbschat-composer.jschat-drawer.hbschat-drawer.js
chat-drawer
chat-emoji-picker.hbschat-emoji-picker.jschat-live-pane.hbschat-live-pane.jschat-message-actions-desktop.hbschat-message-actions-desktop.jschat-message-actions-mobile.hbschat-message-actions-mobile.jschat-message-in-reply-to-indicator.jschat-message-reaction.jschat-message.hbschat-message.jschat-selection-manager.jschat-thread.hbschat-thread.jsconnectors/below-footer
chat-channel-message-emoji-picker-connector.hbschat-message-actions-desktop-outlet.hbschat-message-actions-mobile-outlet.hbs
initializers
lib
models
routes
services
chat-api.jschat-channel-composer.jschat-channel-emoji-picker-manager.jschat-channel-pane.jschat-channel-thread-composer.jschat-channel-thread-pane.jschat-drawer-router.jschat-emoji-picker-manager.jschat.js
templates/connectors/below-footer
stylesheets
config/locales
spec/system
test/javascripts
|
@ -0,0 +1,7 @@
|
|||
<ChatEmojiPicker
|
||||
@context="chat-channel-message"
|
||||
@didInsert={{this.didInsert}}
|
||||
@willDestroy={{this.willDestroy}}
|
||||
@didSelectEmoji={{this.didSelectEmoji}}
|
||||
@class="hidden"
|
||||
/>
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -1,26 +1,32 @@
|
|||
{{#if @buttons.length}}
|
||||
<DPopover
|
||||
@class="chat-composer-dropdown"
|
||||
@options={{hash arrow=null}}
|
||||
as |state|
|
||||
>
|
||||
<FlatButton
|
||||
@disabled={{@isDisabled}}
|
||||
@class="chat-composer-dropdown__trigger-btn d-popover-trigger"
|
||||
@title="chat.composer.toggle_toolbar"
|
||||
@icon={{if state.isExpanded "times" "plus"}}
|
||||
/>
|
||||
<ul class="chat-composer-dropdown__list">
|
||||
<DButton
|
||||
@disabled={{@isDisabled}}
|
||||
@class="chat-composer-dropdown__trigger-btn btn-flat btn-icon"
|
||||
@title="chat.composer.toggle_toolbar"
|
||||
@icon={{if @hasActivePanel "times" "plus"}}
|
||||
@action={{this.toggleExpand}}
|
||||
{{did-insert this.setupTrigger}}
|
||||
/>
|
||||
|
||||
{{#if this.isExpanded}}
|
||||
<ul
|
||||
class="chat-composer-dropdown__list"
|
||||
{{did-insert this.setupPanel}}
|
||||
{{will-destroy this.teardownPanel}}
|
||||
>
|
||||
{{#each @buttons as |button|}}
|
||||
<li class="chat-composer-dropdown__item {{button.id}}">
|
||||
<li class={{concat-class "chat-composer-dropdown__item" button.id}}>
|
||||
<DButton
|
||||
@class={{concat "chat-composer-dropdown__action-btn " button.id}}
|
||||
@class={{concat-class
|
||||
"chat-composer-dropdown__action-btn"
|
||||
button.id
|
||||
}}
|
||||
@icon={{button.icon}}
|
||||
@action={{button.action}}
|
||||
@action={{(fn this.onButtonClick button)}}
|
||||
@label={{button.label}}
|
||||
/>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</DPopover>
|
||||
{{/if}}
|
||||
{{/if}}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -1,45 +1,35 @@
|
|||
{{#if this.replyToMsg}}
|
||||
{{#if this.composerService.replyToMsg}}
|
||||
<ChatComposerMessageDetails
|
||||
@message={{this.replyToMsg}}
|
||||
@message={{this.composerService.replyToMsg}}
|
||||
@icon="reply"
|
||||
@action={{action "cancelReplyTo"}}
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.editingMessage}}
|
||||
{{#if this.composerService.editingMessage}}
|
||||
<ChatComposerMessageDetails
|
||||
@message={{this.editingMessage}}
|
||||
@message={{this.composerService.editingMessage}}
|
||||
@icon="pencil-alt"
|
||||
@action={{action "cancelEditing"}}
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
<div class="chat-composer-emoji-picker-anchor"></div>
|
||||
|
||||
<div
|
||||
role="region"
|
||||
aria-label={{i18n "chat.aria_roles.composer"}}
|
||||
class="chat-composer {{if this.disableComposer 'is-disabled'}}"
|
||||
{{did-update this.updateEditingMessage this.composerService.editingMessage}}
|
||||
>
|
||||
{{#if
|
||||
(and
|
||||
this.chatEmojiPickerManager.opened
|
||||
(eq this.chatEmojiPickerManager.context "chat-composer")
|
||||
)
|
||||
}}
|
||||
<DButton
|
||||
@icon="times"
|
||||
@action={{this.chatEmojiPickerManager.close}}
|
||||
@class="chat-composer__close-emoji-picker-btn btn-flat"
|
||||
/>
|
||||
{{else}}
|
||||
{{#unless this.disableComposer}}
|
||||
<ChatComposerDropdown
|
||||
@buttons={{this.dropdownButtons}}
|
||||
@isDisabled={{this.disableComposer}}
|
||||
/>
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
|
||||
<ChatComposerDropdown
|
||||
@buttons={{this.dropdownButtons}}
|
||||
@isDisabled={{this.disableComposer}}
|
||||
@hasActivePanel={{and
|
||||
this.chatEmojiPickerManager.picker
|
||||
(eq this.chatEmojiPickerManager.picker.context @context)
|
||||
}}
|
||||
@onCloseActivePanel={{this.chatEmojiPickerManager.close}}
|
||||
/>
|
||||
|
||||
<DTextarea
|
||||
@value={{readonly this.value}}
|
||||
|
@ -82,7 +72,7 @@
|
|||
@onUploadChanged={{this.uploadsChanged}}
|
||||
@existingUploads={{or
|
||||
this.chatChannel.draft.uploads
|
||||
this.editingMessage.uploads
|
||||
this.composerService.editingMessage.uploads
|
||||
}}
|
||||
/>
|
||||
{{/if}}
|
||||
|
@ -91,4 +81,9 @@
|
|||
<div class="chat-replying-indicator-container">
|
||||
<ChatReplyingIndicator @chatChannel={{this.chatChannel}} />
|
||||
</div>
|
||||
{{/unless}}
|
||||
{{/unless}}
|
||||
|
||||
<ChatEmojiPicker
|
||||
@context={{@context}}
|
||||
@didSelectEmoji={{this.didSelectEmoji}}
|
||||
/>
|
|
@ -15,7 +15,7 @@ import { findRawTemplate } from "discourse-common/lib/raw-templates";
|
|||
import { emojiSearch, isSkinTonableEmoji } from "pretty-text/emoji";
|
||||
import { emojiUrlFor } from "discourse/lib/text";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { readOnly, reads } from "@ember/object/computed";
|
||||
import { reads } from "@ember/object/computed";
|
||||
import { SKIP } from "discourse/lib/autocomplete";
|
||||
import { Promise } from "rsvp";
|
||||
import { translations } from "pretty-text/emoji/data";
|
||||
|
@ -32,12 +32,9 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
chat: service(),
|
||||
classNames: ["chat-composer-container"],
|
||||
classNameBindings: ["emojiPickerVisible:with-emoji-picker"],
|
||||
userSilenced: readOnly("chatChannel.userSilenced"),
|
||||
chatEmojiReactionStore: service("chat-emoji-reaction-store"),
|
||||
chatEmojiPickerManager: service("chat-emoji-picker-manager"),
|
||||
chatStateManager: service("chat-state-manager"),
|
||||
editingMessage: null,
|
||||
onValueChange: null,
|
||||
timer: null,
|
||||
value: "",
|
||||
inProgressUploads: null,
|
||||
|
@ -50,12 +47,12 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
|
||||
@discourseComputed(...chatComposerButtonsDependentKeys())
|
||||
inlineButtons() {
|
||||
return chatComposerButtons(this, "inline");
|
||||
return chatComposerButtons(this, "inline", this.context);
|
||||
},
|
||||
|
||||
@discourseComputed(...chatComposerButtonsDependentKeys())
|
||||
dropdownButtons() {
|
||||
return chatComposerButtons(this, "dropdown");
|
||||
return chatComposerButtons(this, "dropdown", this.context);
|
||||
},
|
||||
|
||||
@discourseComputed("chatEmojiPickerManager.{opened,context}")
|
||||
|
@ -71,7 +68,6 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
init() {
|
||||
this._super(...arguments);
|
||||
|
||||
this.appEvents.on("chat-composer:reply-to-set", this, "_replyToMsgChanged");
|
||||
this.appEvents.on(
|
||||
"upload-mixin:chat-composer-uploader:in-progress-uploads",
|
||||
this,
|
||||
|
@ -82,6 +78,10 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
inProgressUploads: [],
|
||||
_uploads: [],
|
||||
});
|
||||
|
||||
this.composerService?.registerFocusHandler(() => {
|
||||
this._focusTextArea();
|
||||
});
|
||||
},
|
||||
|
||||
didInsertElement() {
|
||||
|
@ -92,7 +92,6 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
this._applyUserAutocomplete(this._$textarea);
|
||||
this._applyCategoryHashtagAutocomplete(this._$textarea);
|
||||
this._applyEmojiAutocomplete(this._$textarea);
|
||||
this.appEvents.on("chat:focus-composer", this, "_focusTextArea");
|
||||
this.appEvents.on("chat:insert-text", this, "insertText");
|
||||
this._focusTextArea();
|
||||
|
||||
|
@ -134,11 +133,6 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
willDestroyElement() {
|
||||
this._super(...arguments);
|
||||
|
||||
this.appEvents.off(
|
||||
"chat-composer:reply-to-set",
|
||||
this,
|
||||
"_replyToMsgChanged"
|
||||
);
|
||||
this.appEvents.off(
|
||||
"upload-mixin:chat-composer-uploader:in-progress-uploads",
|
||||
this,
|
||||
|
@ -147,7 +141,6 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
|
||||
cancel(this.timer);
|
||||
|
||||
this.appEvents.off("chat:focus-composer", this, "_focusTextArea");
|
||||
this.appEvents.off("chat:insert-text", this, "insertText");
|
||||
this.appEvents.off("chat:modify-selection", this, "_modifySelection");
|
||||
this.appEvents.off(
|
||||
|
@ -192,19 +185,19 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
if (
|
||||
event.key === "ArrowUp" &&
|
||||
this._messageIsEmpty() &&
|
||||
!this.editingMessage
|
||||
!this.composerService?.editingMessage
|
||||
) {
|
||||
event.preventDefault();
|
||||
this.onEditLastMessageRequested();
|
||||
this.paneService?.editLastMessageRequested();
|
||||
}
|
||||
|
||||
if (event.keyCode === 27) {
|
||||
// keyCode for 'Escape'
|
||||
if (this.replyToMsg) {
|
||||
if (this.composerService?.replyToMsg) {
|
||||
this.set("value", "");
|
||||
this._replyToMsgChanged(null);
|
||||
this.composerService?.setReplyTo(null);
|
||||
return false;
|
||||
} else if (this.editingMessage) {
|
||||
} else if (this.composerService?.editingMessage) {
|
||||
this.set("value", "");
|
||||
this.cancelEditing();
|
||||
return false;
|
||||
|
@ -218,34 +211,36 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
this._super(...arguments);
|
||||
|
||||
if (
|
||||
!this.editingMessage &&
|
||||
!this.composerService?.editingMessage &&
|
||||
this.chatChannel?.draft &&
|
||||
this.chatChannel?.canModifyMessages(this.currentUser)
|
||||
) {
|
||||
// uses uploads from draft here...
|
||||
this.setProperties({
|
||||
value: this.chatChannel.draft.message,
|
||||
replyToMsg: this.chatChannel.draft.replyToMsg,
|
||||
});
|
||||
this.set("value", this.chatChannel.draft.message);
|
||||
this.composerService?.setReplyTo(this.chatChannel.draft.replyToMsg);
|
||||
|
||||
this._captureMentions();
|
||||
this._syncUploads(this.chatChannel.draft.uploads);
|
||||
this.setInReplyToMsg(this.chatChannel.draft.replyToMsg);
|
||||
}
|
||||
|
||||
if (this.editingMessage && !this.loading) {
|
||||
this.setProperties({
|
||||
replyToMsg: null,
|
||||
value: this.editingMessage.message,
|
||||
});
|
||||
|
||||
this._syncUploads(this.editingMessage.uploads);
|
||||
this._focusTextArea({ ensureAtEnd: true, resizeTextarea: false });
|
||||
}
|
||||
|
||||
this.resizeTextarea();
|
||||
},
|
||||
|
||||
@action
|
||||
updateEditingMessage() {
|
||||
if (
|
||||
this.composerService?.editingMessage &&
|
||||
!this.paneService?.sendingLoading
|
||||
) {
|
||||
this.set("value", this.composerService?.editingMessage.message);
|
||||
|
||||
this.composerService?.setReplyTo(null);
|
||||
|
||||
this._syncUploads(this.composerService?.editingMessage.uploads);
|
||||
this._focusTextArea({ ensureAtEnd: true, resizeTextarea: false });
|
||||
}
|
||||
},
|
||||
|
||||
// the chat-composer needs to be able to set the internal list of uploads
|
||||
// for chat-composer-uploads to preload in existing uploads for drafts
|
||||
// and for when messages are being edited.
|
||||
|
@ -281,11 +276,6 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
});
|
||||
},
|
||||
|
||||
_replyToMsgChanged(replyToMsg) {
|
||||
this.set("replyToMsg", replyToMsg);
|
||||
this.onValueChange?.({ replyToMsg });
|
||||
},
|
||||
|
||||
@action
|
||||
onTextareaInput(value) {
|
||||
this.set("value", value);
|
||||
|
@ -299,7 +289,7 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
|
||||
@bind
|
||||
_handleTextareaInput() {
|
||||
this.onValueChange?.({ value: this.value });
|
||||
this.composerService?.onComposerValueChange?.({ value: this.value });
|
||||
},
|
||||
|
||||
@bind
|
||||
|
@ -324,6 +314,18 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
const code = `:${emoji}:`;
|
||||
this.chatEmojiReactionStore.track(code);
|
||||
this.addText(this.getSelected(), code);
|
||||
|
||||
if (this.site.desktopView) {
|
||||
this._focusTextArea();
|
||||
} else {
|
||||
this.chatEmojiPickerManager.close();
|
||||
}
|
||||
},
|
||||
|
||||
@action
|
||||
closeComposerDropdown() {
|
||||
this.chatEmojiPickerManager.close();
|
||||
this.appEvents.trigger("d-popover:close");
|
||||
},
|
||||
|
||||
@action
|
||||
|
@ -420,8 +422,9 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
return `${v.code}:`;
|
||||
} else {
|
||||
$textarea.autocomplete({ cancel: true });
|
||||
this.chatEmojiPickerManager.startFromComposer(this.emojiSelected, {
|
||||
filter: v.term,
|
||||
this.chatEmojiPickerManager.open({
|
||||
context: this.context,
|
||||
initialFilter: v.term,
|
||||
});
|
||||
return "";
|
||||
}
|
||||
|
@ -551,18 +554,21 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
|
||||
@discourseComputed(
|
||||
"chatChannel.{id,chatable.users.[]}",
|
||||
"canInteractWithChat"
|
||||
"chat.userCanInteractWithChat"
|
||||
)
|
||||
disableComposer(channel, canInteractWithChat) {
|
||||
disableComposer(channel, userCanInteractWithChat) {
|
||||
return (
|
||||
(channel.isDraft && isEmpty(channel?.chatable?.users)) ||
|
||||
!canInteractWithChat ||
|
||||
!userCanInteractWithChat ||
|
||||
!channel.canModifyMessages(this.currentUser)
|
||||
);
|
||||
},
|
||||
|
||||
@discourseComputed("userSilenced", "chatChannel.{chatable.users.[],id}")
|
||||
placeholder(userSilenced, chatChannel) {
|
||||
@discourseComputed(
|
||||
"chatChannel.{chatable.users.[],id}",
|
||||
"chat.userCanInteractWithChat"
|
||||
)
|
||||
placeholder(chatChannel, userCanInteractWithChat) {
|
||||
if (!chatChannel.canModifyMessages(this.currentUser)) {
|
||||
return I18n.t(
|
||||
`chat.placeholder_new_message_disallowed.${chatChannel.status}`
|
||||
|
@ -581,7 +587,7 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
}
|
||||
}
|
||||
|
||||
if (userSilenced) {
|
||||
if (!userCanInteractWithChat) {
|
||||
return I18n.t("chat.placeholder_silenced");
|
||||
} else {
|
||||
return this.messageRecipient(chatChannel);
|
||||
|
@ -612,7 +618,7 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
|
||||
@discourseComputed(
|
||||
"value",
|
||||
"loading",
|
||||
"paneService.sendingLoading",
|
||||
"disableComposer",
|
||||
"inProgressUploads.[]"
|
||||
)
|
||||
|
@ -636,23 +642,30 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
return;
|
||||
}
|
||||
|
||||
this.editingMessage
|
||||
this.composerService?.editingMessage
|
||||
? this.internalEditMessage()
|
||||
: this.internalSendMessage();
|
||||
},
|
||||
|
||||
@action
|
||||
internalSendMessage() {
|
||||
return this.sendMessage(this.value, this._uploads).then(this.reset);
|
||||
// FIXME: This is fairly hacky, we should have a nicer
|
||||
// flow and relationship between the panes for resetting
|
||||
// the value here on send.
|
||||
const _previousValue = this.value;
|
||||
this.set("value", "");
|
||||
return this.sendMessage(_previousValue, this._uploads)
|
||||
.then(this.reset)
|
||||
.catch(() => {
|
||||
this.set("value", _previousValue);
|
||||
});
|
||||
},
|
||||
|
||||
@action
|
||||
internalEditMessage() {
|
||||
return this.editMessage(
|
||||
this.editingMessage,
|
||||
this.value,
|
||||
this._uploads
|
||||
).then(this.reset);
|
||||
return this.paneService
|
||||
?.editMessage(this.value, this._uploads)
|
||||
.then(this.reset);
|
||||
},
|
||||
|
||||
_messageIsValid() {
|
||||
|
@ -691,19 +704,21 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
this._captureMentions();
|
||||
this._syncUploads([]);
|
||||
this._focusTextArea({ ensureAtEnd: true, resizeTextarea: true });
|
||||
this.onValueChange?.(this.value, this._uploads, this.replyToMsg);
|
||||
this.composerService?.onComposerValueChange?.(
|
||||
this.value,
|
||||
this._uploads,
|
||||
this.composerService?.replyToMsg
|
||||
);
|
||||
},
|
||||
|
||||
@action
|
||||
cancelReplyTo() {
|
||||
this.set("replyToMsg", null);
|
||||
this.setInReplyToMsg(null);
|
||||
this.onValueChange?.({ replyToMsg: null });
|
||||
this.composerService?.setReplyTo(null);
|
||||
},
|
||||
|
||||
@action
|
||||
cancelEditing() {
|
||||
this.onCancelEditing();
|
||||
this.composerService?.cancelEditing();
|
||||
this._focusTextArea({ ensureAtEnd: true, resizeTextarea: true });
|
||||
},
|
||||
|
||||
|
@ -721,7 +736,10 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
@action
|
||||
uploadsChanged(uploads, { inProgressUploadsCount }) {
|
||||
this.set("_uploads", cloneJSON(uploads));
|
||||
this.onValueChange?.({ uploads: this._uploads, inProgressUploadsCount });
|
||||
this.composerService?.onComposerValueChange?.({
|
||||
uploads: this._uploads,
|
||||
inProgressUploadsCount,
|
||||
});
|
||||
},
|
||||
|
||||
@action
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{{#if this.chatStateManager.isDrawerActive}}
|
||||
|
||||
<div
|
||||
data-chat-channel-id={{this.chat.activeChannel.id}}
|
||||
data-chat-thread-id={{this.chat.activeChannel.activeThread.id}}
|
||||
class={{concat-class
|
||||
"chat-drawer"
|
||||
(if this.chatStateManager.isDrawerExpanded "is-expanded")
|
||||
|
|
|
@ -22,6 +22,7 @@ export default Component.extend({
|
|||
|
||||
didInsertElement() {
|
||||
this._super(...arguments);
|
||||
|
||||
if (!this.chat.userCanChat) {
|
||||
return;
|
||||
}
|
||||
|
@ -46,6 +47,7 @@ export default Component.extend({
|
|||
|
||||
willDestroyElement() {
|
||||
this._super(...arguments);
|
||||
|
||||
if (!this.chat.userCanChat) {
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -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}}
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,175 +1,137 @@
|
|||
{{! template-lint-disable no-invalid-interactive }}
|
||||
{{! template-lint-disable no-nested-interactive }}
|
||||
{{! template-lint-disable no-down-event-binding }}
|
||||
<div
|
||||
class={{concat-class
|
||||
"chat-emoji-picker"
|
||||
(if this.chatEmojiPickerManager.closing "closing")
|
||||
}}
|
||||
{{did-insert this.addClickOutsideEventListener}}
|
||||
{{will-destroy this.removeClickOutsideEventListener}}
|
||||
{{on "keydown" this.trapKeyDownEvents}}
|
||||
>
|
||||
<div class="chat-emoji-picker__filter-container">
|
||||
<DcFilterInput
|
||||
@class="chat-emoji-picker__filter"
|
||||
@value={{this.chatEmojiPickerManager.initialFilter}}
|
||||
@filterAction={{action this.didInputFilter value="target.value"}}
|
||||
@icons={{hash left="search"}}
|
||||
placeholder={{i18n "chat.emoji_picker.search_placeholder"}}
|
||||
autofocus={{true}}
|
||||
{{did-insert this.focusFilter}}
|
||||
{{did-insert
|
||||
(fn this.didInputFilter this.chatEmojiPickerManager.initialFilter)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="chat-emoji-picker__fitzpatrick-scale"
|
||||
role="toolbar"
|
||||
{{on "keyup" this.didNavigateFitzpatrickScale}}
|
||||
|
||||
{{#if (eq this.chatEmojiPickerManager.picker.context @context)}}
|
||||
<div
|
||||
class={{concat-class
|
||||
"chat-emoji-picker"
|
||||
@class
|
||||
(if this.chatEmojiPickerManager.closing "closing")
|
||||
}}
|
||||
{{did-insert this.addClickOutsideEventListener}}
|
||||
{{did-insert this.chatEmojiPickerManager.loadEmojis}}
|
||||
{{did-insert (if @didInsert @didInsert (noop))}}
|
||||
{{will-destroy (if @willDestroy @willDestroy (noop))}}
|
||||
{{will-destroy this.removeClickOutsideEventListener}}
|
||||
{{on "keydown" this.trapKeyDownEvents}}
|
||||
>
|
||||
<div class="chat-emoji-picker__filter-container">
|
||||
<DcFilterInput
|
||||
@class="chat-emoji-picker__filter"
|
||||
@value={{this.chatEmojiPickerManager.picker.initialFilter}}
|
||||
@filterAction={{action this.didInputFilter value="target.value"}}
|
||||
@icons={{hash left="search"}}
|
||||
placeholder={{i18n "chat.emoji_picker.search_placeholder"}}
|
||||
autofocus={{true}}
|
||||
{{did-insert (if this.site.desktopView this.focusFilter (noop))}}
|
||||
{{did-insert
|
||||
(fn
|
||||
this.didInputFilter this.chatEmojiPickerManager.picker.initialFilter
|
||||
)
|
||||
}}
|
||||
>
|
||||
{{#if this.isExpandedFitzpatrickScale}}
|
||||
{{#each this.fitzpatrickModifiers as |fitzpatrick|}}
|
||||
|
||||
{{#if
|
||||
(not (eq fitzpatrick.scale this.chatEmojiReactionStore.diversity))
|
||||
}}
|
||||
<button
|
||||
type="button"
|
||||
title={{concat "t" fitzpatrick.scale}}
|
||||
tabindex="-1"
|
||||
class={{concat-class
|
||||
"chat-emoji-picker__fitzpatrick-modifier-btn"
|
||||
(concat "t" fitzpatrick.scale)
|
||||
}}
|
||||
{{on
|
||||
"keyup"
|
||||
(fn this.didRequestFitzpatrickScale fitzpatrick.scale)
|
||||
}}
|
||||
{{on
|
||||
"click"
|
||||
(fn this.didRequestFitzpatrickScale fitzpatrick.scale)
|
||||
}}
|
||||
>
|
||||
{{d-icon "check"}}
|
||||
</button>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
title={{concat "t" this.fitzpatrick.scale}}
|
||||
class={{concat-class
|
||||
"chat-emoji-picker__fitzpatrick-modifier-btn current"
|
||||
(concat "t" this.chatEmojiReactionStore.diversity)
|
||||
}}
|
||||
{{on "keyup" this.didToggleFitzpatrickScale}}
|
||||
{{on "click" this.didToggleFitzpatrickScale}}
|
||||
></button>
|
||||
</div>
|
||||
</DcFilterInput>
|
||||
</div>
|
||||
|
||||
{{#if this.chatEmojiPickerManager.sections.length}}
|
||||
{{#if (not (gte this.filteredEmojis.length 0))}}
|
||||
<div class="chat-emoji-picker__sections-nav">
|
||||
<div
|
||||
class="chat-emoji-picker__sections-nav__indicator"
|
||||
style={{this.navIndicatorStyle}}
|
||||
></div>
|
||||
class="chat-emoji-picker__fitzpatrick-scale"
|
||||
role="toolbar"
|
||||
{{on "keyup" this.didNavigateFitzpatrickScale}}
|
||||
>
|
||||
{{#if this.isExpandedFitzpatrickScale}}
|
||||
{{#each this.fitzpatrickModifiers as |fitzpatrick|}}
|
||||
|
||||
{{#each-in this.groups as |section emojis|}}
|
||||
<DButton
|
||||
class={{concat-class
|
||||
"btn-flat"
|
||||
"chat-emoji-picker__section-btn"
|
||||
(if
|
||||
(eq this.chatEmojiPickerManager.lastVisibleSection section)
|
||||
"active"
|
||||
)
|
||||
}}
|
||||
tabindex="-1"
|
||||
style={{this.navBtnStyle}}
|
||||
@action={{fn this.didRequestSection section}}
|
||||
data-section={{section}}
|
||||
>
|
||||
{{#if (eq section "favorites")}}
|
||||
{{replace-emoji ":star:"}}
|
||||
{{else}}
|
||||
<img
|
||||
width="18"
|
||||
height="18"
|
||||
class="emoji"
|
||||
src={{emojis.firstObject.url}}
|
||||
/>
|
||||
{{/if}}
|
||||
</DButton>
|
||||
{{/each-in}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div
|
||||
class="chat-emoji-picker__scrollable-content"
|
||||
{{chat/emoji-picker-scroll-listener}}
|
||||
>
|
||||
<div
|
||||
class="chat-emoji-picker__sections"
|
||||
{{on "click" this.didSelectEmoji}}
|
||||
{{on "keydown" this.onSectionsKeyDown}}
|
||||
role="button"
|
||||
>
|
||||
{{#if (gte this.filteredEmojis.length 0)}}
|
||||
<div class="chat-emoji-picker__section filtered">
|
||||
{{#each this.filteredEmojis as |emoji|}}
|
||||
<img
|
||||
width="32"
|
||||
height="32"
|
||||
class="emoji"
|
||||
src={{tonable-emoji-url
|
||||
emoji
|
||||
this.chatEmojiReactionStore.diversity
|
||||
}}
|
||||
tabindex="0"
|
||||
data-emoji={{emoji.name}}
|
||||
data-tonable={{if emoji.tonable "true"}}
|
||||
alt={{emoji.name}}
|
||||
title={{tonable-emoji-title
|
||||
emoji
|
||||
this.chatEmojiReactionStore.diversity
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
{{else}}
|
||||
<p class="chat-emoji-picker__no-reults">
|
||||
{{i18n "chat.emoji_picker.no_results"}}
|
||||
</p>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#each-in this.groups as |section emojis|}}
|
||||
<div
|
||||
class={{concat-class
|
||||
"chat-emoji-picker__section"
|
||||
(if (gte this.filteredEmojis.length 0) "hidden")
|
||||
}}
|
||||
data-section={{section}}
|
||||
role="region"
|
||||
aria-label={{i18n
|
||||
(concat "chat.emoji_picker." section)
|
||||
translatedFallback=section
|
||||
}}
|
||||
>
|
||||
<h2 class="chat-emoji-picker__section-title">
|
||||
{{i18n
|
||||
(concat "chat.emoji_picker." section)
|
||||
translatedFallback=section
|
||||
{{#if
|
||||
(not
|
||||
(eq fitzpatrick.scale this.chatEmojiReactionStore.diversity)
|
||||
)
|
||||
}}
|
||||
</h2>
|
||||
<div class="chat-emoji-picker__section-emojis">
|
||||
{{! we always want the first emoji for tabbing}}
|
||||
{{#let emojis.firstObject as |emoji|}}
|
||||
<button
|
||||
type="button"
|
||||
title={{concat "t" fitzpatrick.scale}}
|
||||
tabindex="-1"
|
||||
class={{concat-class
|
||||
"chat-emoji-picker__fitzpatrick-modifier-btn"
|
||||
(concat "t" fitzpatrick.scale)
|
||||
}}
|
||||
{{on
|
||||
"keyup"
|
||||
(fn this.didRequestFitzpatrickScale fitzpatrick.scale)
|
||||
}}
|
||||
{{on
|
||||
"click"
|
||||
(fn this.didRequestFitzpatrickScale fitzpatrick.scale)
|
||||
}}
|
||||
>
|
||||
{{d-icon "check"}}
|
||||
</button>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
title={{concat "t" this.fitzpatrick.scale}}
|
||||
class={{concat-class
|
||||
"chat-emoji-picker__fitzpatrick-modifier-btn current"
|
||||
(concat "t" this.chatEmojiReactionStore.diversity)
|
||||
}}
|
||||
{{on "keyup" this.didToggleFitzpatrickScale}}
|
||||
{{on "click" this.didToggleFitzpatrickScale}}
|
||||
></button>
|
||||
</div>
|
||||
</DcFilterInput>
|
||||
</div>
|
||||
|
||||
{{#if this.chatEmojiPickerManager.sections.length}}
|
||||
{{#if (not (gte this.filteredEmojis.length 0))}}
|
||||
<div class="chat-emoji-picker__sections-nav">
|
||||
<div
|
||||
class="chat-emoji-picker__sections-nav__indicator"
|
||||
style={{this.navIndicatorStyle}}
|
||||
></div>
|
||||
|
||||
{{#each-in this.groups as |section emojis|}}
|
||||
<DButton
|
||||
class={{concat-class
|
||||
"btn-flat"
|
||||
"chat-emoji-picker__section-btn"
|
||||
(if
|
||||
(eq this.chatEmojiPickerManager.lastVisibleSection section)
|
||||
"active"
|
||||
)
|
||||
}}
|
||||
tabindex="-1"
|
||||
style={{this.navBtnStyle}}
|
||||
@action={{fn this.didRequestSection section}}
|
||||
data-section={{section}}
|
||||
>
|
||||
{{#if (eq section "favorites")}}
|
||||
{{replace-emoji ":star:"}}
|
||||
{{else}}
|
||||
<img
|
||||
width="18"
|
||||
height="18"
|
||||
class="emoji"
|
||||
src={{emojis.firstObject.url}}
|
||||
/>
|
||||
{{/if}}
|
||||
</DButton>
|
||||
{{/each-in}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div
|
||||
class="chat-emoji-picker__scrollable-content"
|
||||
{{chat/emoji-picker-scroll-listener}}
|
||||
>
|
||||
<div
|
||||
class="chat-emoji-picker__sections"
|
||||
{{on "click" this.didSelectEmoji}}
|
||||
{{on "keydown" this.onSectionsKeyDown}}
|
||||
role="button"
|
||||
>
|
||||
{{#if (gte this.filteredEmojis.length 0)}}
|
||||
<div class="chat-emoji-picker__section filtered">
|
||||
{{#each this.filteredEmojis as |emoji|}}
|
||||
<img
|
||||
width="32"
|
||||
height="32"
|
||||
|
@ -187,53 +149,101 @@
|
|||
this.chatEmojiReactionStore.diversity
|
||||
}}
|
||||
loading="lazy"
|
||||
{{on "focus" this.didFocusFirstEmoji}}
|
||||
/>
|
||||
{{/let}}
|
||||
|
||||
{{#if
|
||||
(includes this.chatEmojiPickerManager.visibleSections section)
|
||||
}}
|
||||
{{#each emojis as |emoji index|}}
|
||||
{{! first emoji has already been rendered, we don't want to re render or would lose focus}}
|
||||
{{#if (gt index 0)}}
|
||||
<img
|
||||
width="32"
|
||||
height="32"
|
||||
class="emoji"
|
||||
src={{tonable-emoji-url
|
||||
emoji
|
||||
this.chatEmojiReactionStore.diversity
|
||||
}}
|
||||
tabindex="-1"
|
||||
data-emoji={{emoji.name}}
|
||||
data-tonable={{if emoji.tonable "true"}}
|
||||
alt={{emoji.name}}
|
||||
title={{tonable-emoji-title
|
||||
emoji
|
||||
this.chatEmojiReactionStore.diversity
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<p class="chat-emoji-picker__no-results">
|
||||
{{i18n "chat.emoji_picker.no_results"}}
|
||||
</p>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
{{/each-in}}
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="spinner medium"></div>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if
|
||||
(and
|
||||
this.chatEmojiPickerManager.opened
|
||||
this.site.mobileView
|
||||
(eq this.chatEmojiPickerManager.context "chat-message")
|
||||
)
|
||||
}}
|
||||
<div class="chat-emoji-picker__backdrop"></div>
|
||||
{{#each-in this.groups as |section emojis|}}
|
||||
<div
|
||||
class={{concat-class
|
||||
"chat-emoji-picker__section"
|
||||
(if (gte this.filteredEmojis.length 0) "hidden")
|
||||
}}
|
||||
data-section={{section}}
|
||||
role="region"
|
||||
aria-label={{i18n
|
||||
(concat "chat.emoji_picker." section)
|
||||
translatedFallback=section
|
||||
}}
|
||||
>
|
||||
<h2 class="chat-emoji-picker__section-title">
|
||||
{{i18n
|
||||
(concat "chat.emoji_picker." section)
|
||||
translatedFallback=section
|
||||
}}
|
||||
</h2>
|
||||
<div class="chat-emoji-picker__section-emojis">
|
||||
{{! we always want the first emoji for tabbing}}
|
||||
{{#let emojis.firstObject as |emoji|}}
|
||||
<img
|
||||
width="32"
|
||||
height="32"
|
||||
class="emoji"
|
||||
src={{tonable-emoji-url
|
||||
emoji
|
||||
this.chatEmojiReactionStore.diversity
|
||||
}}
|
||||
tabindex="0"
|
||||
data-emoji={{emoji.name}}
|
||||
data-tonable={{if emoji.tonable "true"}}
|
||||
alt={{emoji.name}}
|
||||
title={{tonable-emoji-title
|
||||
emoji
|
||||
this.chatEmojiReactionStore.diversity
|
||||
}}
|
||||
loading="lazy"
|
||||
{{on "focus" this.didFocusFirstEmoji}}
|
||||
/>
|
||||
{{/let}}
|
||||
|
||||
{{#if
|
||||
(includes this.chatEmojiPickerManager.visibleSections section)
|
||||
}}
|
||||
{{#each emojis as |emoji index|}}
|
||||
{{! first emoji has already been rendered, we don't want to re render or would lose focus}}
|
||||
{{#if (gt index 0)}}
|
||||
<img
|
||||
width="32"
|
||||
height="32"
|
||||
class="emoji"
|
||||
src={{tonable-emoji-url
|
||||
emoji
|
||||
this.chatEmojiReactionStore.diversity
|
||||
}}
|
||||
tabindex="-1"
|
||||
data-emoji={{emoji.name}}
|
||||
data-tonable={{if emoji.tonable "true"}}
|
||||
alt={{emoji.name}}
|
||||
title={{tonable-emoji-title
|
||||
emoji
|
||||
this.chatEmojiReactionStore.diversity
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
{{/each-in}}
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="spinner medium"></div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{#if
|
||||
(and
|
||||
this.site.mobileView
|
||||
(eq this.chatEmojiPickerManager.picker.context "chat-channel-message")
|
||||
)
|
||||
}}
|
||||
<div class="chat-emoji-picker__backdrop"></div>
|
||||
{{/if}}
|
||||
{{/if}}
|
|
@ -1,4 +1,4 @@
|
|||
import Component from "@ember/component";
|
||||
import Component from "@glimmer/component";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import { action } from "@ember/object";
|
||||
import { inject as service } from "@ember/service";
|
||||
|
@ -40,9 +40,11 @@ export default class ChatEmojiPicker extends Component {
|
|||
@service chatEmojiPickerManager;
|
||||
@service emojiPickerScrollObserver;
|
||||
@service chatEmojiReactionStore;
|
||||
@service capabilities;
|
||||
@service site;
|
||||
|
||||
@tracked filteredEmojis = null;
|
||||
@tracked isExpandedFitzpatrickScale = false;
|
||||
tagName = "";
|
||||
|
||||
fitzpatrickModifiers = FITZPATRICK_MODIFIERS;
|
||||
|
||||
|
@ -163,7 +165,7 @@ export default class ChatEmojiPicker extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
this.toggleProperty("isExpandedFitzpatrickScale");
|
||||
this.isExpandedFitzpatrickScale = !this.isExpandedFitzpatrickScale;
|
||||
}
|
||||
|
||||
@action
|
||||
|
@ -210,7 +212,9 @@ export default class ChatEmojiPicker extends Component {
|
|||
|
||||
@action
|
||||
focusFilter(target) {
|
||||
target.focus();
|
||||
schedule("afterRender", () => {
|
||||
target?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
debouncedDidInputFilter(filter = "") {
|
||||
|
@ -347,8 +351,7 @@ export default class ChatEmojiPicker extends Component {
|
|||
emoji = `${emoji}:t${diversity}`;
|
||||
}
|
||||
|
||||
this.chatEmojiPickerManager.didSelectEmoji(emoji);
|
||||
this.appEvents.trigger("chat:focus-composer");
|
||||
this.args.didSelectEmoji?.(emoji);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
class={{concat-class
|
||||
"chat-live-pane"
|
||||
(if this.loading "loading")
|
||||
(if this.sendingLoading "sending-loading")
|
||||
(if this.chatChannelPane.sendingLoading "sending-loading")
|
||||
(unless this.loadedOnce "not-loaded-once")
|
||||
}}
|
||||
{{did-insert this.setupListeners}}
|
||||
|
@ -23,47 +23,26 @@
|
|||
|
||||
<ChatMentionWarnings />
|
||||
|
||||
<div class="chat-message-actions-mobile-anchor"></div>
|
||||
|
||||
<div
|
||||
class={{concat-class
|
||||
"chat-message-emoji-picker-anchor"
|
||||
(if
|
||||
(and
|
||||
this.chatEmojiPickerManager.opened
|
||||
(eq this.chatEmojiPickerManager.context "chat-message")
|
||||
)
|
||||
"-opened"
|
||||
)
|
||||
}}
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="chat-messages-scroll chat-messages-container"
|
||||
{{on "scroll" this.computeScrollState passive=true}}
|
||||
{{chat/on-scroll this.resetIdle (hash delay=500)}}
|
||||
{{chat/on-scroll this.computeArrow (hash delay=150)}}
|
||||
{{did-insert this.setScrollable}}
|
||||
>
|
||||
<div class="chat-message-actions-desktop-anchor"></div>
|
||||
<div class="chat-messages-container" {{chat/on-resize this.didResizePane}}>
|
||||
<div
|
||||
class="chat-messages-container"
|
||||
{{chat/on-resize this.didResizePane (hash delay=10)}}
|
||||
>
|
||||
{{#if this.loadedOnce}}
|
||||
{{#each @channel.messages key="id" as |message|}}
|
||||
<ChatMessage
|
||||
@message={{message}}
|
||||
@canInteractWithChat={{this.canInteractWithChat}}
|
||||
@channel={{@channel}}
|
||||
@setReplyTo={{this.setReplyTo}}
|
||||
@replyMessageClicked={{this.replyMessageClicked}}
|
||||
@editButtonClicked={{this.editButtonClicked}}
|
||||
@selectingMessages={{this.selectingMessages}}
|
||||
@onStartSelectingMessages={{this.onStartSelectingMessages}}
|
||||
@onSelectMessage={{this.onSelectMessage}}
|
||||
@bulkSelectMessages={{this.bulkSelectMessages}}
|
||||
@isHovered={{eq message.id this.hoveredMessageId}}
|
||||
@onHoverMessage={{this.onHoverMessage}}
|
||||
@resendStagedMessage={{this.resendStagedMessage}}
|
||||
@messageDidEnterViewport={{this.messageDidEnterViewport}}
|
||||
@messageDidLeaveViewport={{this.messageDidLeaveViewport}}
|
||||
@context="channel"
|
||||
/>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
|
@ -86,26 +65,25 @@
|
|||
@channel={{@channel}}
|
||||
/>
|
||||
|
||||
{{#if this.selectingMessages}}
|
||||
{{#if this.chatChannelPane.selectingMessages}}
|
||||
<ChatSelectionManager
|
||||
@selectedMessageIds={{this.selectedMessageIds}}
|
||||
@selectedMessageIds={{this.chatChannelPane.selectedMessageIds}}
|
||||
@chatChannel={{@channel}}
|
||||
@cancelSelecting={{this.cancelSelecting}}
|
||||
@cancelSelecting={{action
|
||||
this.chatChannelPane.cancelSelecting
|
||||
@channel.selectedMessages
|
||||
}}
|
||||
@context="channel"
|
||||
/>
|
||||
{{else}}
|
||||
{{#if (or @channel.isDraft @channel.isFollowing)}}
|
||||
<ChatComposer
|
||||
@canInteractWithChat={{this.canInteractWithChat}}
|
||||
@sendMessage={{this.sendMessage}}
|
||||
@editMessage={{this.editMessage}}
|
||||
@setReplyTo={{this.setReplyTo}}
|
||||
@loading={{this.sendingLoading}}
|
||||
@editingMessage={{readonly this.editingMessage}}
|
||||
@onCancelEditing={{this.cancelEditing}}
|
||||
@setInReplyToMsg={{this.setInReplyToMsg}}
|
||||
@onEditLastMessageRequested={{this.editLastMessageRequested}}
|
||||
@onValueChange={{this.composerValueChanged}}
|
||||
@chatChannel={{@channel}}
|
||||
@composerService={{this.chatChannelComposer}}
|
||||
@paneService={{this.chatChannelPane}}
|
||||
@context="channel"
|
||||
/>
|
||||
{{else}}
|
||||
<ChatChannelPreviewCard @channel={{@channel}} />
|
||||
|
|
|
@ -32,6 +32,8 @@ export default class ChatLivePane extends Component {
|
|||
@service chatEmojiPickerManager;
|
||||
@service chatComposerPresenceManager;
|
||||
@service chatStateManager;
|
||||
@service chatChannelComposer;
|
||||
@service chatChannelPane;
|
||||
@service chatApi;
|
||||
@service currentUser;
|
||||
@service appEvents;
|
||||
|
@ -41,28 +43,26 @@ export default class ChatLivePane extends Component {
|
|||
@tracked loading = false;
|
||||
@tracked loadingMorePast = false;
|
||||
@tracked loadingMoreFuture = false;
|
||||
@tracked hoveredMessageId = null;
|
||||
@tracked sendingLoading = false;
|
||||
@tracked selectingMessages = false;
|
||||
@tracked showChatQuoteSuccess = false;
|
||||
@tracked includeHeader = true;
|
||||
@tracked editingMessage = null;
|
||||
@tracked replyToMsg = null;
|
||||
@tracked hasNewMessages = false;
|
||||
@tracked needsArrow = false;
|
||||
@tracked loadedOnce = false;
|
||||
|
||||
scrollable = null;
|
||||
_loadedChannelId = null;
|
||||
_scrollerEl = null;
|
||||
_lastSelectedMessage = null;
|
||||
_mentionWarningsSeen = {};
|
||||
_unreachableGroupMentions = [];
|
||||
_overMembersLimitGroupMentions = [];
|
||||
|
||||
@action
|
||||
setupListeners(element) {
|
||||
this._scrollerEl = element.querySelector(".chat-messages-scroll");
|
||||
setScrollable(element) {
|
||||
this.scrollable = element;
|
||||
}
|
||||
|
||||
@action
|
||||
setupListeners() {
|
||||
document.addEventListener("scroll", this._forceBodyScroll, {
|
||||
passive: true,
|
||||
});
|
||||
|
@ -102,8 +102,8 @@ export default class ChatLivePane extends Component {
|
|||
|
||||
if (this._loadedChannelId !== this.args.channel?.id) {
|
||||
this._unsubscribeToUpdates(this._loadedChannelId);
|
||||
this.selectingMessages = false;
|
||||
this.cancelEditing();
|
||||
this.chatChannelPane.selectingMessages = false;
|
||||
this.chatChannelComposer.cancelEditing();
|
||||
this._loadedChannelId = this.args.channel?.id;
|
||||
}
|
||||
|
||||
|
@ -239,7 +239,8 @@ export default class ChatLivePane extends Component {
|
|||
.then((results) => {
|
||||
if (
|
||||
this._selfDeleted ||
|
||||
this.args.channel.id !== results.meta.channel_id
|
||||
this.args.channel.id !== results.meta.channel_id ||
|
||||
!this.scrollable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
@ -372,11 +373,15 @@ export default class ChatLivePane extends Component {
|
|||
}
|
||||
|
||||
schedule("afterRender", () => {
|
||||
const messageEl = this._scrollerEl.querySelector(
|
||||
if (this._selfDeleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messageEl = this.scrollable.querySelector(
|
||||
`.chat-message-container[data-id='${messageId}']`
|
||||
);
|
||||
|
||||
if (!messageEl || this._selfDeleted) {
|
||||
if (!messageEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -428,13 +433,13 @@ export default class ChatLivePane extends Component {
|
|||
return;
|
||||
}
|
||||
|
||||
const element = this._scrollerEl.querySelector(
|
||||
const element = this.scrollable.querySelector(
|
||||
`[data-id='${lastUnreadVisibleMessage.id}']`
|
||||
);
|
||||
|
||||
// if the last visible message is not fully visible, we don't want to mark it as read
|
||||
// attempt to mark previous one as read
|
||||
if (!this.#isBottomOfMessageVisible(element, this._scrollerEl)) {
|
||||
if (!this.#isBottomOfMessageVisible(element, this.scrollable)) {
|
||||
lastUnreadVisibleMessage = lastUnreadVisibleMessage.previousMessage;
|
||||
|
||||
if (
|
||||
|
@ -449,23 +454,6 @@ export default class ChatLivePane extends Component {
|
|||
});
|
||||
}
|
||||
|
||||
@action
|
||||
scrollToBottom() {
|
||||
schedule("afterRender", () => {
|
||||
if (this._selfDeleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A more consistent way to scroll to the bottom when we are sure this is our goal
|
||||
// it will also limit issues with any element changing the height while we are scrolling
|
||||
// to the bottom
|
||||
this._scrollerEl.scrollTop = -1;
|
||||
this.forceRendering(() => {
|
||||
this._scrollerEl.scrollTop = 0;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
scrollToLatestMessage() {
|
||||
schedule("afterRender", () => {
|
||||
|
@ -485,13 +473,21 @@ export default class ChatLivePane extends Component {
|
|||
|
||||
@action
|
||||
computeArrow() {
|
||||
this.needsArrow = Math.abs(this._scrollerEl.scrollTop) >= 250;
|
||||
if (!this.scrollable) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.needsArrow = Math.abs(this.scrollable.scrollTop) >= 250;
|
||||
}
|
||||
|
||||
@action
|
||||
computeScrollState() {
|
||||
cancel(this.onScrollEndedHandler);
|
||||
|
||||
if (!this.scrollable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.#isAtTop()) {
|
||||
this.fetchMoreMessages({ direction: PAST });
|
||||
this.onScrollEnded();
|
||||
|
@ -719,6 +715,8 @@ export default class ChatLivePane extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
// TODO (martin) Maybe change this to public, since its referred to by
|
||||
// livePanel.linkedComponent at the moment.
|
||||
get _selfDeleted() {
|
||||
return this.isDestroying || this.isDestroyed;
|
||||
}
|
||||
|
@ -731,11 +729,11 @@ export default class ChatLivePane extends Component {
|
|||
sendMessage(message, uploads = []) {
|
||||
resetIdle();
|
||||
|
||||
if (this.sendingLoading) {
|
||||
if (this.chatChannelPane.sendingLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendingLoading = true;
|
||||
this.chatChannelPane.sendingLoading = true;
|
||||
this.args.channel.draft = ChatMessageDraft.create();
|
||||
|
||||
// TODO: all send message logic is due for massive refactoring
|
||||
|
@ -758,8 +756,8 @@ export default class ChatLivePane extends Component {
|
|||
return;
|
||||
}
|
||||
this.loading = false;
|
||||
this.sendingLoading = false;
|
||||
this._resetAfterSend();
|
||||
this.chatChannelPane.sendingLoading = false;
|
||||
this.chatChannelPane.resetAfterSend();
|
||||
this.scrollToLatestMessage();
|
||||
});
|
||||
}
|
||||
|
@ -771,8 +769,8 @@ export default class ChatLivePane extends Component {
|
|||
user: this.currentUser,
|
||||
});
|
||||
|
||||
if (this.replyToMsg) {
|
||||
stagedMessage.inReplyTo = this.replyToMsg;
|
||||
if (this.chatChannelComposer.replyToMsg) {
|
||||
stagedMessage.inReplyTo = this.chatChannelComposer.replyToMsg;
|
||||
}
|
||||
|
||||
this.args.channel.messagesManager.addMessages([stagedMessage]);
|
||||
|
@ -797,8 +795,8 @@ export default class ChatLivePane extends Component {
|
|||
if (this._selfDeleted) {
|
||||
return;
|
||||
}
|
||||
this.sendingLoading = false;
|
||||
this._resetAfterSend();
|
||||
this.chatChannelPane.sendingLoading = false;
|
||||
this.chatChannelPane.resetAfterSend();
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -836,12 +834,12 @@ export default class ChatLivePane extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
this._resetAfterSend();
|
||||
this.chatChannelPane.resetAfterSend();
|
||||
}
|
||||
|
||||
@action
|
||||
resendStagedMessage(stagedMessage) {
|
||||
this.sendingLoading = true;
|
||||
this.chatChannelPane.sendingLoading = true;
|
||||
|
||||
stagedMessage.error = null;
|
||||
|
||||
|
@ -864,154 +862,14 @@ export default class ChatLivePane extends Component {
|
|||
if (this._selfDeleted) {
|
||||
return;
|
||||
}
|
||||
this.sendingLoading = false;
|
||||
this.chatChannelPane.sendingLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
editMessage(chatMessage, newContent, uploads) {
|
||||
this.sendingLoading = true;
|
||||
let data = {
|
||||
new_message: newContent,
|
||||
upload_ids: (uploads || []).map((upload) => upload.id),
|
||||
};
|
||||
return ajax(`/chat/${this.args.channel.id}/edit/${chatMessage.id}`, {
|
||||
type: "PUT",
|
||||
data,
|
||||
})
|
||||
.then(() => {
|
||||
this._resetAfterSend();
|
||||
})
|
||||
.catch(popupAjaxError)
|
||||
.finally(() => {
|
||||
if (this._selfDeleted) {
|
||||
return;
|
||||
}
|
||||
this.sendingLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
_resetAfterSend() {
|
||||
if (this._selfDeleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.replyToMsg = null;
|
||||
this.editingMessage = null;
|
||||
this.chatComposerPresenceManager.notifyState(this.args.channel.id, false);
|
||||
this.appEvents.trigger("chat-composer:reply-to-set", null);
|
||||
}
|
||||
|
||||
@action
|
||||
editLastMessageRequested() {
|
||||
const lastUserMessage = this.args.channel.messages.findLast(
|
||||
(message) => message.user.id === this.currentUser.id
|
||||
);
|
||||
|
||||
if (!lastUserMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastUserMessage.staged || lastUserMessage.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.editingMessage = lastUserMessage;
|
||||
this._focusComposer();
|
||||
}
|
||||
|
||||
@action
|
||||
setReplyTo(messageId) {
|
||||
if (messageId) {
|
||||
this.cancelEditing();
|
||||
|
||||
const message = this.args.channel.messagesManager.findMessage(messageId);
|
||||
this.replyToMsg = message;
|
||||
this.appEvents.trigger("chat-composer:reply-to-set", message);
|
||||
this._focusComposer();
|
||||
} else {
|
||||
this.replyToMsg = null;
|
||||
this.appEvents.trigger("chat-composer:reply-to-set", null);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
replyMessageClicked(message) {
|
||||
const replyMessageFromLookup =
|
||||
this.args.channel.messagesManager.findMessage(message.id);
|
||||
if (replyMessageFromLookup) {
|
||||
this.scrollToMessage(replyMessageFromLookup.id, {
|
||||
highlight: true,
|
||||
position: "start",
|
||||
autoExpand: true,
|
||||
});
|
||||
} else {
|
||||
// Message is not present in the loaded messages. Fetch it!
|
||||
this.requestedTargetMessageId = message.id;
|
||||
this.fetchMessages();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
editButtonClicked(messageId) {
|
||||
const message = this.args.channel.messagesManager.findMessage(messageId);
|
||||
this.editingMessage = message;
|
||||
this.scrollToLatestMessage();
|
||||
this._focusComposer();
|
||||
}
|
||||
|
||||
get canInteractWithChat() {
|
||||
return !this.args.channel?.userSilenced;
|
||||
}
|
||||
|
||||
get chatProgressBarContainer() {
|
||||
return document.querySelector("#chat-progress-bar-container");
|
||||
}
|
||||
|
||||
get selectedMessageIds() {
|
||||
return this.args.channel?.messages
|
||||
?.filter((m) => m.selected)
|
||||
?.map((m) => m.id);
|
||||
}
|
||||
|
||||
@action
|
||||
onStartSelectingMessages(message) {
|
||||
this._lastSelectedMessage = message;
|
||||
this.selectingMessages = true;
|
||||
}
|
||||
|
||||
@action
|
||||
cancelSelecting() {
|
||||
this.selectingMessages = false;
|
||||
this.args.channel.messages.forEach((message) => {
|
||||
message.selected = false;
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
onSelectMessage(message) {
|
||||
this._lastSelectedMessage = message;
|
||||
}
|
||||
|
||||
@action
|
||||
bulkSelectMessages(message, checked) {
|
||||
const lastSelectedIndex = this._findIndexOfMessage(
|
||||
this._lastSelectedMessage
|
||||
);
|
||||
const newlySelectedIndex = this._findIndexOfMessage(message);
|
||||
const sortedIndices = [lastSelectedIndex, newlySelectedIndex].sort(
|
||||
(a, b) => a - b
|
||||
);
|
||||
|
||||
for (let i = sortedIndices[0]; i <= sortedIndices[1]; i++) {
|
||||
this.args.channel.messages[i].selected = checked;
|
||||
}
|
||||
}
|
||||
|
||||
_findIndexOfMessage(message) {
|
||||
return this.args.channel.messages.findIndex((m) => m.id === message.id);
|
||||
}
|
||||
|
||||
@action
|
||||
onCloseFullScreen() {
|
||||
this.chatStateManager.prefersDrawer();
|
||||
|
@ -1023,144 +881,6 @@ export default class ChatLivePane extends Component {
|
|||
});
|
||||
}
|
||||
|
||||
@action
|
||||
cancelEditing() {
|
||||
this.editingMessage = null;
|
||||
}
|
||||
|
||||
@action
|
||||
setInReplyToMsg(inReplyMsg) {
|
||||
this.replyToMsg = inReplyMsg;
|
||||
}
|
||||
|
||||
@action
|
||||
composerValueChanged({ value, uploads, replyToMsg, inProgressUploadsCount }) {
|
||||
if (!this.editingMessage && !this.args.channel.isDraft) {
|
||||
if (typeof value !== "undefined") {
|
||||
this.args.channel.draft.message = value;
|
||||
}
|
||||
|
||||
// only save the uploads to the draft if we are not still uploading other
|
||||
// ones, otherwise we get into a cycle where we pass the draft uploads as
|
||||
// existingUploads back to the upload component and cause in progress ones
|
||||
// to be cancelled
|
||||
if (
|
||||
typeof uploads !== "undefined" &&
|
||||
inProgressUploadsCount !== "undefined" &&
|
||||
inProgressUploadsCount === 0
|
||||
) {
|
||||
this.args.channel.draft.uploads = uploads;
|
||||
}
|
||||
|
||||
if (typeof replyToMsg !== "undefined") {
|
||||
this.args.channel.draft.replyToMsg = replyToMsg;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.args.channel.isDraft) {
|
||||
this._reportReplyingPresence(value);
|
||||
}
|
||||
|
||||
this._persistDraft();
|
||||
}
|
||||
|
||||
@debounce(2000)
|
||||
_persistDraft() {
|
||||
if (this._selfDeleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.args.channel.draft) {
|
||||
return;
|
||||
}
|
||||
|
||||
ajax("/chat/drafts.json", {
|
||||
type: "POST",
|
||||
data: {
|
||||
chat_channel_id: this.args.channel.id,
|
||||
data: this.args.channel.draft.toJSON(),
|
||||
},
|
||||
ignoreUnsent: false,
|
||||
})
|
||||
.then(() => {
|
||||
this.chat.markNetworkAsReliable();
|
||||
})
|
||||
.catch((error) => {
|
||||
// we ignore a draft which can't be saved because it's too big
|
||||
// and only deal with network error for now
|
||||
if (!error.jqXHR?.responseJSON?.errors?.length) {
|
||||
this.chat.markNetworkAsUnreliable();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
onHoverMessage(message, options = {}, event) {
|
||||
if (this.site.mobileView && options.desktopOnly) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isScrolling) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message?.staged) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.hoveredMessageId &&
|
||||
message?.id &&
|
||||
this.hoveredMessageId === message?.id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event) {
|
||||
if (
|
||||
event.type === "mouseleave" &&
|
||||
(event.toElement || event.relatedTarget)?.closest(
|
||||
".chat-message-actions-desktop-anchor"
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "mouseenter" &&
|
||||
(event.fromElement || event.relatedTarget)?.closest(
|
||||
".chat-message-actions-desktop-anchor"
|
||||
)
|
||||
) {
|
||||
this.hoveredMessageId = message?.id;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.hoveredMessageId =
|
||||
message?.id && message.id !== this.hoveredMessageId ? message.id : null;
|
||||
}
|
||||
|
||||
_reportReplyingPresence(composerValue) {
|
||||
if (this._selfDeleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.args.channel.isDraft) {
|
||||
return;
|
||||
}
|
||||
|
||||
const replying = !this.editingMessage && !!composerValue;
|
||||
this.chatComposerPresenceManager.notifyState(
|
||||
this.args.channel.id,
|
||||
replying
|
||||
);
|
||||
}
|
||||
|
||||
_focusComposer() {
|
||||
this.appEvents.trigger("chat:focus-composer");
|
||||
}
|
||||
|
||||
_unsubscribeToUpdates(channelId) {
|
||||
if (!channelId) {
|
||||
return;
|
||||
|
@ -1213,33 +933,6 @@ export default class ChatLivePane extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
// since -webkit-overflow-scrolling: touch can't be used anymore to disable momentum scrolling
|
||||
// we now use this hack to disable it
|
||||
@bind
|
||||
forceRendering(callback) {
|
||||
schedule("afterRender", () => {
|
||||
if (!this._scrollerEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.capabilities.isIOS) {
|
||||
this._scrollerEl.style.overflow = "hidden";
|
||||
}
|
||||
|
||||
callback?.();
|
||||
|
||||
if (this.capabilities.isIOS) {
|
||||
discourseLater(() => {
|
||||
if (!this._scrollerEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._scrollerEl.style.overflow = "auto";
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
addAutoFocusEventListener() {
|
||||
document.addEventListener("keydown", this._autoFocus);
|
||||
|
@ -1277,14 +970,14 @@ export default class ChatLivePane extends Component {
|
|||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const composer = document.querySelector(".chat-composer-input");
|
||||
if (composer && !this.args.channel.isDraft) {
|
||||
this.appEvents.trigger("chat:insert-text", key);
|
||||
composer.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
@action
|
||||
|
@ -1292,12 +985,66 @@ export default class ChatLivePane extends Component {
|
|||
throttle(this, this._computeDatesSeparators, 50, false);
|
||||
}
|
||||
|
||||
// A more consistent way to scroll to the bottom when we are sure this is our goal
|
||||
// it will also limit issues with any element changing the height while we are scrolling
|
||||
// to the bottom
|
||||
@action
|
||||
scrollToBottom() {
|
||||
if (!this.scrollable) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.scrollable.scrollTop = -1;
|
||||
this.forceRendering(() => {
|
||||
this.scrollable.scrollTop = 0;
|
||||
});
|
||||
}
|
||||
|
||||
// since -webkit-overflow-scrolling: touch can't be used anymore to disable momentum scrolling
|
||||
// we now use this hack to disable it
|
||||
@bind
|
||||
forceRendering(callback) {
|
||||
schedule("afterRender", () => {
|
||||
if (this._selfDeleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.scrollable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.capabilities.isIOS) {
|
||||
this.scrollable.style.overflow = "hidden";
|
||||
}
|
||||
|
||||
callback?.();
|
||||
|
||||
if (this.capabilities.isIOS) {
|
||||
discourseLater(() => {
|
||||
if (!this.scrollable) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.scrollable.style.overflow = "auto";
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_computeDatesSeparators() {
|
||||
schedule("afterRender", () => {
|
||||
if (this._selfDeleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.scrollable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dates = [
|
||||
...this._scrollerEl.querySelectorAll(".chat-message-separator-date"),
|
||||
...this.scrollable.querySelectorAll(".chat-message-separator-date"),
|
||||
].reverse();
|
||||
const height = this._scrollerEl.querySelector(
|
||||
const height = this.scrollable.querySelector(
|
||||
".chat-messages-container"
|
||||
).clientHeight;
|
||||
|
||||
|
@ -1336,17 +1083,29 @@ export default class ChatLivePane extends Component {
|
|||
}
|
||||
|
||||
#isAtBottom() {
|
||||
return Math.abs(this._scrollerEl.scrollTop) <= 2;
|
||||
if (!this.scrollable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Math.abs(this.scrollable.scrollTop) <= 2;
|
||||
}
|
||||
|
||||
#isTowardsBottom() {
|
||||
return Math.abs(this._scrollerEl.scrollTop) <= 50;
|
||||
if (!this.scrollable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Math.abs(this.scrollable.scrollTop) <= 50;
|
||||
}
|
||||
|
||||
#isAtTop() {
|
||||
if (!this.scrollable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
Math.abs(this._scrollerEl.scrollTop) >=
|
||||
this._scrollerEl.scrollHeight - this._scrollerEl.offsetHeight - 2
|
||||
Math.abs(this.scrollable.scrollTop) >=
|
||||
this.scrollable.scrollHeight - this.scrollable.offsetHeight - 2
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,63 +1,72 @@
|
|||
<div
|
||||
class="chat-message-actions-container"
|
||||
data-id={{@message.id}}
|
||||
{{did-insert this.attachPopper}}
|
||||
{{will-destroy this.destroyPopper}}
|
||||
>
|
||||
<div class="chat-message-actions">
|
||||
{{#if this.chatStateManager.isFullPageActive}}
|
||||
{{#each @emojiReactions key="emoji" as |reaction|}}
|
||||
<ChatMessageReaction
|
||||
@reaction={{reaction}}
|
||||
@react={{@messageActions.react}}
|
||||
@showCount={{false}}
|
||||
{{#if (and this.site.desktopView this.chat.activeMessage.model.id)}}
|
||||
<div
|
||||
{{did-insert this.setupPopper}}
|
||||
{{did-update this.setupPopper this.chat.activeMessage.model.id}}
|
||||
{{will-destroy this.teardownPopper}}
|
||||
class="chat-message-actions-container"
|
||||
data-id={{this.message.id}}
|
||||
>
|
||||
<div class="chat-message-actions">
|
||||
{{#if this.chatStateManager.isFullPageActive}}
|
||||
{{#each
|
||||
this.messageInteractor.emojiReactions
|
||||
key="emoji"
|
||||
as |reaction|
|
||||
}}
|
||||
<ChatMessageReaction
|
||||
@reaction={{reaction}}
|
||||
@onReaction={{this.messageInteractor.react}}
|
||||
@message={{this.message}}
|
||||
@showCount={{false}}
|
||||
/>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
|
||||
{{#if this.messageInteractor.canInteractWithMessage}}
|
||||
<DButton
|
||||
@class="btn-flat react-btn"
|
||||
@action={{this.messageInteractor.openEmojiPicker}}
|
||||
@icon="discourse-emojis"
|
||||
@title="chat.react"
|
||||
@forwardEvent={{true}}
|
||||
/>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
{{#if @messageCapabilities.canReact}}
|
||||
<DButton
|
||||
@class="btn-flat react-btn"
|
||||
@action={{@messageActions.startReactionForMessageActions}}
|
||||
@icon="discourse-emojis"
|
||||
@title="chat.react"
|
||||
/>
|
||||
{{/if}}
|
||||
{{#if this.messageInteractor.canBookmark}}
|
||||
<DButton
|
||||
@class="btn-flat bookmark-btn"
|
||||
@action={{this.messageInteractor.toggleBookmark}}
|
||||
>
|
||||
<BookmarkIcon @bookmark={{this.message.bookmark}} />
|
||||
</DButton>
|
||||
{{/if}}
|
||||
|
||||
{{#if @messageCapabilities.canBookmark}}
|
||||
<DButton
|
||||
@class="btn-flat bookmark-btn"
|
||||
@action={{@messageActions.toggleBookmark}}
|
||||
>
|
||||
<BookmarkIcon @bookmark={{@message.bookmark}} />
|
||||
</DButton>
|
||||
{{/if}}
|
||||
{{#if this.messageInteractor.canReply}}
|
||||
<DButton
|
||||
@class="btn-flat reply-btn"
|
||||
@action={{this.messageInteractor.reply}}
|
||||
@icon="reply"
|
||||
@title="chat.reply"
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if @messageCapabilities.canReply}}
|
||||
<DButton
|
||||
@class="btn-flat reply-btn"
|
||||
@action={{@messageActions.reply}}
|
||||
@icon="reply"
|
||||
@title="chat.reply"
|
||||
/>
|
||||
{{/if}}
|
||||
{{#if this.messageInteractor.canOpenThread}}
|
||||
<DButton
|
||||
@class="btn-flat chat-message-thread-btn"
|
||||
@action={{this.messageInteractor.openThread}}
|
||||
@icon="puzzle-piece"
|
||||
@title="chat.threads.open"
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if @messageCapabilities.hasThread}}
|
||||
<DButton
|
||||
@class="btn-flat chat-message-thread-btn"
|
||||
@action={{@messageActions.openThread}}
|
||||
@icon="puzzle-piece"
|
||||
@title="chat.threads.open"
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if @secondaryButtons.length}}
|
||||
<DropdownSelectBox
|
||||
@class="more-buttons"
|
||||
@options={{hash icon="ellipsis-v" placement="left"}}
|
||||
@content={{@secondaryButtons}}
|
||||
@onChange={{action "handleSecondaryButtons"}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{#if this.messageInteractor.secondaryButtons.length}}
|
||||
<DropdownSelectBox
|
||||
@class="more-buttons"
|
||||
@options={{hash icon="ellipsis-v" placement="left"}}
|
||||
@content={{this.messageInteractor.secondaryButtons}}
|
||||
@onChange={{action this.messageInteractor.handleSecondaryButtons}}
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
|
@ -1,34 +1,48 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { action } from "@ember/object";
|
||||
import { createPopper } from "@popperjs/core";
|
||||
import { schedule } from "@ember/runloop";
|
||||
import { inject as service } from "@ember/service";
|
||||
import ChatMessageInteractor from "discourse/plugins/chat/discourse/lib/chat-message-interactor";
|
||||
import { getOwner } from "@ember/application";
|
||||
import { schedule } from "@ember/runloop";
|
||||
import { createPopper } from "@popperjs/core";
|
||||
import chatMessageContainer from "discourse/plugins/chat/discourse/lib/chat-message-container";
|
||||
import { action } from "@ember/object";
|
||||
|
||||
const MSG_ACTIONS_VERTICAL_PADDING = -10;
|
||||
|
||||
export default class ChatMessageActionsDesktop extends Component {
|
||||
@service chat;
|
||||
@service chatStateManager;
|
||||
@service chatEmojiPickerManager;
|
||||
@service site;
|
||||
|
||||
popper = null;
|
||||
|
||||
@action
|
||||
destroyPopper() {
|
||||
this.popper?.destroy();
|
||||
this.popper = null;
|
||||
get message() {
|
||||
return this.chat.activeMessage.model;
|
||||
}
|
||||
|
||||
get context() {
|
||||
return this.chat.activeMessage.context;
|
||||
}
|
||||
|
||||
get messageInteractor() {
|
||||
const activeMessage = this.chat.activeMessage;
|
||||
|
||||
return new ChatMessageInteractor(
|
||||
getOwner(this),
|
||||
activeMessage.model,
|
||||
activeMessage.context
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
attachPopper() {
|
||||
this.destroyPopper();
|
||||
setupPopper(element) {
|
||||
this.popper?.destroy();
|
||||
|
||||
schedule("afterRender", () => {
|
||||
this.popper = createPopper(
|
||||
document.querySelector(
|
||||
`.chat-message-container[data-id="${this.args.message.id}"]`
|
||||
),
|
||||
document.querySelector(
|
||||
`.chat-message-actions-container[data-id="${this.args.message.id}"] .chat-message-actions`
|
||||
),
|
||||
chatMessageContainer(this.message.id, this.context),
|
||||
element,
|
||||
{
|
||||
placement: "top-end",
|
||||
strategy: "fixed",
|
||||
|
@ -46,7 +60,7 @@ export default class ChatMessageActionsDesktop extends Component {
|
|||
}
|
||||
|
||||
@action
|
||||
handleSecondaryButtons(id) {
|
||||
this.args.messageActions?.[id]?.();
|
||||
teardownPopper() {
|
||||
this.popper?.destroy();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,91 +1,91 @@
|
|||
<div
|
||||
class={{concat-class
|
||||
"chat-message-actions-backdrop"
|
||||
(if this.showFadeIn "fade-in")
|
||||
}}
|
||||
{{did-insert this.fadeAndVibrate}}
|
||||
>
|
||||
{{#if (and this.site.mobileView this.chat.activeMessage)}}
|
||||
<div
|
||||
role="button"
|
||||
class="collapse-area"
|
||||
{{on "touchstart" this.collapseMenu passive=true}}
|
||||
class={{concat-class
|
||||
"chat-message-actions-backdrop"
|
||||
(if this.showFadeIn "fade-in")
|
||||
}}
|
||||
{{did-insert this.fadeAndVibrate}}
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="chat-message-actions">
|
||||
<div class="selected-message-container">
|
||||
<div class="selected-message">
|
||||
<ChatUserAvatar @user={{@message.user}} />
|
||||
<span
|
||||
{{on "touchstart" this.expandReply passive=true}}
|
||||
role="button"
|
||||
class={{concat-class
|
||||
"selected-message-reply"
|
||||
(if this.hasExpandedReply "is-expanded")
|
||||
}}
|
||||
>
|
||||
{{@message.message}}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
role="button"
|
||||
class="collapse-area"
|
||||
{{on "touchstart" this.collapseMenu passive=true}}
|
||||
>
|
||||
</div>
|
||||
|
||||
<ul class="secondary-actions">
|
||||
{{#each @secondaryButtons as |button|}}
|
||||
<li class="chat-message-action-item" data-id={{button.id}}>
|
||||
<DButton
|
||||
@class="chat-message-action"
|
||||
@translatedLabel={{button.name}}
|
||||
@icon={{button.icon}}
|
||||
@actionParam={{button.id}}
|
||||
@action={{action
|
||||
this.actAndCloseMenu
|
||||
(get @messageActions button.id)
|
||||
<div class="chat-message-actions">
|
||||
<div class="selected-message-container">
|
||||
<div class="selected-message">
|
||||
<ChatUserAvatar @user={{this.message.user}} />
|
||||
<span
|
||||
{{on "touchstart" this.expandReply passive=true}}
|
||||
role="button"
|
||||
class={{concat-class
|
||||
"selected-message-reply"
|
||||
(if this.hasExpandedReply "is-expanded")
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
|
||||
{{#if (or @messageCapabilities.canReact @messageCapabilities.canReply)}}
|
||||
<div class="main-actions">
|
||||
{{#if @messageCapabilities.canReact}}
|
||||
{{#each @emojiReactions as |reaction|}}
|
||||
<ChatMessageReaction
|
||||
@reaction={{reaction}}
|
||||
@react={{@messageActions.react}}
|
||||
@showCount={{false}}
|
||||
/>
|
||||
{{/each}}
|
||||
|
||||
<DButton
|
||||
@class="btn-flat react-btn"
|
||||
@action={{action
|
||||
this.actAndCloseMenu
|
||||
@messageActions.startReactionForMessageActions
|
||||
}}
|
||||
@icon="discourse-emojis"
|
||||
@title="chat.react"
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if @messageCapabilities.canBookmark}}
|
||||
<DButton
|
||||
@class="btn-flat bookmark-btn"
|
||||
@action={{@messageActions.toggleBookmark}}
|
||||
>
|
||||
<BookmarkIcon @bookmark={{@message.bookmark}} />
|
||||
</DButton>
|
||||
{{/if}}
|
||||
|
||||
{{#if @messageCapabilities.canReply}}
|
||||
<DButton
|
||||
@class="chat-message-action reply-btn btn-flat"
|
||||
@action={{action "actAndCloseMenu" @messageActions.reply}}
|
||||
@icon="reply"
|
||||
@title="chat.reply"
|
||||
/>
|
||||
{{/if}}
|
||||
{{this.message.message}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<ul class="secondary-actions">
|
||||
{{#each this.messageInteractor.secondaryButtons as |button|}}
|
||||
<li class="chat-message-action-item" data-id={{button.id}}>
|
||||
<DButton
|
||||
@class="chat-message-action"
|
||||
@translatedLabel={{button.name}}
|
||||
@icon={{button.icon}}
|
||||
@actionParam={{button.id}}
|
||||
@action={{action this.actAndCloseMenu button.id}}
|
||||
/>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
|
||||
{{#if
|
||||
(or this.messageInteractor.canReact this.messageInteractor.canReply)
|
||||
}}
|
||||
<div class="main-actions">
|
||||
{{#if this.messageInteractor.canReact}}
|
||||
{{#each this.messageInteractor.emojiReactions as |reaction|}}
|
||||
<ChatMessageReaction
|
||||
@reaction={{reaction}}
|
||||
@onReaction={{this.messageInteractor.react}}
|
||||
@message={{this.message}}
|
||||
@showCount={{false}}
|
||||
/>
|
||||
{{/each}}
|
||||
|
||||
<DButton
|
||||
@class="btn-flat react-btn"
|
||||
@action={{this.openEmojiPicker}}
|
||||
@icon="discourse-emojis"
|
||||
@title="chat.react"
|
||||
@forwardEvent={{true}}
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.messageInteractor.canBookmark}}
|
||||
<DButton
|
||||
@class="btn-flat bookmark-btn"
|
||||
@action={{action this.actAndCloseMenu "toggleBookmark"}}
|
||||
>
|
||||
<BookmarkIcon @bookmark={{this.message.bookmark}} />
|
||||
</DButton>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.messageInteractor.canReply}}
|
||||
<DButton
|
||||
@class="chat-message-action reply-btn btn-flat"
|
||||
@action={{action this.actAndCloseMenu "reply"}}
|
||||
@icon="reply"
|
||||
@title="chat.reply"
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
|
@ -1,4 +1,6 @@
|
|||
import Component from "@glimmer/component";
|
||||
import ChatMessageInteractor from "discourse/plugins/chat/discourse/lib/chat-message-interactor";
|
||||
import { getOwner } from "discourse-common/lib/get-owner";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import discourseLater from "discourse-common/lib/later";
|
||||
import { action } from "@ember/object";
|
||||
|
@ -6,6 +8,8 @@ import { isTesting } from "discourse-common/config/environment";
|
|||
import { inject as service } from "@ember/service";
|
||||
|
||||
export default class ChatMessageActionsMobile extends Component {
|
||||
@service chat;
|
||||
@service site;
|
||||
@service capabilities;
|
||||
|
||||
@tracked hasExpandedReply = false;
|
||||
|
@ -13,6 +17,20 @@ export default class ChatMessageActionsMobile extends Component {
|
|||
|
||||
messageActions = null;
|
||||
|
||||
get message() {
|
||||
return this.chat.activeMessage.model;
|
||||
}
|
||||
|
||||
get messageInteractor() {
|
||||
const activeMessage = this.chat.activeMessage;
|
||||
|
||||
return new ChatMessageInteractor(
|
||||
getOwner(this),
|
||||
activeMessage.model,
|
||||
activeMessage.context
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
fadeAndVibrate() {
|
||||
discourseLater(this.#addFadeIn.bind(this));
|
||||
|
@ -35,8 +53,14 @@ export default class ChatMessageActionsMobile extends Component {
|
|||
}
|
||||
|
||||
@action
|
||||
actAndCloseMenu(fn) {
|
||||
fn?.();
|
||||
actAndCloseMenu(fnId) {
|
||||
this.messageInteractor[fnId]();
|
||||
this.#onCloseMenu();
|
||||
}
|
||||
|
||||
@action
|
||||
openEmojiPicker(_, event) {
|
||||
this.messageInteractor.openEmojiPicker(_, event);
|
||||
this.#onCloseMenu();
|
||||
}
|
||||
|
||||
|
@ -52,7 +76,7 @@ export default class ChatMessageActionsMobile extends Component {
|
|||
|
||||
// by ensuring we are not hovering any message anymore
|
||||
// we also ensure the menu is fully removed
|
||||
this.args.onHoverMessage?.(null);
|
||||
this.chat.activeMessage = null;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
|
|
|
@ -28,7 +28,7 @@ export default class ChatMessageInReplyToIndicator extends Component {
|
|||
|
||||
get hasThread() {
|
||||
return (
|
||||
this.args.message?.channel?.get("threading_enabled") &&
|
||||
this.args.message?.channel?.threadingEnabled &&
|
||||
this.args.message?.threadId
|
||||
);
|
||||
}
|
||||
|
|
|
@ -52,10 +52,11 @@ export default class ChatMessageReaction extends Component {
|
|||
|
||||
@action
|
||||
handleClick() {
|
||||
this.args.react?.(
|
||||
this.args.onReaction?.(
|
||||
this.args.reaction.emoji,
|
||||
this.args.reaction.reacted ? "remove" : "add"
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,57 +1,23 @@
|
|||
{{! template-lint-disable no-invalid-interactive }}
|
||||
|
||||
<ChatMessageSeparatorDate @message={{@message}} />
|
||||
<ChatMessageSeparatorNew @message={{@message}} />
|
||||
|
||||
{{#if
|
||||
(and
|
||||
this.showActions this.site.mobileView this.chatMessageActionsMobileAnchor
|
||||
)
|
||||
}}
|
||||
{{#in-element this.chatMessageActionsMobileAnchor}}
|
||||
<ChatMessageActionsMobile
|
||||
@message={{@message}}
|
||||
@emojiReactions={{this.emojiReactions}}
|
||||
@secondaryButtons={{this.secondaryButtons}}
|
||||
@messageActions={{this.messageActions}}
|
||||
@messageCapabilities={{this.messageCapabilities}}
|
||||
@onHoverMessage={{@onHoverMessage}}
|
||||
/>
|
||||
{{/in-element}}
|
||||
{{/if}}
|
||||
|
||||
{{#if
|
||||
(and
|
||||
this.showActions this.site.desktopView this.chatMessageActionsDesktopAnchor
|
||||
)
|
||||
}}
|
||||
{{#in-element this.chatMessageActionsDesktopAnchor}}
|
||||
<ChatMessageActionsDesktop
|
||||
@message={{@message}}
|
||||
@emojiReactions={{this.emojiReactions}}
|
||||
@secondaryButtons={{this.secondaryButtons}}
|
||||
@messageActions={{this.messageActions}}
|
||||
@messageCapabilities={{this.messageCapabilities}}
|
||||
/>
|
||||
{{/in-element}}
|
||||
{{#if (eq @context "channel")}}
|
||||
<ChatMessageSeparatorDate @message={{@message}} />
|
||||
<ChatMessageSeparatorNew @message={{@message}} />
|
||||
{{/if}}
|
||||
|
||||
<div
|
||||
{{will-destroy this.teardownChatMessage}}
|
||||
{{did-insert this.setMessageActionsAnchors}}
|
||||
{{did-insert this.decorateCookedMessage}}
|
||||
{{did-update this.decorateCookedMessage @message.id}}
|
||||
{{did-update this.decorateCookedMessage @message.version}}
|
||||
{{on "touchmove" this.handleTouchMove passive=true}}
|
||||
{{on "touchstart" this.handleTouchStart passive=true}}
|
||||
{{on "touchend" this.handleTouchEnd passive=true}}
|
||||
{{on "mouseenter" (fn @onHoverMessage @message (hash desktopOnly=true))}}
|
||||
{{on "mousemove" (fn @onHoverMessage @message (hash desktopOnly=true))}}
|
||||
{{on "mouseleave" (fn @onHoverMessage null (hash desktopOnly=true))}}
|
||||
{{on "mouseenter" this.onMouseEnter}}
|
||||
{{on "mouseleave" this.onMouseLeave}}
|
||||
class={{concat-class
|
||||
"chat-message-container"
|
||||
(if @isHovered "is-hovered")
|
||||
(if @selectingMessages "selecting-messages")
|
||||
(if this.pane.selectingMessages "selecting-messages")
|
||||
(if @message.highlighted "highlighted")
|
||||
}}
|
||||
data-id={{@message.id}}
|
||||
|
@ -63,7 +29,7 @@
|
|||
}}
|
||||
>
|
||||
{{#if this.show}}
|
||||
{{#if @selectingMessages}}
|
||||
{{#if this.pane.selectingMessages}}
|
||||
<Input
|
||||
@type="checkbox"
|
||||
class="chat-message-selector"
|
||||
|
@ -98,7 +64,6 @@
|
|||
(if this.hideUserInfo "user-info-hidden")
|
||||
(if @message.error "errored")
|
||||
(if @message.bookmark "chat-message-bookmarked")
|
||||
(if @isHovered "chat-message-selected")
|
||||
}}
|
||||
>
|
||||
{{#unless this.hideReplyToInfo}}
|
||||
|
@ -132,18 +97,20 @@
|
|||
{{#each @message.reactions as |reaction|}}
|
||||
<ChatMessageReaction
|
||||
@reaction={{reaction}}
|
||||
@react={{this.react}}
|
||||
@onReaction={{this.messageInteractor.react}}
|
||||
@message={{@message}}
|
||||
@showTooltip={{true}}
|
||||
/>
|
||||
{{/each}}
|
||||
|
||||
{{#if @canInteractWithChat}}
|
||||
{{#if this.chat.userCanInteractWithChat}}
|
||||
{{#unless this.site.mobileView}}
|
||||
<DButton
|
||||
@class="chat-message-react-btn"
|
||||
@action={{this.startReactionForReactionList}}
|
||||
@action={{this.messageInteractor.openEmojiPicker}}
|
||||
@icon="discourse-emojis"
|
||||
@title="chat.react"
|
||||
@forwardEvent={{true}}
|
||||
/>
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
|
|
|
@ -1,22 +1,17 @@
|
|||
import Bookmark from "discourse/models/bookmark";
|
||||
import { openBookmarkModal } from "discourse/controllers/bookmark";
|
||||
import { isTesting } from "discourse-common/config/environment";
|
||||
import Component from "@glimmer/component";
|
||||
import I18n from "I18n";
|
||||
import getURL from "discourse-common/lib/get-url";
|
||||
import optionalService from "discourse/lib/optional-service";
|
||||
import { action } from "@ember/object";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { cancel, schedule } from "@ember/runloop";
|
||||
import { clipboardCopy } from "discourse/lib/utilities";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import discourseLater from "discourse-common/lib/later";
|
||||
import isZoomed from "discourse/plugins/chat/discourse/lib/zoom-check";
|
||||
import showModal from "discourse/lib/show-modal";
|
||||
import ChatMessageFlag from "discourse/plugins/chat/discourse/lib/chat-message-flag";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import ChatMessageReaction from "discourse/plugins/chat/discourse/models/chat-message-reaction";
|
||||
import { getOwner } from "discourse-common/lib/get-owner";
|
||||
import ChatMessageInteractor from "discourse/plugins/chat/discourse/lib/chat-message-interactor";
|
||||
import discourseDebounce from "discourse-common/lib/debounce";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
|
||||
let _chatMessageDecorators = [];
|
||||
|
||||
|
@ -29,8 +24,7 @@ export function resetChatMessageDecorators() {
|
|||
}
|
||||
|
||||
export const MENTION_KEYWORDS = ["here", "all"];
|
||||
|
||||
export const REACTIONS = { add: "add", remove: "remove" };
|
||||
export const MESSAGE_CONTEXT_THREAD = "thread";
|
||||
|
||||
export default class ChatMessage extends Component {
|
||||
@service site;
|
||||
|
@ -42,21 +36,25 @@ export default class ChatMessage extends Component {
|
|||
@service chatApi;
|
||||
@service chatEmojiReactionStore;
|
||||
@service chatEmojiPickerManager;
|
||||
@service chatChannelPane;
|
||||
@service chatChannelThreadPane;
|
||||
@service chatChannelsManager;
|
||||
@service router;
|
||||
|
||||
@tracked chatMessageActionsMobileAnchor = null;
|
||||
@tracked chatMessageActionsDesktopAnchor = null;
|
||||
|
||||
@optionalService adminTools;
|
||||
|
||||
cachedFavoritesReactions = null;
|
||||
reacting = false;
|
||||
get pane() {
|
||||
return this.args.context === MESSAGE_CONTEXT_THREAD
|
||||
? this.chatChannelThreadPane
|
||||
: this.chatChannelPane;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.cachedFavoritesReactions = this.chatEmojiReactionStore.favorites;
|
||||
get messageInteractor() {
|
||||
return new ChatMessageInteractor(
|
||||
getOwner(this),
|
||||
this.args.message,
|
||||
this.args.context
|
||||
);
|
||||
}
|
||||
|
||||
get deletedAndCollapsed() {
|
||||
|
@ -72,15 +70,17 @@ export default class ChatMessage extends Component {
|
|||
}
|
||||
|
||||
@action
|
||||
setMessageActionsAnchors() {
|
||||
schedule("afterRender", () => {
|
||||
this.chatMessageActionsDesktopAnchor = document.querySelector(
|
||||
".chat-message-actions-desktop-anchor"
|
||||
);
|
||||
this.chatMessageActionsMobileAnchor = document.querySelector(
|
||||
".chat-message-actions-mobile-anchor"
|
||||
);
|
||||
});
|
||||
expand() {
|
||||
this.args.message.expanded = true;
|
||||
}
|
||||
|
||||
@action
|
||||
toggleChecked(event) {
|
||||
if (event.shiftKey) {
|
||||
this.messageInteractor.bulkSelect(event.target.checked);
|
||||
}
|
||||
|
||||
this.messageInteractor.select(event.target.checked);
|
||||
}
|
||||
|
||||
@action
|
||||
|
@ -108,114 +108,6 @@ export default class ChatMessage extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
get showActions() {
|
||||
return (
|
||||
this.args.canInteractWithChat &&
|
||||
!this.args.message?.staged &&
|
||||
this.args.isHovered
|
||||
);
|
||||
}
|
||||
|
||||
get secondaryButtons() {
|
||||
const buttons = [];
|
||||
|
||||
buttons.push({
|
||||
id: "copyLinkToMessage",
|
||||
name: I18n.t("chat.copy_link"),
|
||||
icon: "link",
|
||||
});
|
||||
|
||||
if (this.showEditButton) {
|
||||
buttons.push({
|
||||
id: "edit",
|
||||
name: I18n.t("chat.edit"),
|
||||
icon: "pencil-alt",
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.args.selectingMessages) {
|
||||
buttons.push({
|
||||
id: "selectMessage",
|
||||
name: I18n.t("chat.select"),
|
||||
icon: "tasks",
|
||||
});
|
||||
}
|
||||
|
||||
if (this.canFlagMessage) {
|
||||
buttons.push({
|
||||
id: "flag",
|
||||
name: I18n.t("chat.flag"),
|
||||
icon: "flag",
|
||||
});
|
||||
}
|
||||
|
||||
if (this.showDeleteButton) {
|
||||
buttons.push({
|
||||
id: "deleteMessage",
|
||||
name: I18n.t("chat.delete"),
|
||||
icon: "trash-alt",
|
||||
});
|
||||
}
|
||||
|
||||
if (this.showRestoreButton) {
|
||||
buttons.push({
|
||||
id: "restore",
|
||||
name: I18n.t("chat.restore"),
|
||||
icon: "undo",
|
||||
});
|
||||
}
|
||||
|
||||
if (this.showRebakeButton) {
|
||||
buttons.push({
|
||||
id: "rebakeMessage",
|
||||
name: I18n.t("chat.rebake_message"),
|
||||
icon: "sync-alt",
|
||||
});
|
||||
}
|
||||
|
||||
if (this.hasThread) {
|
||||
buttons.push({
|
||||
id: "openThread",
|
||||
name: I18n.t("chat.threads.open"),
|
||||
icon: "puzzle-piece",
|
||||
});
|
||||
}
|
||||
|
||||
return buttons;
|
||||
}
|
||||
|
||||
get messageActions() {
|
||||
return {
|
||||
reply: this.reply,
|
||||
react: this.react,
|
||||
copyLinkToMessage: this.copyLinkToMessage,
|
||||
edit: this.edit,
|
||||
selectMessage: this.selectMessage,
|
||||
flag: this.flag,
|
||||
deleteMessage: this.deleteMessage,
|
||||
restore: this.restore,
|
||||
rebakeMessage: this.rebakeMessage,
|
||||
toggleBookmark: this.toggleBookmark,
|
||||
openThread: this.openThread,
|
||||
startReactionForMessageActions: this.startReactionForMessageActions,
|
||||
};
|
||||
}
|
||||
|
||||
get messageCapabilities() {
|
||||
return {
|
||||
canReact: this.canReact,
|
||||
canReply: this.canReply,
|
||||
canBookmark: this.showBookmarkButton,
|
||||
hasThread: this.canReply && this.hasThread,
|
||||
};
|
||||
}
|
||||
|
||||
get hasThread() {
|
||||
return (
|
||||
this.args.channel?.get("threading_enabled") && this.args.message?.threadId
|
||||
);
|
||||
}
|
||||
|
||||
get show() {
|
||||
return (
|
||||
!this.args.message?.deletedAt ||
|
||||
|
@ -225,6 +117,58 @@ export default class ChatMessage extends Component {
|
|||
);
|
||||
}
|
||||
|
||||
@action
|
||||
onMouseEnter() {
|
||||
if (this.site.mobileView) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.pane.hoveredMessageId === this.args.message.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._onHoverMessageDebouncedHandler = discourseDebounce(
|
||||
this,
|
||||
this._debouncedOnHoverMessage,
|
||||
250
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
onMouseLeave(event) {
|
||||
if (this.site.mobileView) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(event.toElement || event.relatedTarget)?.closest(
|
||||
".chat-message-actions-container"
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
cancel(this._onHoverMessageDebouncedHandler);
|
||||
|
||||
this.chat.activeMessage = null;
|
||||
}
|
||||
|
||||
@bind
|
||||
_debouncedOnHoverMessage() {
|
||||
if (!this.chat.userCanInteractWithChat) {
|
||||
return;
|
||||
}
|
||||
this._setActiveMessage();
|
||||
}
|
||||
|
||||
_setActiveMessage() {
|
||||
this.chat.activeMessage = {
|
||||
model: this.args.message,
|
||||
context: this.args.context,
|
||||
};
|
||||
this.pane.hoveredMessageId = this.args.message.id;
|
||||
}
|
||||
|
||||
@action
|
||||
handleTouchStart() {
|
||||
// if zoomed don't track long press
|
||||
|
@ -232,24 +176,20 @@ export default class ChatMessage extends Component {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!this.args.isHovered) {
|
||||
// when testing this must be triggered immediately because there
|
||||
// is no concept of "long press" there, the Ember `tap` test helper
|
||||
// does send the touchstart/touchend events but immediately, see
|
||||
// https://github.com/emberjs/ember-test-helpers/blob/master/API.md#tap
|
||||
if (isTesting()) {
|
||||
this._handleLongPress();
|
||||
}
|
||||
|
||||
this._isPressingHandler = discourseLater(this._handleLongPress, 500);
|
||||
// when testing this must be triggered immediately because there
|
||||
// is no concept of "long press" there, the Ember `tap` test helper
|
||||
// does send the touchstart/touchend events but immediately, see
|
||||
// https://github.com/emberjs/ember-test-helpers/blob/master/API.md#tap
|
||||
if (isTesting()) {
|
||||
this._handleLongPress();
|
||||
}
|
||||
|
||||
this._isPressingHandler = discourseLater(this._handleLongPress, 500);
|
||||
}
|
||||
|
||||
@action
|
||||
handleTouchMove() {
|
||||
if (!this.args.isHovered) {
|
||||
cancel(this._isPressingHandler);
|
||||
}
|
||||
cancel(this._isPressingHandler);
|
||||
}
|
||||
|
||||
@action
|
||||
|
@ -267,7 +207,7 @@ export default class ChatMessage extends Component {
|
|||
document.activeElement.blur();
|
||||
document.querySelector(".chat-composer-input")?.blur();
|
||||
|
||||
this.args.onHoverMessage?.(this.args.message);
|
||||
this._setActiveMessage();
|
||||
}
|
||||
|
||||
get hideUserInfo() {
|
||||
|
@ -297,81 +237,12 @@ export default class ChatMessage extends Component {
|
|||
|
||||
get hideReplyToInfo() {
|
||||
return (
|
||||
this.args.context === MESSAGE_CONTEXT_THREAD ||
|
||||
this.args.message?.inReplyTo?.id ===
|
||||
this.args.message?.previousMessage?.id
|
||||
this.args.message?.previousMessage?.id
|
||||
);
|
||||
}
|
||||
|
||||
get showEditButton() {
|
||||
return (
|
||||
!this.args.message?.deletedAt &&
|
||||
this.currentUser?.id === this.args.message?.user?.id &&
|
||||
this.args.channel?.canModifyMessages?.(this.currentUser)
|
||||
);
|
||||
}
|
||||
|
||||
get canFlagMessage() {
|
||||
return (
|
||||
this.currentUser?.id !== this.args.message?.user?.id &&
|
||||
!this.args.channel?.isDirectMessageChannel &&
|
||||
this.args.message?.userFlagStatus === undefined &&
|
||||
this.args.channel?.canFlag &&
|
||||
!this.args.message?.chatWebhookEvent &&
|
||||
!this.args.message?.deletedAt
|
||||
);
|
||||
}
|
||||
|
||||
get canManageDeletion() {
|
||||
return this.currentUser?.id === this.args.message.user.id
|
||||
? this.args.channel?.canDeleteSelf
|
||||
: this.args.channel?.canDeleteOthers;
|
||||
}
|
||||
|
||||
get canReply() {
|
||||
return (
|
||||
!this.args.message?.deletedAt &&
|
||||
this.args.channel?.canModifyMessages?.(this.currentUser)
|
||||
);
|
||||
}
|
||||
|
||||
get canReact() {
|
||||
return (
|
||||
!this.args.message?.deletedAt &&
|
||||
this.args.channel?.canModifyMessages?.(this.currentUser)
|
||||
);
|
||||
}
|
||||
|
||||
get showDeleteButton() {
|
||||
return (
|
||||
this.canManageDeletion &&
|
||||
!this.args.message?.deletedAt &&
|
||||
this.args.channel?.canModifyMessages?.(this.currentUser)
|
||||
);
|
||||
}
|
||||
|
||||
get showRestoreButton() {
|
||||
return (
|
||||
this.canManageDeletion &&
|
||||
this.args.message?.deletedAt &&
|
||||
this.args.channel?.canModifyMessages?.(this.currentUser)
|
||||
);
|
||||
}
|
||||
|
||||
get showBookmarkButton() {
|
||||
return this.args.channel?.canModifyMessages?.(this.currentUser);
|
||||
}
|
||||
|
||||
get showRebakeButton() {
|
||||
return (
|
||||
this.currentUser?.staff &&
|
||||
this.args.channel?.canModifyMessages?.(this.currentUser)
|
||||
);
|
||||
}
|
||||
|
||||
get hasReactions() {
|
||||
return Object.values(this.args.message.reactions).some((r) => r.count > 0);
|
||||
}
|
||||
|
||||
get mentionWarning() {
|
||||
return this.args.message.mentionWarning;
|
||||
}
|
||||
|
@ -447,261 +318,4 @@ export default class ChatMessage extends Component {
|
|||
dismissMentionWarning() {
|
||||
this.args.message.mentionWarning = null;
|
||||
}
|
||||
|
||||
@action
|
||||
startReactionForMessageActions() {
|
||||
this.chatEmojiPickerManager.startFromMessageActions(
|
||||
this.args.message,
|
||||
this.selectReaction,
|
||||
{ desktop: this.site.desktopView }
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
startReactionForReactionList() {
|
||||
this.chatEmojiPickerManager.startFromMessageReactionList(
|
||||
this.args.message,
|
||||
this.selectReaction,
|
||||
{ desktop: this.site.desktopView }
|
||||
);
|
||||
}
|
||||
|
||||
deselectReaction(emoji) {
|
||||
if (!this.args.canInteractWithChat) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.react(emoji, REACTIONS.remove);
|
||||
}
|
||||
|
||||
@action
|
||||
selectReaction(emoji) {
|
||||
if (!this.args.canInteractWithChat) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.react(emoji, REACTIONS.add);
|
||||
}
|
||||
|
||||
@action
|
||||
react(emoji, reactAction) {
|
||||
if (!this.args.canInteractWithChat) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.reacting) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.capabilities.canVibrate && !isTesting()) {
|
||||
navigator.vibrate(5);
|
||||
}
|
||||
|
||||
if (this.site.mobileView) {
|
||||
this.args.onHoverMessage(null);
|
||||
}
|
||||
|
||||
if (reactAction === REACTIONS.add) {
|
||||
this.chatEmojiReactionStore.track(`:${emoji}:`);
|
||||
}
|
||||
|
||||
this.reacting = true;
|
||||
|
||||
this.args.message.react(
|
||||
emoji,
|
||||
reactAction,
|
||||
this.currentUser,
|
||||
this.currentUser.id
|
||||
);
|
||||
|
||||
return ajax(
|
||||
`/chat/${this.args.message.channelId}/react/${this.args.message.id}`,
|
||||
{
|
||||
type: "PUT",
|
||||
data: {
|
||||
react_action: reactAction,
|
||||
emoji,
|
||||
},
|
||||
}
|
||||
)
|
||||
.catch((errResult) => {
|
||||
popupAjaxError(errResult);
|
||||
this.args.message.react(
|
||||
emoji,
|
||||
REACTIONS.remove,
|
||||
this.currentUser,
|
||||
this.currentUser.id
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
this.reacting = false;
|
||||
});
|
||||
}
|
||||
|
||||
// TODO(roman): For backwards-compatibility.
|
||||
// Remove after the 3.0 release.
|
||||
_legacyFlag() {
|
||||
this.dialog.yesNoConfirm({
|
||||
message: I18n.t("chat.confirm_flag", {
|
||||
username: this.args.message.user?.username,
|
||||
}),
|
||||
didConfirm: () => {
|
||||
return ajax("/chat/flag", {
|
||||
method: "PUT",
|
||||
data: {
|
||||
chat_message_id: this.args.message.id,
|
||||
flag_type_id: 7, // notify_moderators
|
||||
},
|
||||
}).catch(popupAjaxError);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
reply() {
|
||||
this.args.setReplyTo(this.args.message.id);
|
||||
}
|
||||
|
||||
@action
|
||||
edit() {
|
||||
this.args.editButtonClicked(this.args.message.id);
|
||||
}
|
||||
|
||||
@action
|
||||
flag() {
|
||||
const targetFlagSupported =
|
||||
requirejs.entries["discourse/lib/flag-targets/flag"];
|
||||
|
||||
if (targetFlagSupported) {
|
||||
const model = this.args.message;
|
||||
model.username = model.user?.username;
|
||||
model.user_id = model.user?.id;
|
||||
let controller = showModal("flag", { model });
|
||||
controller.set("flagTarget", new ChatMessageFlag());
|
||||
} else {
|
||||
this._legacyFlag();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
expand() {
|
||||
this.args.message.expanded = true;
|
||||
}
|
||||
|
||||
@action
|
||||
restore() {
|
||||
return ajax(
|
||||
`/chat/${this.args.message.channelId}/restore/${this.args.message.id}`,
|
||||
{
|
||||
type: "PUT",
|
||||
}
|
||||
).catch(popupAjaxError);
|
||||
}
|
||||
|
||||
@action
|
||||
openThread() {
|
||||
this.router.transitionTo("chat.channel.thread", this.args.message.threadId);
|
||||
}
|
||||
|
||||
@action
|
||||
toggleBookmark() {
|
||||
return openBookmarkModal(
|
||||
this.args.message.bookmark ||
|
||||
Bookmark.createFor(
|
||||
this.currentUser,
|
||||
"Chat::Message",
|
||||
this.args.message.id
|
||||
),
|
||||
{
|
||||
onAfterSave: (savedData) => {
|
||||
const bookmark = Bookmark.create(savedData);
|
||||
this.args.message.bookmark = bookmark;
|
||||
this.appEvents.trigger(
|
||||
"bookmarks:changed",
|
||||
savedData,
|
||||
bookmark.attachedTo()
|
||||
);
|
||||
},
|
||||
onAfterDelete: () => {
|
||||
this.args.message.bookmark = null;
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
rebakeMessage() {
|
||||
return ajax(
|
||||
`/chat/${this.args.message.channelId}/${this.args.message.id}/rebake`,
|
||||
{
|
||||
type: "PUT",
|
||||
}
|
||||
).catch(popupAjaxError);
|
||||
}
|
||||
|
||||
@action
|
||||
deleteMessage() {
|
||||
return this.chatApi
|
||||
.trashMessage(this.args.message.channelId, this.args.message.id)
|
||||
.catch(popupAjaxError);
|
||||
}
|
||||
|
||||
@action
|
||||
selectMessage() {
|
||||
this.args.message.selected = true;
|
||||
this.args.onStartSelectingMessages(this.args.message);
|
||||
}
|
||||
|
||||
@action
|
||||
toggleChecked(e) {
|
||||
if (e.shiftKey) {
|
||||
this.args.bulkSelectMessages(this.args.message, e.target.checked);
|
||||
}
|
||||
|
||||
this.args.onSelectMessage(this.args.message);
|
||||
}
|
||||
|
||||
@action
|
||||
copyLinkToMessage() {
|
||||
if (!this.messageContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.messageContainer
|
||||
.querySelector(".link-to-message-btn")
|
||||
?.classList?.add("copied");
|
||||
|
||||
const { protocol, host } = window.location;
|
||||
let url = getURL(
|
||||
`/chat/c/-/${this.args.message.channelId}/${this.args.message.id}`
|
||||
);
|
||||
url = url.indexOf("/") === 0 ? protocol + "//" + host + url : url;
|
||||
clipboardCopy(url);
|
||||
|
||||
discourseLater(() => {
|
||||
this.messageContainer
|
||||
?.querySelector(".link-to-message-btn")
|
||||
?.classList?.remove("copied");
|
||||
}, 250);
|
||||
}
|
||||
|
||||
get emojiReactions() {
|
||||
let favorites = this.cachedFavoritesReactions;
|
||||
|
||||
// may be a {} if no defaults defined in some production builds
|
||||
if (!favorites || !favorites.slice) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return favorites.slice(0, 3).map((emoji) => {
|
||||
return (
|
||||
this.args.message.reactions.find(
|
||||
(reaction) => reaction.emoji === emoji
|
||||
) ||
|
||||
ChatMessageReaction.create({
|
||||
emoji,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,11 +10,13 @@ import { schedule } from "@ember/runloop";
|
|||
import { inject as service } from "@ember/service";
|
||||
import getURL from "discourse-common/lib/get-url";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
import { MESSAGE_CONTEXT_THREAD } from "discourse/plugins/chat/discourse/components/chat-message";
|
||||
|
||||
export default class AdminCustomizeColorsShowController extends Component {
|
||||
export default class ChatSelectionManager extends Component {
|
||||
@service router;
|
||||
tagName = "";
|
||||
chatChannel = null;
|
||||
context = null;
|
||||
selectedMessageIds = null;
|
||||
chatCopySuccess = false;
|
||||
showChatCopySuccess = false;
|
||||
|
@ -28,7 +30,9 @@ export default class AdminCustomizeColorsShowController extends Component {
|
|||
@computed("chatChannel.isDirectMessageChannel", "chatChannel.canModerate")
|
||||
get showMoveMessageButton() {
|
||||
return (
|
||||
!this.chatChannel.isDirectMessageChannel && this.chatChannel.canModerate
|
||||
this.context !== MESSAGE_CONTEXT_THREAD &&
|
||||
!this.chatChannel.isDirectMessageChannel &&
|
||||
this.chatChannel.canModerate
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -2,61 +2,58 @@
|
|||
class={{concat-class "chat-thread" (if this.loading "loading")}}
|
||||
data-id={{this.thread.id}}
|
||||
{{did-insert this.loadMessages}}
|
||||
{{did-update this.thread.id this.loadMessages}}
|
||||
>
|
||||
<div class="chat-thread__header">
|
||||
<div class="chat-thread__info">
|
||||
<div class="chat-thread__title">
|
||||
<h2>{{this.title}}</h2>
|
||||
<span class="chat-thread__label">{{i18n "chat.thread.label"}}</span>
|
||||
<LinkTo
|
||||
class="chat-thread__close"
|
||||
@route="chat.channel"
|
||||
@models={{this.chat.activeChannel.routeModels}}
|
||||
>
|
||||
{{d-icon "times"}}
|
||||
</LinkTo>
|
||||
</div>
|
||||
|
||||
<LinkTo
|
||||
class="chat-thread__close"
|
||||
@route="chat.channel"
|
||||
@models={{this.chat.activeChannel.routeModels}}
|
||||
>
|
||||
{{d-icon "times"}}
|
||||
</LinkTo>
|
||||
</div>
|
||||
|
||||
<p class="chat-thread__om">
|
||||
{{replace-emoji this.thread.originalMessage.excerpt}}
|
||||
</p>
|
||||
|
||||
<div class="chat-thread__omu">
|
||||
<span class="chat-thread__started-by">{{i18n
|
||||
"chat.threads.started_by"
|
||||
}}</span>
|
||||
<ChatMessageAvatar
|
||||
class="chat-thread__omu-avatar"
|
||||
@message={{this.thread.originalMessage}}
|
||||
<div class="chat-thread__body" {{did-insert this.setScrollable}}>
|
||||
<div
|
||||
class="chat-thread__messages chat-messages-container"
|
||||
{{chat/on-resize this.didResizePane (hash delay=10)}}
|
||||
>
|
||||
{{#each this.thread.messages key="id" as |message|}}
|
||||
<ChatMessage
|
||||
@message={{message}}
|
||||
@channel={{this.channel}}
|
||||
@resendStagedMessage={{this.resendStagedMessage}}
|
||||
@messageDidEnterViewport={{this.messageDidEnterViewport}}
|
||||
@messageDidLeaveViewport={{this.messageDidLeaveViewport}}
|
||||
@context="thread"
|
||||
/>
|
||||
<span
|
||||
class="chat-thread__omu-username"
|
||||
>{{this.thread.originalMessageUser.username}}</span>
|
||||
</div>
|
||||
{{/each}}
|
||||
{{#if (or this.loading this.loadingMoreFuture)}}
|
||||
<ChatSkeleton />
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-thread__messages">
|
||||
<ul>
|
||||
{{#each this.thread.messages as |message|}}
|
||||
<li><strong>{{message.user.username}}</strong>: {{message.message}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{#if (or this.loading this.loadingMoreFuture)}}
|
||||
<ChatSkeleton />
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<ChatComposer
|
||||
@canInteractWithChat="true"
|
||||
@sendMessage={{this.sendMessage}}
|
||||
@editMessage={{this.editMessage}}
|
||||
@setReplyTo={{this.setReplyTo}}
|
||||
@loading={{this.sendingLoading}}
|
||||
@editingMessage={{readonly this.editingMessage}}
|
||||
@onCancelEditing={{this.cancelEditing}}
|
||||
@setInReplyToMsg={{this.setInReplyToMsg}}
|
||||
@onEditLastMessageRequested={{this.editLastMessageRequested}}
|
||||
@onValueChange={{this.composerValueChanged}}
|
||||
@chatChannel={{this.channel}}
|
||||
/>
|
||||
{{#if this.chatChannelThreadPane.selectingMessages}}
|
||||
<ChatSelectionManager
|
||||
@selectedMessageIds={{this.chatChannelThreadPane.selectedMessageIds}}
|
||||
@chatChannel={{this.chat.activeChannel}}
|
||||
@cancelSelecting={{action
|
||||
this.chatChannelThreadPane.cancelSelecting
|
||||
this.chat.activeChannel.selectedMessages
|
||||
}}
|
||||
@context="thread"
|
||||
/>
|
||||
{{else}}
|
||||
<ChatComposer
|
||||
@sendMessage={{this.sendMessage}}
|
||||
@onCancelEditing={{this.cancelEditing}}
|
||||
@chatChannel={{this.channel}}
|
||||
@composerService={{this.chatChannelThreadComposer}}
|
||||
@paneService={{this.chatChannelThreadPane}}
|
||||
@context="thread"
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
|
@ -6,8 +6,9 @@ import { action } from "@ember/object";
|
|||
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { bind, debounce } from "discourse-common/utils/decorators";
|
||||
import I18n from "I18n";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { schedule } from "@ember/runloop";
|
||||
import discourseLater from "discourse-common/lib/later";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
|
@ -18,11 +19,16 @@ export default class ChatThreadPanel extends Component {
|
|||
@service router;
|
||||
@service chatApi;
|
||||
@service chatComposerPresenceManager;
|
||||
@service chatChannelThreadComposer;
|
||||
@service chatChannelThreadPane;
|
||||
@service appEvents;
|
||||
@service capabilities;
|
||||
|
||||
@tracked loading;
|
||||
@tracked loadingMorePast;
|
||||
|
||||
scrollable = null;
|
||||
|
||||
get thread() {
|
||||
return this.channel.activeThread;
|
||||
}
|
||||
|
@ -31,12 +37,9 @@ export default class ChatThreadPanel extends Component {
|
|||
return this.chat.activeChannel;
|
||||
}
|
||||
|
||||
get title() {
|
||||
if (this.thread.title) {
|
||||
this.thread.escapedTitle;
|
||||
}
|
||||
|
||||
return I18n.t("chat.threads.op_said");
|
||||
@action
|
||||
setScrollable(element) {
|
||||
this.scrollable = element;
|
||||
}
|
||||
|
||||
@action
|
||||
|
@ -53,6 +56,11 @@ export default class ChatThreadPanel extends Component {
|
|||
// }
|
||||
}
|
||||
|
||||
@action
|
||||
didResizePane() {
|
||||
this.forceRendering();
|
||||
}
|
||||
|
||||
get _selfDeleted() {
|
||||
return this.isDestroying || this.isDestroyed;
|
||||
}
|
||||
|
@ -93,9 +101,6 @@ export default class ChatThreadPanel extends Component {
|
|||
const [messages, meta] = this.afterFetchCallback(this.channel, results);
|
||||
this.thread.messagesManager.addMessages(messages);
|
||||
|
||||
// TODO (martin) ECHO MODE
|
||||
this.channel.messagesManager.addMessages(messages);
|
||||
|
||||
// TODO (martin) details needed for thread??
|
||||
this.thread.details = meta;
|
||||
|
||||
|
@ -127,7 +132,6 @@ export default class ChatThreadPanel extends Component {
|
|||
@bind
|
||||
afterFetchCallback(channel, results) {
|
||||
const messages = [];
|
||||
let foundFirstNew = false;
|
||||
|
||||
results.chat_messages.forEach((messageData) => {
|
||||
// If a message has been hidden it is because the current user is ignoring
|
||||
|
@ -145,16 +149,6 @@ export default class ChatThreadPanel extends Component {
|
|||
messageData.expanded = !(messageData.hidden || messageData.deleted_at);
|
||||
}
|
||||
|
||||
// newest has to be in after fetch callback as we don't want to make it
|
||||
// dynamic or it will make the pane jump around, it will disappear on reload
|
||||
if (
|
||||
!foundFirstNew &&
|
||||
messageData.id > channel.currentUserMembership.last_read_message_id
|
||||
) {
|
||||
foundFirstNew = true;
|
||||
messageData.newest = true;
|
||||
}
|
||||
|
||||
messages.push(ChatMessage.create(channel, messageData));
|
||||
});
|
||||
|
||||
|
@ -165,11 +159,11 @@ export default class ChatThreadPanel extends Component {
|
|||
sendMessage(message, uploads = []) {
|
||||
// TODO (martin) For desktop notifications
|
||||
// resetIdle()
|
||||
if (this.sendingLoading) {
|
||||
if (this.chatChannelThreadPane.sendingLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendingLoading = true;
|
||||
this.chatChannelThreadPane.sendingLoading = true;
|
||||
this.channel.draft = ChatMessageDraft.create();
|
||||
|
||||
// TODO (martin) Handling case when channel is not followed???? IDK if we
|
||||
|
@ -199,8 +193,7 @@ export default class ChatThreadPanel extends Component {
|
|||
thread_id: stagedMessage.threadId,
|
||||
})
|
||||
.then(() => {
|
||||
// TODO (martin) Scrolling!!
|
||||
// this.scrollToBottom();
|
||||
this.scrollToBottom();
|
||||
})
|
||||
.catch((error) => {
|
||||
this.#onSendError(stagedMessage.stagedId, error);
|
||||
|
@ -209,35 +202,70 @@ export default class ChatThreadPanel extends Component {
|
|||
if (this._selfDeleted) {
|
||||
return;
|
||||
}
|
||||
this.sendingLoading = false;
|
||||
this.#resetAfterSend();
|
||||
this.chatChannelThreadPane.sendingLoading = false;
|
||||
this.chatChannelThreadPane.resetAfterSend();
|
||||
});
|
||||
}
|
||||
|
||||
// A more consistent way to scroll to the bottom when we are sure this is our goal
|
||||
// it will also limit issues with any element changing the height while we are scrolling
|
||||
// to the bottom
|
||||
@action
|
||||
editMessage() {}
|
||||
// editMessage(chatMessage, newContent, uploads) {}
|
||||
scrollToBottom() {
|
||||
if (!this.scrollable) {
|
||||
return;
|
||||
}
|
||||
|
||||
@action
|
||||
setReplyTo() {}
|
||||
// setReplyTo(messageId) {}
|
||||
this.scrollable.scrollTop = -1;
|
||||
this.forceRendering(() => {
|
||||
this.scrollable.scrollTop = 0;
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
setInReplyToMsg(inReplyMsg) {
|
||||
this.replyToMsg = inReplyMsg;
|
||||
// since -webkit-overflow-scrolling: touch can't be used anymore to disable momentum scrolling
|
||||
// we now use this hack to disable it
|
||||
@bind
|
||||
forceRendering(callback) {
|
||||
schedule("afterRender", () => {
|
||||
if (this._selfDeleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.scrollable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.capabilities.isIOS) {
|
||||
this.scrollable.style.overflow = "hidden";
|
||||
}
|
||||
|
||||
callback?.();
|
||||
|
||||
if (this.capabilities.isIOS) {
|
||||
discourseLater(() => {
|
||||
if (!this.scrollable) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.scrollable.style.overflow = "auto";
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
cancelEditing() {
|
||||
this.editingMessage = null;
|
||||
resendStagedMessage() {}
|
||||
// resendStagedMessage(stagedMessage) {}
|
||||
|
||||
@action
|
||||
messageDidEnterViewport(message) {
|
||||
message.visible = true;
|
||||
}
|
||||
|
||||
@action
|
||||
editLastMessageRequested() {}
|
||||
|
||||
@action
|
||||
composerValueChanged() {}
|
||||
// composerValueChanged(value, uploads, replyToMsg) {}
|
||||
messageDidLeaveViewport(message) {
|
||||
message.visible = false;
|
||||
}
|
||||
|
||||
#handleErrors(error) {
|
||||
switch (error?.jqXHR?.status) {
|
||||
|
@ -262,17 +290,6 @@ export default class ChatThreadPanel extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
this.#resetAfterSend();
|
||||
}
|
||||
|
||||
#resetAfterSend() {
|
||||
if (this._selfDeleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.replyToMsg = null;
|
||||
this.editingMessage = null;
|
||||
this.chatComposerPresenceManager.notifyState(this.channel.id, false);
|
||||
this.appEvents.trigger("chat-composer:reply-to-set", null);
|
||||
this.chatChannelThreadPane.resetAfterSend();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
<ChatChannelMessageEmojiPicker />
|
|
@ -0,0 +1 @@
|
|||
<ChatMessageActionsDesktop />
|
|
@ -0,0 +1 @@
|
|||
<ChatMessageActionsMobile />
|
|
@ -60,11 +60,27 @@ export default {
|
|||
class: "chat-emoji-btn",
|
||||
icon: "discourse-emojis",
|
||||
position: "dropdown",
|
||||
context: "channel",
|
||||
action() {
|
||||
const chatEmojiPickerManager = container.lookup(
|
||||
"service:chat-emoji-picker-manager"
|
||||
);
|
||||
chatEmojiPickerManager.startFromComposer(this.didSelectEmoji);
|
||||
chatEmojiPickerManager.open({ context: "channel" });
|
||||
},
|
||||
});
|
||||
|
||||
api.registerChatComposerButton({
|
||||
label: "chat.emoji",
|
||||
id: "channel-emoji",
|
||||
class: "chat-emoji-btn",
|
||||
icon: "discourse-emojis",
|
||||
position: "dropdown",
|
||||
context: "thread",
|
||||
action() {
|
||||
const chatEmojiPickerManager = container.lookup(
|
||||
"service:chat-emoji-picker-manager"
|
||||
);
|
||||
chatEmojiPickerManager.open({ context: "thread" });
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -66,54 +66,60 @@ export function chatComposerButtonsDependentKeys() {
|
|||
);
|
||||
}
|
||||
|
||||
export function chatComposerButtons(context, position) {
|
||||
export function chatComposerButtons(composer, position, context) {
|
||||
return Object.values(_chatComposerButtons)
|
||||
.filter(
|
||||
(button) =>
|
||||
computeButton(context, button, "displayed") &&
|
||||
computeButton(context, button, "position") === position
|
||||
)
|
||||
.filter((button) => {
|
||||
let valid =
|
||||
computeButton(composer, button, "displayed") &&
|
||||
computeButton(composer, button, "position") === position;
|
||||
|
||||
if (button.context) {
|
||||
valid = valid && computeButton(composer, button, "context") === context;
|
||||
}
|
||||
|
||||
return valid;
|
||||
})
|
||||
.map((button) => {
|
||||
const result = { id: button.id };
|
||||
|
||||
const label = computeButton(context, button, "label");
|
||||
const label = computeButton(composer, button, "label");
|
||||
result.label = label
|
||||
? label
|
||||
: computeButton(context, button, "translatedLabel");
|
||||
: computeButton(composer, button, "translatedLabel");
|
||||
|
||||
const ariaLabel = computeButton(context, button, "ariaLabel");
|
||||
const ariaLabel = computeButton(composer, button, "ariaLabel");
|
||||
if (ariaLabel) {
|
||||
result.ariaLabel = I18n.t(ariaLabel);
|
||||
} else {
|
||||
const translatedAriaLabel = computeButton(
|
||||
context,
|
||||
composer,
|
||||
button,
|
||||
"translatedAriaLabel"
|
||||
);
|
||||
result.ariaLabel = translatedAriaLabel || result.label;
|
||||
}
|
||||
|
||||
const title = computeButton(context, button, "title");
|
||||
const title = computeButton(composer, button, "title");
|
||||
result.title = title
|
||||
? I18n.t(title)
|
||||
: computeButton(context, button, "translatedTitle");
|
||||
: computeButton(composer, button, "translatedTitle");
|
||||
|
||||
result.classNames = (
|
||||
computeButton(context, button, "classNames") || []
|
||||
computeButton(composer, button, "classNames") || []
|
||||
).join(" ");
|
||||
|
||||
result.icon = computeButton(context, button, "icon");
|
||||
result.disabled = computeButton(context, button, "disabled");
|
||||
result.priority = computeButton(context, button, "priority");
|
||||
result.icon = computeButton(composer, button, "icon");
|
||||
result.disabled = computeButton(composer, button, "disabled");
|
||||
result.priority = computeButton(composer, button, "priority");
|
||||
|
||||
if (isFunction(button.action)) {
|
||||
result.action = () => {
|
||||
button.action.apply(context);
|
||||
button.action.apply(composer);
|
||||
};
|
||||
} else {
|
||||
const actionName = button.action;
|
||||
result.action = () => {
|
||||
context[actionName]();
|
||||
composer[actionName]();
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -66,6 +66,10 @@ export default class ChatChannel extends RestModel {
|
|||
threadsManager = new ChatThreadsManager(getOwner(this));
|
||||
messagesManager = new ChatMessagesManager(getOwner(this));
|
||||
|
||||
findIndexOfMessage(message) {
|
||||
return this.messages.findIndex((m) => m.id === message.id);
|
||||
}
|
||||
|
||||
get messages() {
|
||||
return this.messagesManager.messages;
|
||||
}
|
||||
|
@ -90,6 +94,10 @@ export default class ChatChannel extends RestModel {
|
|||
return [this.slugifiedTitle, this.id];
|
||||
}
|
||||
|
||||
get selectedMessages() {
|
||||
return this.messages.filter((message) => message.selected);
|
||||
}
|
||||
|
||||
get isDirectMessageChannel() {
|
||||
return this.chatableType === CHATABLE_TYPES.directMessageChannel;
|
||||
}
|
||||
|
@ -186,6 +194,7 @@ ChatChannel.reopenClass({
|
|||
this._remapKey(args, "chatable_type", "chatableType");
|
||||
this._remapKey(args, "memberships_count", "membershipsCount");
|
||||
this._remapKey(args, "last_message_sent_at", "lastMessageSentAt");
|
||||
this._remapKey(args, "threading_enabled", "threadingEnabled");
|
||||
|
||||
return this._super(args);
|
||||
},
|
||||
|
|
|
@ -2,6 +2,8 @@ import { tracked } from "@glimmer/tracking";
|
|||
import User from "discourse/models/user";
|
||||
import { TrackedArray } from "@ember-compat/tracked-built-ins";
|
||||
|
||||
export const REACTIONS = { add: "add", remove: "remove" };
|
||||
|
||||
export default class ChatMessageReaction {
|
||||
static create(args = {}) {
|
||||
return new ChatMessageReaction(args);
|
||||
|
|
|
@ -56,19 +56,20 @@ export default class ChatMessage {
|
|||
this.firstOfResults = args.firstOfResults;
|
||||
this.staged = args.staged;
|
||||
this.edited = args.edited;
|
||||
this.availableFlags = args.available_flags;
|
||||
this.availableFlags = args.availableFlags || args.available_flags;
|
||||
this.hidden = args.hidden;
|
||||
this.threadId = args.thread_id;
|
||||
this.channelId = args.chat_channel_id;
|
||||
this.chatWebhookEvent = args.chat_webhook_event;
|
||||
this.createdAt = args.created_at;
|
||||
this.deletedAt = args.deleted_at;
|
||||
this.threadId = args.threadId || args.thread_id;
|
||||
this.channelId = args.channelId || args.chat_channel_id;
|
||||
this.chatWebhookEvent = args.chatWebhookEvent || args.chat_webhook_event;
|
||||
this.createdAt = args.createdAt || args.created_at;
|
||||
this.deletedAt = args.deletedAt || args.deleted_at;
|
||||
this.excerpt = args.excerpt;
|
||||
this.reviewableId = args.reviewable_id;
|
||||
this.userFlagStatus = args.user_flag_status;
|
||||
this.inReplyTo = args.in_reply_to
|
||||
? ChatMessage.create(channel, args.in_reply_to)
|
||||
: null;
|
||||
this.reviewableId = args.reviewableId || args.reviewable_id;
|
||||
this.userFlagStatus = args.userFlagStatus || args.user_flag_status;
|
||||
this.inReplyTo =
|
||||
args.inReplyTo || args.in_reply_to
|
||||
? ChatMessage.create(channel, args.in_reply_to)
|
||||
: null;
|
||||
this.message = args.message;
|
||||
this.cooked = args.cooked || ChatMessage.cookFunction(this.message);
|
||||
this.reactions = this.#initChatMessageReactionModel(
|
||||
|
|
|
@ -20,12 +20,10 @@ export default class ChatThread {
|
|||
constructor(args = {}) {
|
||||
this.title = args.title;
|
||||
this.id = args.id;
|
||||
this.channelId = args.channel_id;
|
||||
this.status = args.status;
|
||||
|
||||
this.originalMessageUser = this.#initUserModel(args.original_message_user);
|
||||
|
||||
// TODO (martin) Not sure if ChatMessage is needed here, original_message
|
||||
// only has a small subset of message stuff.
|
||||
this.originalMessage = args.original_message;
|
||||
this.originalMessage.user = this.originalMessageUser;
|
||||
}
|
||||
|
@ -38,6 +36,10 @@ export default class ChatThread {
|
|||
this.messagesManager.messages = messages;
|
||||
}
|
||||
|
||||
get selectedMessages() {
|
||||
return this.messages.filter((message) => message.selected);
|
||||
}
|
||||
|
||||
get escapedTitle() {
|
||||
return escapeExpression(this.title);
|
||||
}
|
||||
|
|
|
@ -14,6 +14,10 @@ export default function withChatChannel(extendedClass) {
|
|||
this.controllerFor("chat-channel").set("targetMessageId", null);
|
||||
this.chat.activeChannel = model;
|
||||
|
||||
if (!model) {
|
||||
return this.router.replaceWith("chat");
|
||||
}
|
||||
|
||||
let { messageId, channelTitle } = this.paramsFor(this.routeName);
|
||||
|
||||
// messageId query param backwards-compatibility
|
||||
|
|
|
@ -22,6 +22,7 @@ export default class ChatRoute extends DiscourseRoute {
|
|||
|
||||
const INTERCEPTABLE_ROUTES = [
|
||||
"chat.channel",
|
||||
"chat.channel.thread",
|
||||
"chat.channel.index",
|
||||
"chat.channel.near-message",
|
||||
"chat.channel-legacy",
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
import ChatEmojiPickerManager from "./chat-emoji-picker-manager";
|
||||
|
||||
export default class ChatChannelEmojiPickerManager extends ChatEmojiPickerManager {}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -2,11 +2,21 @@ import Service, { inject as service } from "@ember/service";
|
|||
import { tracked } from "@glimmer/tracking";
|
||||
import ChatDrawerDraftChannel from "discourse/plugins/chat/discourse/components/chat-drawer/draft-channel";
|
||||
import ChatDrawerChannel from "discourse/plugins/chat/discourse/components/chat-drawer/channel";
|
||||
import ChatDrawerThread from "discourse/plugins/chat/discourse/components/chat-drawer/thread";
|
||||
import ChatDrawerIndex from "discourse/plugins/chat/discourse/components/chat-drawer/index";
|
||||
|
||||
const COMPONENTS_MAP = {
|
||||
"chat.draft-channel": { name: ChatDrawerDraftChannel },
|
||||
"chat.channel": { name: ChatDrawerChannel },
|
||||
"chat.channel.thread": {
|
||||
name: ChatDrawerThread,
|
||||
extractParams: (route) => {
|
||||
return {
|
||||
channelId: route.parent.params.channelId,
|
||||
threadId: route.params.threadId,
|
||||
};
|
||||
},
|
||||
},
|
||||
chat: { name: ChatDrawerIndex },
|
||||
"chat.channel.near-message": {
|
||||
name: ChatDrawerChannel,
|
||||
|
|
|
@ -1,49 +1,42 @@
|
|||
import { headerOffset } from "discourse/lib/offset-calculator";
|
||||
import { createPopper } from "@popperjs/core";
|
||||
import Service from "@ember/service";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
import { later, schedule } from "@ember/runloop";
|
||||
import { later } from "@ember/runloop";
|
||||
import { makeArray } from "discourse-common/lib/helpers";
|
||||
import { Promise } from "rsvp";
|
||||
import { computed } from "@ember/object";
|
||||
import { isTesting } from "discourse-common/config/environment";
|
||||
import { action } from "@ember/object";
|
||||
import Service, { inject as service } from "@ember/service";
|
||||
|
||||
const TRANSITION_TIME = isTesting() ? 0 : 125; // CSS transition time
|
||||
const DEFAULT_VISIBLE_SECTIONS = ["favorites", "smileys_&_emotion"];
|
||||
const DEFAULT_LAST_SECTION = "favorites";
|
||||
|
||||
export default class ChatEmojiPickerManager extends Service {
|
||||
@tracked opened = false;
|
||||
@service appEvents;
|
||||
|
||||
@tracked closing = false;
|
||||
@tracked loading = false;
|
||||
@tracked context = null;
|
||||
@tracked picker = null;
|
||||
@tracked emojis = null;
|
||||
@tracked visibleSections = DEFAULT_VISIBLE_SECTIONS;
|
||||
@tracked lastVisibleSection = DEFAULT_LAST_SECTION;
|
||||
@tracked initialFilter = null;
|
||||
@tracked element = null;
|
||||
@tracked callback;
|
||||
|
||||
@computed("emojis.[]", "loading")
|
||||
get sections() {
|
||||
return !this.loading && this.emojis ? Object.keys(this.emojis) : [];
|
||||
}
|
||||
|
||||
@bind
|
||||
closeExisting() {
|
||||
this.callback = null;
|
||||
this.opened = false;
|
||||
this.initialFilter = null;
|
||||
this.visibleSections = DEFAULT_VISIBLE_SECTIONS;
|
||||
this.lastVisibleSection = DEFAULT_LAST_SECTION;
|
||||
this.picker = null;
|
||||
}
|
||||
|
||||
@bind
|
||||
close() {
|
||||
this.callback = null;
|
||||
this.closing = true;
|
||||
|
||||
later(() => {
|
||||
|
@ -53,9 +46,8 @@ export default class ChatEmojiPickerManager extends Service {
|
|||
|
||||
this.visibleSections = DEFAULT_VISIBLE_SECTIONS;
|
||||
this.lastVisibleSection = DEFAULT_LAST_SECTION;
|
||||
this.initialFilter = null;
|
||||
this.closing = false;
|
||||
this.opened = false;
|
||||
this.picker = null;
|
||||
}, TRANSITION_TIME);
|
||||
}
|
||||
|
||||
|
@ -65,80 +57,23 @@ export default class ChatEmojiPickerManager extends Service {
|
|||
.uniq();
|
||||
}
|
||||
|
||||
didSelectEmoji(emoji) {
|
||||
this?.callback(emoji);
|
||||
this.callback = null;
|
||||
this.close();
|
||||
}
|
||||
open(picker) {
|
||||
this.loadEmojis();
|
||||
|
||||
startFromMessageReactionList(message, callback, options = {}) {
|
||||
const trigger = document.querySelector(
|
||||
`.chat-message-container[data-id="${message.id}"] .chat-message-react-btn`
|
||||
);
|
||||
this.startFromMessage(callback, trigger, options);
|
||||
}
|
||||
|
||||
startFromMessageActions(message, callback, options = {}) {
|
||||
const trigger = document.querySelector(
|
||||
`.chat-message-actions-container[data-id="${message.id}"] .chat-message-actions`
|
||||
);
|
||||
this.startFromMessage(callback, trigger, options);
|
||||
}
|
||||
|
||||
startFromMessage(
|
||||
callback,
|
||||
trigger,
|
||||
options = { filter: null, desktop: true }
|
||||
) {
|
||||
this.initialFilter = options.filter;
|
||||
this.context = "chat-message";
|
||||
this.element = document.querySelector(".chat-message-emoji-picker-anchor");
|
||||
this.open(callback);
|
||||
this._popper?.destroy();
|
||||
|
||||
if (options.desktop) {
|
||||
schedule("afterRender", () => {
|
||||
this._popper = createPopper(trigger, this.element, {
|
||||
placement: "top",
|
||||
modifiers: [
|
||||
{
|
||||
name: "eventListeners",
|
||||
options: {
|
||||
scroll: false,
|
||||
resize: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "flip",
|
||||
options: {
|
||||
padding: { top: headerOffset() },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
if (this.picker) {
|
||||
if (this.picker.trigger === picker.trigger) {
|
||||
this.closeExisting();
|
||||
} else {
|
||||
this.closeExisting();
|
||||
this.picker = picker;
|
||||
}
|
||||
} else {
|
||||
this.picker = picker;
|
||||
}
|
||||
}
|
||||
|
||||
startFromComposer(callback, options = { filter: null }) {
|
||||
this.initialFilter = options.filter;
|
||||
this.context = "chat-composer";
|
||||
this.element = document.querySelector(".chat-composer-emoji-picker-anchor");
|
||||
this.open(callback);
|
||||
}
|
||||
|
||||
open(callback) {
|
||||
if (this.opened) {
|
||||
this.closeExisting();
|
||||
}
|
||||
|
||||
this._loadEmojisData();
|
||||
|
||||
this.callback = callback;
|
||||
this.opened = true;
|
||||
}
|
||||
|
||||
_loadEmojisData() {
|
||||
@action
|
||||
loadEmojis() {
|
||||
if (this.emojis) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import { and } from "@ember/object/computed";
|
|||
import { computed } from "@ember/object";
|
||||
import discourseLater from "discourse-common/lib/later";
|
||||
import ChatMessageDraft from "discourse/plugins/chat/discourse/models/chat-message-draft";
|
||||
import { MESSAGE_CONTEXT_THREAD } from "discourse/plugins/chat/discourse/components/chat-message";
|
||||
|
||||
const CHAT_ONLINE_OPTIONS = {
|
||||
userUnseenTime: 300000, // 5 minutes seconds with no interaction
|
||||
|
@ -26,6 +27,9 @@ export default class Chat extends Service {
|
|||
@service site;
|
||||
|
||||
@service chatChannelsManager;
|
||||
@service chatChannelPane;
|
||||
@service chatChannelThreadPane;
|
||||
|
||||
@tracked activeChannel = null;
|
||||
|
||||
cook = null;
|
||||
|
@ -35,6 +39,8 @@ export default class Chat extends Service {
|
|||
|
||||
@and("currentUser.has_chat_enabled", "siteSettings.chat_enabled") userCanChat;
|
||||
|
||||
@tracked _activeMessage = null;
|
||||
|
||||
@computed("currentUser.staff", "currentUser.groups.[]")
|
||||
get userCanDirectMessage() {
|
||||
if (!this.currentUser) {
|
||||
|
@ -51,6 +57,32 @@ export default class Chat extends Service {
|
|||
);
|
||||
}
|
||||
|
||||
@computed("activeChannel.userSilenced")
|
||||
get userCanInteractWithChat() {
|
||||
return !this.activeChannel?.userSilenced;
|
||||
}
|
||||
|
||||
get activeMessage() {
|
||||
return this._activeMessage;
|
||||
}
|
||||
|
||||
set activeMessage(hash) {
|
||||
this.chatChannelPane.hoveredMessageId = null;
|
||||
this.chatChannelThreadPane.hoveredMessageId = null;
|
||||
|
||||
if (hash) {
|
||||
this._activeMessage = hash;
|
||||
|
||||
if (hash.context === MESSAGE_CONTEXT_THREAD) {
|
||||
this.chatChannelThreadPane.hoveredMessageId = hash.model.id;
|
||||
} else {
|
||||
this.chatChannelPane.hoveredMessageId = hash.model.id;
|
||||
}
|
||||
} else {
|
||||
this._activeMessage = null;
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
super.init(...arguments);
|
||||
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
{{#if
|
||||
(and this.chatEmojiPickerManager.opened this.chatEmojiPickerManager.element)
|
||||
}}
|
||||
{{#in-element this.chatEmojiPickerManager.element}}
|
||||
<ChatEmojiPicker />
|
||||
{{/in-element}}
|
||||
{{/if}}
|
|
@ -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);
|
||||
},
|
||||
};
|
|
@ -5,7 +5,7 @@ $float-height: 530px;
|
|||
--full-page-border-radius: 12px;
|
||||
--full-page-sidebar-width: 275px;
|
||||
--channel-list-avatar-size: 30px;
|
||||
--chat-header-offset: 65px;
|
||||
--chat-header-offset: 50px;
|
||||
}
|
||||
|
||||
.chat-message-move-to-channel-modal-modal {
|
||||
|
@ -289,9 +289,6 @@ $float-height: 530px;
|
|||
z-index: 1;
|
||||
margin: 0 1px 0 0;
|
||||
will-change: transform;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
@include chat-scrollbar();
|
||||
|
||||
.join-channel-btn.in-float {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
.chat-composer-dropdown {
|
||||
[data-theme="chat-composer-drodown"] {
|
||||
margin-left: 0.2rem;
|
||||
|
||||
.tippy-content {
|
||||
|
@ -7,7 +7,7 @@
|
|||
}
|
||||
|
||||
.chat-composer-dropdown__trigger-btn {
|
||||
padding: 5px;
|
||||
padding: 5px !important; // overwrite ios rule
|
||||
border-radius: 100%;
|
||||
background: var(--primary-med-or-secondary-high);
|
||||
border: 1px solid transparent;
|
||||
|
@ -30,20 +30,11 @@
|
|||
}
|
||||
|
||||
.chat-composer-dropdown__list {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.chat-composer-dropdown__item {
|
||||
padding-bottom: 0.25rem;
|
||||
|
||||
&:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-composer-dropdown__action-btn {
|
||||
background: none;
|
||||
width: 100%;
|
||||
|
|
|
@ -103,6 +103,8 @@
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@include chat-scrollbar();
|
||||
}
|
||||
|
||||
&__unreliable-network {
|
||||
|
|
|
@ -54,9 +54,11 @@
|
|||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
text-transform: capitalize;
|
||||
@include chat-scrollbar();
|
||||
margin: 1px;
|
||||
}
|
||||
|
||||
&__no-reults {
|
||||
&__no-results {
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
|
@ -197,12 +199,13 @@
|
|||
}
|
||||
}
|
||||
|
||||
.chat-message-emoji-picker-anchor {
|
||||
z-index: z("header") + 1;
|
||||
.chat-channel-message-emoji-picker-connector {
|
||||
position: relative;
|
||||
|
||||
.chat-emoji-picker {
|
||||
border: 1px solid var(--primary-low);
|
||||
width: 320px;
|
||||
z-index: z("header") + 1;
|
||||
|
||||
.emoji {
|
||||
width: 22px;
|
||||
|
@ -210,31 +213,3 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-view {
|
||||
.chat-message-emoji-picker-anchor.-opened {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
box-shadow: shadowcreatePopper("card");
|
||||
|
||||
.chat-emoji-picker {
|
||||
height: 50vh;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-composer-container.with-emoji-picker {
|
||||
background: var(--primary-very-low);
|
||||
|
||||
.chat-emoji-picker {
|
||||
border-bottom: 1px solid var(--primary-low);
|
||||
|
||||
&.closing {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
|
||||
.chat-message-actions-container {
|
||||
@include unselectable;
|
||||
position: relative;
|
||||
z-index: z("dropdown") - 1;
|
||||
}
|
||||
|
||||
.chat-message-actions {
|
||||
|
@ -47,6 +47,10 @@
|
|||
width: 2.5em;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
|
||||
> * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
.d-icon {
|
||||
color: var(--primary);
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
width: var(--message-left-width);
|
||||
}
|
||||
|
||||
.chat-message-container.is-hovered .chat-message-left-gutter {
|
||||
.chat-message-container:hover .chat-message-left-gutter {
|
||||
.chat-time {
|
||||
color: var(--secondary-mediumy);
|
||||
}
|
||||
|
|
|
@ -131,6 +131,10 @@
|
|||
background: none;
|
||||
border: none;
|
||||
|
||||
> * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.d-icon {
|
||||
color: var(--primary-high);
|
||||
}
|
||||
|
@ -181,26 +185,44 @@
|
|||
}
|
||||
|
||||
.chat-messages-container {
|
||||
.not-mobile-device & .chat-message:hover,
|
||||
.chat-message.chat-message-selected {
|
||||
background: var(--primary-very-low);
|
||||
}
|
||||
|
||||
.chat-message.chat-message-bookmarked {
|
||||
background: var(--highlight-bg);
|
||||
|
||||
&:hover {
|
||||
background: var(--highlight-medium);
|
||||
.chat-message {
|
||||
&.chat-message-bookmarked {
|
||||
background: var(--highlight-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.not-mobile-device & .chat-message-reaction-list .chat-message-react-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.not-mobile-device & .chat-message:hover {
|
||||
.chat-message-reaction-list .chat-message-react-btn {
|
||||
display: inline-block;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.touch & {
|
||||
&:active {
|
||||
background: var(--primary-very-low);
|
||||
}
|
||||
|
||||
&.chat-message-bookmarked {
|
||||
&:active {
|
||||
background: var(--highlight-medium);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-touch & {
|
||||
&:hover,
|
||||
&:active {
|
||||
background: var(--primary-very-low);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.chat-message-react-btn {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
&.chat-message-bookmarked {
|
||||
&:hover {
|
||||
background: var(--highlight-medium);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -222,15 +244,6 @@
|
|||
font-style: italic;
|
||||
}
|
||||
|
||||
.chat-message-container.is-hovered,
|
||||
.chat-message.chat-message-selected {
|
||||
background: var(--primary-very-low);
|
||||
}
|
||||
|
||||
.chat-message.chat-message-bookmarked {
|
||||
background: var(--highlight-bg);
|
||||
}
|
||||
|
||||
.has-full-page-chat .chat-message .onebox:not(img),
|
||||
.chat-drawer-container .chat-message .onebox {
|
||||
margin: 0.5em 0;
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
grid-area: threads;
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
border-left: 1px solid var(--primary-medium);
|
||||
border-left: 1px solid var(--primary-low);
|
||||
|
||||
&__list {
|
||||
flex-grow: 1;
|
||||
|
|
|
@ -1,11 +1,31 @@
|
|||
.chat-thread {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-block: 1rem;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
&__header {
|
||||
height: var(--chat-header-offset);
|
||||
min-height: var(--chat-header-offset);
|
||||
border-bottom: 1px solid var(--primary-low);
|
||||
border-top: 1px solid var(--primary-low);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-inline: 1.5rem;
|
||||
}
|
||||
|
||||
&__body {
|
||||
overflow-y: scroll;
|
||||
@include chat-scrollbar();
|
||||
margin: 2px;
|
||||
padding-right: 2px;
|
||||
box-sizing: border-box;
|
||||
flex-grow: 1;
|
||||
overscroll-behavior: contain;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
&__close {
|
||||
|
@ -15,41 +35,4 @@
|
|||
color: var(--primary-medium);
|
||||
}
|
||||
}
|
||||
|
||||
&__info {
|
||||
padding-inline: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--primary-low);
|
||||
}
|
||||
|
||||
&__om {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&__omu {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.chat-message-avatar {
|
||||
width: var(--message-left-width);
|
||||
}
|
||||
}
|
||||
|
||||
&__started-by {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__messages {
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
overflow-y: scroll;
|
||||
padding-inline: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,3 +5,4 @@
|
|||
@import "chat-message-actions";
|
||||
@import "chat-message";
|
||||
@import "chat-selection-manager";
|
||||
@import "chat-emoji-picker";
|
||||
|
|
|
@ -532,8 +532,9 @@ en:
|
|||
search_placeholder: "Search by emoji name and alias..."
|
||||
no_results: "No results"
|
||||
|
||||
thread:
|
||||
label: Thread
|
||||
threads:
|
||||
op_said: "OP said:"
|
||||
started_by: "Started by"
|
||||
open: "Open Thread"
|
||||
|
||||
|
|
|
@ -75,13 +75,13 @@ module PageObjects
|
|||
def select_message(message)
|
||||
hover_message(message)
|
||||
click_more_button
|
||||
find("[data-value='selectMessage']").click
|
||||
find("[data-value='select']").click
|
||||
end
|
||||
|
||||
def delete_message(message)
|
||||
hover_message(message)
|
||||
click_more_button
|
||||
find("[data-value='deleteMessage']").click
|
||||
find("[data-value='delete']").click
|
||||
end
|
||||
|
||||
def open_edit_message(message)
|
||||
|
|
|
@ -30,6 +30,10 @@ module PageObjects
|
|||
def maximize
|
||||
find("#{VISIBLE_DRAWER} .chat-drawer-header__full-screen-btn").click
|
||||
end
|
||||
|
||||
def has_open_thread?(thread)
|
||||
has_css?("#{VISIBLE_DRAWER} .chat-thread[data-id='#{thread.id}']")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
RSpec.describe "React to message", type: :system, js: true do
|
||||
fab!(:current_user) { Fabricate(:user) }
|
||||
fab!(:other_user) { Fabricate(:user) }
|
||||
fab!(:category_channel_1) { Fabricate(:category_channel) }
|
||||
fab!(:message_1) { Fabricate(:chat_message, chat_channel: category_channel_1) }
|
||||
|
||||
|
@ -11,11 +12,12 @@ RSpec.describe "React to message", type: :system, js: true do
|
|||
before do
|
||||
chat_system_bootstrap
|
||||
category_channel_1.add(current_user)
|
||||
category_channel_1.add(other_user)
|
||||
end
|
||||
|
||||
context "when other user has reacted" do
|
||||
fab!(:reaction_1) do
|
||||
Chat::MessageReactor.new(Fabricate(:user), category_channel_1).react!(
|
||||
Chat::MessageReactor.new(other_user, category_channel_1).react!(
|
||||
message_id: message_1.id,
|
||||
react_action: :add,
|
||||
emoji: "female_detective",
|
||||
|
@ -48,7 +50,7 @@ RSpec.describe "React to message", type: :system, js: true do
|
|||
|
||||
context "when current user reacts" do
|
||||
fab!(:reaction_1) do
|
||||
Chat::MessageReactor.new(Fabricate(:user), category_channel_1).react!(
|
||||
Chat::MessageReactor.new(other_user, category_channel_1).react!(
|
||||
message_id: message_1.id,
|
||||
react_action: :add,
|
||||
emoji: "female_detective",
|
||||
|
@ -62,14 +64,14 @@ RSpec.describe "React to message", type: :system, js: true do
|
|||
chat.visit_channel(category_channel_1)
|
||||
channel.hover_message(message_1)
|
||||
find(".chat-message-react-btn").click
|
||||
find(".chat-emoji-picker [data-emoji=\"nerd_face\"]").click
|
||||
find(".chat-emoji-picker [data-emoji=\"grimacing\"]").click
|
||||
|
||||
expect(channel).to have_reaction(message_1, reaction_1.emoji)
|
||||
expect(channel).to have_reaction(message_1, "grimacing")
|
||||
end
|
||||
|
||||
context "when current user has multiple sessions" do
|
||||
it "adds reaction on each session" do
|
||||
reaction = OpenStruct.new(emoji: "nerd_face")
|
||||
reaction = OpenStruct.new(emoji: "grimacing")
|
||||
|
||||
using_session(:tab_1) do
|
||||
sign_in(current_user)
|
||||
|
|
|
@ -7,6 +7,7 @@ describe "Single thread in side panel", type: :system, js: true do
|
|||
let(:channel_page) { PageObjects::Pages::ChatChannel.new }
|
||||
let(:side_panel) { PageObjects::Pages::ChatSidePanel.new }
|
||||
let(:open_thread) { PageObjects::Pages::ChatThread.new }
|
||||
let(:chat_drawer_page) { PageObjects::Pages::ChatDrawer.new }
|
||||
|
||||
before do
|
||||
chat_system_bootstrap(current_user, [channel])
|
||||
|
@ -49,19 +50,27 @@ describe "Single thread in side panel", type: :system, js: true do
|
|||
|
||||
before { SiteSetting.enable_experimental_chat_threaded_discussions = true }
|
||||
|
||||
it "opens the single thread in the drawer from the message actions menu" do
|
||||
visit("/latest")
|
||||
chat_page.open_from_header
|
||||
chat_drawer_page.open_channel(channel)
|
||||
channel_page.open_message_thread(thread.chat_messages.order(:created_at).last)
|
||||
expect(chat_drawer_page).to have_open_thread(thread)
|
||||
end
|
||||
|
||||
it "opens the side panel for a single thread from the message actions menu" do
|
||||
chat_page.visit_channel(channel)
|
||||
channel_page.open_message_thread(thread.original_message)
|
||||
expect(side_panel).to have_open_thread(thread)
|
||||
end
|
||||
|
||||
it "shows the excerpt of the thread original message" do
|
||||
xit "shows the excerpt of the thread original message" do
|
||||
chat_page.visit_channel(channel)
|
||||
channel_page.open_message_thread(thread.original_message)
|
||||
expect(open_thread).to have_header_content(thread.excerpt)
|
||||
end
|
||||
|
||||
it "shows the avatar and username of the original message user" do
|
||||
xit "shows the avatar and username of the original message user" do
|
||||
chat_page.visit_channel(channel)
|
||||
channel_page.open_message_thread(thread.original_message)
|
||||
expect(open_thread.omu).to have_css(".chat-user-avatar img.avatar")
|
||||
|
|
|
@ -21,7 +21,7 @@ RSpec.describe "Quoting chat message transcripts", type: :system, js: true do
|
|||
chat_channel_page.message_by_id(message.id).hover
|
||||
expect(page).to have_css(".chat-message-actions .more-buttons")
|
||||
find(".chat-message-actions .more-buttons").click
|
||||
find(".select-kit-row[data-value=\"selectMessage\"]").click
|
||||
find(".select-kit-row[data-value=\"select\"]").click
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -209,7 +209,7 @@ RSpec.describe "Quoting chat message transcripts", type: :system, js: true do
|
|||
mobile: true do
|
||||
chat_page.visit_channel(chat_channel_1)
|
||||
|
||||
chat_channel_page.click_message_action_mobile(message_1, "selectMessage")
|
||||
chat_channel_page.click_message_action_mobile(message_1, "select")
|
||||
click_selection_button("quote")
|
||||
|
||||
expect(topic_page).to have_expanded_composer
|
||||
|
|
|
@ -4,6 +4,7 @@ import hbs from "htmlbars-inline-precompile";
|
|||
import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel";
|
||||
import { module, test } from "qunit";
|
||||
import { render } from "@ember/test-helpers";
|
||||
import pretender from "discourse/tests/helpers/create-pretender";
|
||||
|
||||
module(
|
||||
"Discourse Chat | Component | chat-composer placeholder",
|
||||
|
@ -11,6 +12,8 @@ module(
|
|||
setupRenderingTest(hooks);
|
||||
|
||||
test("direct message to self shows Jot something down", async function (assert) {
|
||||
pretender.get("/chat/emojis.json", () => [200, [], {}]);
|
||||
|
||||
this.currentUser.set("id", 1);
|
||||
this.set(
|
||||
"chatChannel",
|
||||
|
@ -31,6 +34,8 @@ module(
|
|||
});
|
||||
|
||||
test("direct message to multiple folks shows their names", async function (assert) {
|
||||
pretender.get("/chat/emojis.json", () => [200, [], {}]);
|
||||
|
||||
this.set(
|
||||
"chatChannel",
|
||||
ChatChannel.create({
|
||||
|
@ -54,6 +59,8 @@ module(
|
|||
});
|
||||
|
||||
test("message to channel shows send message to channel name", async function (assert) {
|
||||
pretender.get("/chat/emojis.json", () => [200, [], {}]);
|
||||
|
||||
this.set(
|
||||
"chatChannel",
|
||||
ChatChannel.create({
|
||||
|
|
|
@ -68,7 +68,7 @@ module("Discourse Chat | Component | chat-emoji-picker", function (hooks) {
|
|||
this.chatEmojiPickerManager = this.container.lookup(
|
||||
"service:chat-emoji-picker-manager"
|
||||
);
|
||||
this.chatEmojiPickerManager.startFromComposer(() => {});
|
||||
this.chatEmojiPickerManager.open(() => {});
|
||||
this.chatEmojiPickerManager.addVisibleSections([
|
||||
"smileys_&_emotion",
|
||||
"people_&_body",
|
||||
|
@ -164,10 +164,13 @@ module("Discourse Chat | Component | chat-emoji-picker", function (hooks) {
|
|||
|
||||
test("When selecting an emoji", async function (assert) {
|
||||
let selection;
|
||||
this.chatEmojiPickerManager.didSelectEmoji = (emoji) => {
|
||||
this.didSelectEmoji = (emoji) => {
|
||||
selection = emoji;
|
||||
};
|
||||
await render(hbs`<ChatEmojiPicker />`);
|
||||
|
||||
await render(
|
||||
hbs`<ChatEmojiPicker @didSelectEmoji={{this.didSelectEmoji}} />`
|
||||
);
|
||||
await click('img.emoji[data-emoji="grinning"]');
|
||||
|
||||
assert.strictEqual(selection, "grinning");
|
||||
|
@ -241,10 +244,13 @@ module("Discourse Chat | Component | chat-emoji-picker", function (hooks) {
|
|||
|
||||
test("When selecting a toned an emoji", async function (assert) {
|
||||
let selection;
|
||||
this.chatEmojiPickerManager.didSelectEmoji = (emoji) => {
|
||||
this.didSelectEmoji = (emoji) => {
|
||||
selection = emoji;
|
||||
};
|
||||
await render(hbs`<ChatEmojiPicker />`);
|
||||
|
||||
await render(
|
||||
hbs`<ChatEmojiPicker @didSelectEmoji={{this.didSelectEmoji}} />`
|
||||
);
|
||||
this.emojiReactionStore.diversity = 1;
|
||||
await click('img.emoji[data-emoji="man_rowing_boat"]');
|
||||
|
||||
|
|
|
@ -55,7 +55,7 @@ module("Discourse Chat | Component | chat-message-reaction", function (hooks) {
|
|||
});
|
||||
|
||||
await render(hbs`
|
||||
<ChatMessageReaction class="show" @reaction={{hash emoji="heart" count=this.count}} @react={{this.react}} />
|
||||
<ChatMessageReaction class="show" @reaction={{hash emoji="heart" count=this.count}} @onReaction={{this.react}} />
|
||||
`);
|
||||
|
||||
assert.false(exists(".chat-message-reaction .count"));
|
||||
|
|
|
@ -21,7 +21,6 @@ module("Discourse Chat | Component | chat-message", function (hooks) {
|
|||
unread_count: 0,
|
||||
muted: false,
|
||||
},
|
||||
canInteractWithChat: true,
|
||||
canDeleteSelf: true,
|
||||
canDeleteOthers: true,
|
||||
canFlag: true,
|
||||
|
@ -46,14 +45,7 @@ module("Discourse Chat | Component | chat-message", function (hooks) {
|
|||
)
|
||||
),
|
||||
chatChannel,
|
||||
setReplyTo: () => {},
|
||||
replyMessageClicked: () => {},
|
||||
editButtonClicked: () => {},
|
||||
afterExpand: () => {},
|
||||
selectingMessages: false,
|
||||
onStartSelectingMessages: () => {},
|
||||
onSelectMessage: () => {},
|
||||
bulkSelectMessages: () => {},
|
||||
onHoverMessage: () => {},
|
||||
messageDidEnterViewport: () => {},
|
||||
messageDidLeaveViewport: () => {},
|
||||
|
@ -63,16 +55,7 @@ module("Discourse Chat | Component | chat-message", function (hooks) {
|
|||
const template = hbs`
|
||||
<ChatMessage
|
||||
@message={{this.message}}
|
||||
@canInteractWithChat={{this.canInteractWithChat}}
|
||||
@channel={{this.chatChannel}}
|
||||
@setReplyTo={{this.setReplyTo}}
|
||||
@replyMessageClicked={{this.replyMessageClicked}}
|
||||
@editButtonClicked={{this.editButtonClicked}}
|
||||
@selectingMessages={{this.selectingMessages}}
|
||||
@onStartSelectingMessages={{this.onStartSelectingMessages}}
|
||||
@onSelectMessage={{this.onSelectMessage}}
|
||||
@bulkSelectMessages={{this.bulkSelectMessages}}
|
||||
@onHoverMessage={{this.onHoverMessage}}
|
||||
@messageDidEnterViewport={{this.messageDidEnterViewport}}
|
||||
@messageDidLeaveViewport={{this.messageDidLeaveViewport}}
|
||||
/>
|
||||
|
|
|
@ -22,46 +22,6 @@ module(
|
|||
this.manager.close();
|
||||
});
|
||||
|
||||
test("startFromMessageReactionList", async function (assert) {
|
||||
const callback = () => {};
|
||||
this.manager.startFromMessageReactionList({ id: 1 }, callback);
|
||||
|
||||
assert.ok(this.manager.loading);
|
||||
assert.ok(this.manager.opened);
|
||||
assert.strictEqual(this.manager.context, "chat-message");
|
||||
assert.strictEqual(this.manager.callback, callback);
|
||||
assert.deepEqual(this.manager.visibleSections, [
|
||||
"favorites",
|
||||
"smileys_&_emotion",
|
||||
]);
|
||||
assert.strictEqual(this.manager.lastVisibleSection, "favorites");
|
||||
|
||||
await settled();
|
||||
|
||||
assert.deepEqual(this.manager.emojis, emojisReponse());
|
||||
assert.strictEqual(this.manager.loading, false);
|
||||
});
|
||||
|
||||
test("startFromMessageActions", async function (assert) {
|
||||
const callback = () => {};
|
||||
this.manager.startFromMessageReactionList({ id: 1 }, callback);
|
||||
|
||||
assert.ok(this.manager.loading);
|
||||
assert.ok(this.manager.opened);
|
||||
assert.strictEqual(this.manager.context, "chat-message");
|
||||
assert.strictEqual(this.manager.callback, callback);
|
||||
assert.deepEqual(this.manager.visibleSections, [
|
||||
"favorites",
|
||||
"smileys_&_emotion",
|
||||
]);
|
||||
assert.strictEqual(this.manager.lastVisibleSection, "favorites");
|
||||
|
||||
await settled();
|
||||
|
||||
assert.deepEqual(this.manager.emojis, emojisReponse());
|
||||
assert.strictEqual(this.manager.loading, false);
|
||||
});
|
||||
|
||||
test("addVisibleSections", async function (assert) {
|
||||
this.manager.addVisibleSections(["favorites", "objects"]);
|
||||
|
||||
|
@ -75,7 +35,7 @@ module(
|
|||
test("sections", async function (assert) {
|
||||
assert.deepEqual(this.manager.sections, []);
|
||||
|
||||
this.manager.startFromComposer(() => {});
|
||||
this.manager.open({});
|
||||
|
||||
assert.deepEqual(this.manager.sections, []);
|
||||
|
||||
|
@ -84,14 +44,12 @@ module(
|
|||
assert.deepEqual(this.manager.sections, ["favorites"]);
|
||||
});
|
||||
|
||||
test("startFromComposer", async function (assert) {
|
||||
const callback = () => {};
|
||||
this.manager.startFromComposer(callback);
|
||||
test("open", async function (assert) {
|
||||
this.manager.open({ context: "chat-composer" });
|
||||
|
||||
assert.ok(this.manager.loading);
|
||||
assert.ok(this.manager.opened);
|
||||
assert.strictEqual(this.manager.context, "chat-composer");
|
||||
assert.strictEqual(this.manager.callback, callback);
|
||||
assert.ok(this.manager.picker);
|
||||
assert.strictEqual(this.manager.picker.context, "chat-composer");
|
||||
assert.deepEqual(this.manager.visibleSections, [
|
||||
"favorites",
|
||||
"smileys_&_emotion",
|
||||
|
@ -104,28 +62,16 @@ module(
|
|||
assert.strictEqual(this.manager.loading, false);
|
||||
});
|
||||
|
||||
test("startFromComposer with filter option", async function (assert) {
|
||||
const callback = () => {};
|
||||
this.manager.startFromComposer(callback, { filter: "foofilter" });
|
||||
await settled();
|
||||
|
||||
assert.strictEqual(this.manager.initialFilter, "foofilter");
|
||||
});
|
||||
|
||||
test("closeExisting", async function (assert) {
|
||||
const callback = () => {
|
||||
return;
|
||||
};
|
||||
|
||||
this.manager.startFromComposer(() => {});
|
||||
this.manager.open({ context: "channel-composer", trigger: "foo" });
|
||||
this.manager.addVisibleSections("objects");
|
||||
this.manager.lastVisibleSection = "objects";
|
||||
this.manager.startFromComposer(callback);
|
||||
this.manager.open({ context: "thread-composer", trigger: "bar" });
|
||||
|
||||
assert.strictEqual(
|
||||
this.manager.callback,
|
||||
callback,
|
||||
"it resets the callback to latest picker"
|
||||
this.manager.picker.context,
|
||||
"thread-composer",
|
||||
"it resets the picker to latest picker"
|
||||
);
|
||||
assert.deepEqual(
|
||||
this.manager.visibleSections,
|
||||
|
@ -139,39 +85,21 @@ module(
|
|||
);
|
||||
});
|
||||
|
||||
test("didSelectEmoji", async function (assert) {
|
||||
let value;
|
||||
const callback = (emoji) => {
|
||||
value = emoji.name;
|
||||
};
|
||||
this.manager.startFromComposer(callback);
|
||||
this.manager.didSelectEmoji({ name: "joy" });
|
||||
|
||||
assert.notOk(this.manager.callback);
|
||||
assert.strictEqual(value, "joy");
|
||||
|
||||
await settled();
|
||||
|
||||
assert.notOk(this.manager.opened, "it closes the picker after selection");
|
||||
});
|
||||
|
||||
test("close", async function (assert) {
|
||||
this.manager.startFromComposer(() => {});
|
||||
this.manager.open({ context: "channel-composer" });
|
||||
|
||||
assert.ok(this.manager.opened);
|
||||
assert.ok(this.manager.callback);
|
||||
assert.ok(this.manager.picker);
|
||||
|
||||
this.manager.addVisibleSections("objects");
|
||||
this.manager.lastVisibleSection = "objects";
|
||||
this.manager.close();
|
||||
|
||||
assert.notOk(this.manager.callback);
|
||||
assert.ok(this.manager.closing);
|
||||
assert.ok(this.manager.opened);
|
||||
assert.ok(this.manager.picker);
|
||||
|
||||
await settled();
|
||||
|
||||
assert.notOk(this.manager.opened);
|
||||
assert.notOk(this.manager.picker);
|
||||
assert.notOk(this.manager.closing);
|
||||
assert.deepEqual(
|
||||
this.manager.visibleSections,
|
||||
|
|
Loading…
Reference in New Issue