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

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

There are now several new classes and concepts:

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

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

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

Co-authored-by: Joffrey JAFFEUX <j.jaffeux@gmail.com>
This commit is contained in:
Martin Brennan 2023-04-06 23:19:52 +10:00 committed by GitHub
parent cee06bdc77
commit ea548292bc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
71 changed files with 2169 additions and 1887 deletions
plugins/chat
assets
javascripts/discourse
stylesheets
config/locales
spec/system
test/javascripts

View File

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

View File

@ -0,0 +1,51 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { headerOffset } from "discourse/lib/offset-calculator";
import { createPopper } from "@popperjs/core";
export default class ChatChannelMessageEmojiPicker extends Component {
@service site;
@service chatEmojiPickerManager;
context = "chat-channel-message";
@action
didSelectEmoji(emoji) {
this.chatEmojiPickerManager.picker?.didSelectEmoji(emoji);
this.chatEmojiPickerManager.close();
}
@action
didInsert(element) {
if (this.site.mobileView) {
element.classList.remove("hidden");
return;
}
this._popper = createPopper(
this.chatEmojiPickerManager.picker?.trigger,
element,
{
placement: "top",
modifiers: [
{
name: "eventListeners",
options: { scroll: false, resize: false },
},
{
name: "flip",
options: { padding: { top: headerOffset() } },
},
],
}
);
element.classList.remove("hidden");
}
@action
willDestroy() {
this._popper?.destroy();
}
}

View File

@ -1,26 +1,32 @@
{{#if @buttons.length}}
<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}}

View File

@ -0,0 +1,63 @@
import Component from "@glimmer/component";
import { iconHTML } from "discourse-common/lib/icon-library";
import tippy from "tippy.js";
import { action } from "@ember/object";
import { hideOnEscapePlugin } from "discourse/lib/d-popover";
import { tracked } from "@glimmer/tracking";
export default class ChatComposerDropdown extends Component {
@tracked isExpanded = false;
trigger = null;
@action
setupTrigger(element) {
this.trigger = element;
}
@action
toggleExpand() {
if (this.args.hasActivePanel) {
this.args.onCloseActivePanel?.();
} else {
this.isExpanded = !this.isExpanded;
}
}
@action
onButtonClick(button) {
this._tippyInstance.hide();
button.action();
}
@action
setupPanel(element) {
this._tippyInstance = tippy(this.trigger, {
theme: "chat-composer-drodown",
trigger: "click",
zIndex: 1400,
arrow: iconHTML("tippy-rounded-arrow"),
interactive: true,
allowHTML: false,
appendTo: "parent",
hideOnClick: true,
plugins: [hideOnEscapePlugin],
content: element,
onShow: () => {
this.isExpanded = true;
return true;
},
onHide: () => {
this.isExpanded = false;
return true;
},
});
this._tippyInstance.show();
}
@action
teardownPanel() {
this._tippyInstance?.destroy();
}
}

View File

@ -1,45 +1,35 @@
{{#if this.replyToMsg}}
{{#if this.composerService.replyToMsg}}
<ChatComposerMessageDetails
@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}}
/>

View File

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

View File

@ -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")

View File

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

View File

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

View File

@ -0,0 +1,29 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import { action } from "@ember/object";
export default class ChatDrawerThread extends Component {
@service appEvents;
@service chat;
@service chatStateManager;
@service chatChannelsManager;
@action
fetchChannelAndThread() {
if (!this.args.params?.channelId || !this.args.params?.threadId) {
return;
}
return this.chatChannelsManager
.find(this.args.params.channelId)
.then((channel) => {
this.chat.activeChannel = channel;
channel.threadsManager
.find(channel.id, this.args.params.threadId)
.then((thread) => {
this.chat.activeChannel.activeThread = thread;
});
});
}
}

View File

@ -1,175 +1,137 @@
{{! template-lint-disable no-invalid-interactive }}
{{! template-lint-disable no-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}}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,22 +1,17 @@
import Bookmark from "discourse/models/bookmark";
import { openBookmarkModal } from "discourse/controllers/bookmark";
import { isTesting } from "discourse-common/config/environment";
import 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,
})
);
});
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,13 @@
import { MESSAGE_CONTEXT_THREAD } from "discourse/plugins/chat/discourse/components/chat-message";
export default function chatMessageContainer(id, context) {
let selector;
if (context === MESSAGE_CONTEXT_THREAD) {
selector = `.chat-thread .chat-message-container[data-id="${id}"]`;
} else {
selector = `.chat-live-pane .chat-message-container[data-id="${id}"]`;
}
return document.querySelector(selector);
}

View File

@ -0,0 +1,399 @@
import getURL from "discourse-common/lib/get-url";
import { bind } from "discourse-common/utils/decorators";
import showModal from "discourse/lib/show-modal";
import ChatMessageFlag from "discourse/plugins/chat/discourse/lib/chat-message-flag";
import Bookmark from "discourse/models/bookmark";
import { openBookmarkModal } from "discourse/controllers/bookmark";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { isTesting } from "discourse-common/config/environment";
import { clipboardCopy } from "discourse/lib/utilities";
import ChatMessageReaction, {
REACTIONS,
} from "discourse/plugins/chat/discourse/models/chat-message-reaction";
import { getOwner, setOwner } from "@ember/application";
import { tracked } from "@glimmer/tracking";
import ChatMessage from "discourse/plugins/chat/discourse/models/chat-message";
import { MESSAGE_CONTEXT_THREAD } from "discourse/plugins/chat/discourse/components/chat-message";
import I18n from "I18n";
export default class ChatMessageInteractor {
@service appEvents;
@service dialog;
@service chat;
@service chatEmojiReactionStore;
@service chatEmojiPickerManager;
@service chatChannelComposer;
@service chatChannelThreadComposer;
@service chatChannelPane;
@service chatChannelThreadPane;
@service chatApi;
@service currentUser;
@service site;
@service router;
@tracked message = null;
@tracked context = null;
cachedFavoritesReactions = null;
constructor(owner, message, context) {
setOwner(this, owner);
this.message = message;
this.context = context;
this.cachedFavoritesReactions = this.chatEmojiReactionStore.favorites;
}
get capabilities() {
return getOwner(this).lookup("capabilities:main");
}
get pane() {
return this.context === MESSAGE_CONTEXT_THREAD
? this.chatChannelThreadPane
: this.chatChannelPane;
}
get emojiReactions() {
let favorites = this.cachedFavoritesReactions;
// may be a {} if no defaults defined in some production builds
if (!favorites || !favorites.slice) {
return [];
}
return favorites.slice(0, 3).map((emoji) => {
return (
this.message.reactions.find((reaction) => reaction.emoji === emoji) ||
ChatMessageReaction.create({ emoji })
);
});
}
get canEdit() {
return (
!this.message.deletedAt &&
this.currentUser.id === this.message.user.id &&
this.message.channel?.canModifyMessages?.(this.currentUser)
);
}
get canInteractWithMessage() {
return (
!this.message?.deletedAt &&
this.message?.channel?.canModifyMessages(this.currentUser)
);
}
get canRestoreMessage() {
return (
this.canDelete &&
this.message?.deletedAt &&
this.message.channel?.canModifyMessages?.(this.currentUser)
);
}
get canBookmark() {
return this.message?.channel?.canModifyMessages?.(this.currentUser);
}
get canReply() {
return (
this.canInteractWithMessage && this.context !== MESSAGE_CONTEXT_THREAD
);
}
get canReact() {
return this.canInteractWithMessage;
}
get canFlagMessage() {
return (
this.currentUser?.id !== this.message?.user?.id &&
!this.message.channel?.isDirectMessageChannel &&
this.message?.userFlagStatus === undefined &&
this.message.channel?.canFlag &&
!this.message?.chatWebhookEvent &&
!this.message?.deletedAt
);
}
get canOpenThread() {
return (
this.context !== MESSAGE_CONTEXT_THREAD &&
this.message.channel?.threadingEnabled &&
this.message?.threadId
);
}
get canRebakeMessage() {
return (
this.currentUser?.staff &&
this.message.channel?.canModifyMessages?.(this.currentUser)
);
}
get canDeleteMessage() {
return (
this.canDelete &&
!this.message?.deletedAt &&
this.message.channel?.canModifyMessages?.(this.currentUser)
);
}
get canDelete() {
return this.currentUser?.id === this.message.user.id
? this.message.channel?.canDeleteSelf
: this.message.channel?.canDeleteOthers;
}
get composer() {
return this.context === MESSAGE_CONTEXT_THREAD
? this.chatChannelThreadComposer
: this.chatChannelComposer;
}
get secondaryButtons() {
const buttons = [];
buttons.push({
id: "copyLink",
name: I18n.t("chat.copy_link"),
icon: "link",
});
if (this.canEdit) {
buttons.push({
id: "edit",
name: I18n.t("chat.edit"),
icon: "pencil-alt",
});
}
if (!this.pane.selectingMessages) {
buttons.push({
id: "select",
name: I18n.t("chat.select"),
icon: "tasks",
});
}
if (this.canFlagMessage) {
buttons.push({
id: "flag",
name: I18n.t("chat.flag"),
icon: "flag",
});
}
if (this.canDeleteMessage) {
buttons.push({
id: "delete",
name: I18n.t("chat.delete"),
icon: "trash-alt",
});
}
if (this.canRestoreMessage) {
buttons.push({
id: "restore",
name: I18n.t("chat.restore"),
icon: "undo",
});
}
if (this.canRebakeMessage) {
buttons.push({
id: "rebake",
name: I18n.t("chat.rebake_message"),
icon: "sync-alt",
});
}
if (this.canOpenThread) {
buttons.push({
id: "openThread",
name: I18n.t("chat.threads.open"),
icon: "puzzle-piece",
});
}
return buttons;
}
select(checked = true) {
this.message.selected = checked;
this.pane.onSelectMessage(this.message);
}
bulkSelect(checked) {
const channel = this.message.channel;
const lastSelectedIndex = channel.findIndexOfMessage(
this.pane.lastSelectedMessage
);
const newlySelectedIndex = channel.findIndexOfMessage(this.message);
const sortedIndices = [lastSelectedIndex, newlySelectedIndex].sort(
(a, b) => a - b
);
for (let i = sortedIndices[0]; i <= sortedIndices[1]; i++) {
channel.messages[i].selected = checked;
}
}
copyLink() {
const { protocol, host } = window.location;
let url = getURL(`/chat/c/-/${this.message.channelId}/${this.message.id}`);
url = url.indexOf("/") === 0 ? protocol + "//" + host + url : url;
clipboardCopy(url);
}
@action
react(emoji, reactAction) {
if (!this.chat.userCanInteractWithChat) {
return;
}
if (this.pane.reacting) {
return;
}
if (this.capabilities.canVibrate && !isTesting()) {
navigator.vibrate(5);
}
if (this.site.mobileView) {
this.chat.activeMessage = null;
}
if (reactAction === REACTIONS.add) {
this.chatEmojiReactionStore.track(`:${emoji}:`);
}
this.pane.reacting = true;
this.message.react(
emoji,
reactAction,
this.currentUser,
this.currentUser.id
);
return this.chatApi
.publishReaction(
this.message.channelId,
this.message.id,
emoji,
reactAction
)
.catch((errResult) => {
popupAjaxError(errResult);
this.message.react(
emoji,
REACTIONS.remove,
this.currentUser,
this.currentUser.id
);
})
.finally(() => {
this.pane.reacting = false;
});
}
@action
toggleBookmark() {
return openBookmarkModal(
this.message.bookmark ||
Bookmark.createFor(this.currentUser, "Chat::Message", this.message.id),
{
onAfterSave: (savedData) => {
const bookmark = Bookmark.create(savedData);
this.message.bookmark = bookmark;
this.appEvents.trigger(
"bookmarks:changed",
savedData,
bookmark.attachedTo()
);
},
onAfterDelete: () => {
this.message.bookmark = null;
},
}
);
}
@action
flag() {
const model = new ChatMessage(this.message.channel, this.message);
model.username = this.message.user?.username;
model.user_id = this.message.user?.id;
const controller = showModal("flag", { model });
controller.set("flagTarget", new ChatMessageFlag());
}
@action
delete() {
return this.chatApi
.trashMessage(this.message.channelId, this.message.id)
.catch(popupAjaxError);
}
@action
restore() {
return this.chatApi
.restoreMessage(this.message.channelId, this.message.id)
.catch(popupAjaxError);
}
@action
rebake() {
return this.chatApi
.rebakeMessage(this.message.channelId, this.message.id)
.catch(popupAjaxError);
}
@action
reply() {
this.composer.setReplyTo(this.message.id);
}
@action
edit() {
this.composer.editButtonClicked(this.message.id);
}
@action
openThread() {
this.router.transitionTo(
"chat.channel.thread",
...this.message.channel.routeModels,
this.message.threadId
);
}
@action
openEmojiPicker(_, { target }) {
const pickerState = {
didSelectEmoji: this.selectReaction,
trigger: target,
context: "chat-channel-message",
};
this.chatEmojiPickerManager.open(pickerState);
}
@bind
selectReaction(emoji) {
if (!this.chat.userCanInteractWithChat) {
return;
}
this.react(emoji, REACTIONS.add);
}
@action
handleSecondaryButtons(id) {
this[id](this.message);
}
}

View File

@ -66,6 +66,10 @@ export default class ChatChannel extends RestModel {
threadsManager = new ChatThreadsManager(getOwner(this));
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);
},

View File

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

View File

@ -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(

View File

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

View File

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

View File

@ -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",

View File

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

View File

@ -0,0 +1,131 @@
import { debounce } from "discourse-common/utils/decorators";
import { tracked } from "@glimmer/tracking";
import Service, { inject as service } from "@ember/service";
export default class ChatChannelComposer extends Service {
@service chat;
@service chatApi;
@service chatComposerPresenceManager;
@tracked editingMessage = null;
@tracked replyToMsg = null;
@tracked linkedComponent = null;
reset() {
this.editingMessage = null;
this.replyToMsg = null;
}
get #model() {
return this.chat.activeChannel;
}
setReplyTo(messageOrId) {
if (messageOrId) {
this.cancelEditing();
const message =
typeof messageOrId === "number"
? this.#model.messagesManager.findMessage(messageOrId)
: messageOrId;
this.replyToMsg = message;
this.focusComposer();
} else {
this.replyToMsg = null;
}
this.onComposerValueChange({ replyToMsg: this.replyToMsg });
}
editButtonClicked(messageId) {
const message = this.#model.messagesManager.findMessage(messageId);
this.editingMessage = message;
// TODO (martin) Move scrollToLatestMessage to live panel.
// this.scrollToLatestMessage();
this.focusComposer();
}
onComposerValueChange({
value,
uploads,
replyToMsg,
inProgressUploadsCount,
}) {
if (!this.#model) {
return;
}
if (!this.editingMessage && !this.#model.isDraft) {
if (typeof value !== "undefined") {
this.#model.draft.message = value;
}
// only save the uploads to the draft if we are not still uploading other
// ones, otherwise we get into a cycle where we pass the draft uploads as
// existingUploads back to the upload component and cause in progress ones
// to be cancelled
if (
typeof uploads !== "undefined" &&
inProgressUploadsCount !== "undefined" &&
inProgressUploadsCount === 0
) {
this.#model.draft.uploads = uploads;
}
if (typeof replyToMsg !== "undefined") {
this.#model.draft.replyToMsg = replyToMsg;
}
}
if (!this.#model.isDraft) {
this.#reportReplyingPresence(value);
}
this._persistDraft();
}
cancelEditing() {
this.editingMessage = null;
}
registerFocusHandler(handlerFn) {
this.focusHandler = handlerFn;
}
focusComposer() {
this.focusHandler();
}
#reportReplyingPresence(composerValue) {
if (this.#componentDeleted) {
return;
}
if (this.#model.isDraft) {
return;
}
const replying = !this.editingMessage && !!composerValue;
this.chatComposerPresenceManager.notifyState(this.#model.id, replying);
}
@debounce(2000)
_persistDraft() {
if (this.#componentDeleted || !this.#model) {
return;
}
if (!this.#model.draft) {
return;
}
return this.chatApi.saveDraft(this.#model.id, this.#model.draft.toJSON());
}
get #componentDeleted() {
// note I didn't set this in the new version, not sure yet what to do with it
// return this.linkedComponent._selfDeleted;
}
}

View File

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

View File

@ -0,0 +1,92 @@
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { popupAjaxError } from "discourse/lib/ajax-error";
import Service, { inject as service } from "@ember/service";
export default class ChatChannelPane extends Service {
@service appEvents;
@service chat;
@service chatChannelComposer;
@service chatApi;
@service chatComposerPresenceManager;
@tracked reacting = false;
@tracked selectingMessages = false;
@tracked hoveredMessageId = false;
@tracked lastSelectedMessage = null;
@tracked sendingLoading = false;
get selectedMessageIds() {
return this.chat.activeChannel.selectedMessages.mapBy("id");
}
get composerService() {
return this.chatChannelComposer;
}
@action
cancelSelecting(selectedMessages) {
this.selectingMessages = false;
selectedMessages.forEach((message) => {
message.selected = false;
});
}
onSelectMessage(message) {
this.lastSelectedMessage = message;
this.selectingMessages = true;
}
@action
editMessage(newContent, uploads) {
this.sendingLoading = true;
let data = {
new_message: newContent,
upload_ids: (uploads || []).map((upload) => upload.id),
};
return this.chatApi
.editMessage(
this.composerService.editingMessage.channelId,
this.composerService.editingMessage.id,
data
)
.then(() => {
this.resetAfterSend();
})
.catch(popupAjaxError)
.finally(() => {
if (this._selfDeleted) {
return;
}
this.sendingLoading = false;
});
}
resetAfterSend() {
const channelId = this.composerService.editingMessage?.channelId;
if (channelId) {
this.chatComposerPresenceManager.notifyState(channelId, false);
}
this.composerService.reset();
}
@action
editLastMessageRequested() {
const lastUserMessage = this.chat.activeChannel.messages.findLast(
(message) => message.user.id === this.currentUser.id
);
if (!lastUserMessage) {
return;
}
if (lastUserMessage.staged || lastUserMessage.error) {
return;
}
this.composerService.editingMessage = lastUserMessage;
this.composerService.focusComposer();
}
}

View File

@ -0,0 +1,15 @@
import ChatChannelComposer from "./chat-channel-composer";
export default class extends ChatChannelComposer {
get #model() {
return this.chat.activeChannel.activeThread;
}
_persistDraft() {
// eslint-disable-next-line no-console
console.debug(
"Drafts are unsupported for chat threads at this point in time"
);
return;
}
}

View File

@ -0,0 +1,14 @@
import ChatChannelPane from "./chat-channel-pane";
import { inject as service } from "@ember/service";
export default class ChatChannelThreadPane extends ChatChannelPane {
@service chatChannelThreadComposer;
get selectedMessageIds() {
return this.chat.activeChannel.activeThread.selectedMessages.mapBy("id");
}
get composerService() {
return this.chatChannelThreadComposer;
}
}

View File

@ -2,11 +2,21 @@ import Service, { inject as service } from "@ember/service";
import { tracked } from "@glimmer/tracking";
import 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,

View File

@ -1,49 +1,42 @@
import { headerOffset } from "discourse/lib/offset-calculator";
import { createPopper } from "@popperjs/core";
import Service from "@ember/service";
import { tracked } from "@glimmer/tracking";
import { 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();
}

View File

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

View File

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

View File

@ -1,12 +0,0 @@
import { getOwner } from "discourse-common/lib/get-owner";
export default {
setupComponent(args, component) {
const container = getOwner(this);
const chatEmojiPickerManager = container.lookup(
"service:chat-emoji-picker-manager"
);
component.set("chatEmojiPickerManager", chatEmojiPickerManager);
},
};

View File

@ -5,7 +5,7 @@ $float-height: 530px;
--full-page-border-radius: 12px;
--full-page-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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,26 @@
.chat-channel-message-emoji-picker-connector {
position: relative;
.chat-emoji-picker {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 50vh;
width: 100%;
box-shadow: shadow("card");
z-index: z("header") + 2;
max-width: 100vw;
&__backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--primary);
opacity: 0.8;
z-index: z("header") + 1;
}
}
}

View File

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

View File

@ -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"

View File

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

View File

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

View File

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

View File

@ -7,6 +7,7 @@ describe "Single thread in side panel", type: :system, js: true do
let(:channel_page) { PageObjects::Pages::ChatChannel.new }
let(: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")

View File

@ -21,7 +21,7 @@ RSpec.describe "Quoting chat message transcripts", type: :system, js: true do
chat_channel_page.message_by_id(message.id).hover
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

View File

@ -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({

View File

@ -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"]');

View File

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

View File

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

View File

@ -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,