Joffrey JAFFEUX bf886662df
UX: improves composer and thread panel ()
This pull request is a full overhaul of the chat-composer and contains various improvements to the thread panel. They have been grouped in the same PR as lots of improvements/fixes to the thread panel needed an improved composer. This is meant as a first step.

### New features included in this PR

- A resizable side panel
- A clear dropzone area for uploads
- A simplified design for image uploads, this is only a first step towards more redesign of this area in the future

### Notable fixes in this PR

- Correct placeholder in thread panel
- Allows to edit the last message of a thread with arrow up
- Correctly focus composer when replying to a message
- The reply indicator is added instantly in the channel when starting a thread
- Prevents a large variety of bug where the composer could bug and prevent sending message or would clear your input while it has content

### Technical notes

To achieve this PR, three important changes have been made:

- `<ChatComposer>` has been fully rewritten and is now a glimmer component
- The chat composer now takes a `ChatMessage` as input which can directly be used in other operations, it simplifies a lot of logic as we are always working a with a `ChatMessage`
- `TextareaInteractor` has been created to wrap the existing `TextareaTextManipulation` mixin, it will make future migrations easier and allow us to have a less polluted `<ChatComposer>`

Note ".chat-live-pane" has been renamed ".chat-channel"

Design for upload dropzone is from @chapoi
2023-04-25 10:23:03 +02:00

384 lines
9.0 KiB
JavaScript

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 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",
});
}
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;
const channelId = this.message.channelId;
const threadId = this.message.threadId;
let url;
if (threadId) {
url = getURL(`/chat/c/-/${channelId}/t/${threadId}`);
} else {
url = getURL(`/chat/c/-/${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.replyTo(this.message);
}
@action
edit() {
this.composer.editMessage(this.message);
}
@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);
}
}