This repository has been archived on 2023-03-18. You can view files and clone it, but cannot push or open issues or pull requests.
osr-discourse-src/plugins/chat/assets/javascripts/discourse/components/chat-message.js
Joffrey JAFFEUX 6b0aeced7e
DEV: rework the chat-live-pane (#20519)
This PR is introducing glimmer usage in the chat-live-pane, for components but also for models. RestModel usage has been dropped in favor of native classes.

Other changes/additions in this PR:

sticky dates, scrolling will now keep the date separator of the current section at the top of the screen
better unread management, marking a channel as unread will correctly mark the correct message and not mark the whole channel as read. Tracking state will also now correctly return unread count and unread mentions.
adds an animation on bottom arrow
better scrolling behavior, we should now always correctly keep the scroll position while loading more
reactions are now more reactive, and will update their tooltip without needed to close/reopen it
skeleton has been improved with placeholder images and reactions
when making a reaction on the desktop message actions, the menu won't move anymore
simplify logic and stop maintaining a list of unloaded messages
2023-03-03 13:09:25 +01:00

703 lines
17 KiB
JavaScript

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 { getOwner } from "discourse-common/lib/get-owner";
import ChatMessageReaction from "discourse/plugins/chat/discourse/models/chat-message-reaction";
let _chatMessageDecorators = [];
export function addChatMessageDecorator(decorator) {
_chatMessageDecorators.push(decorator);
}
export function resetChatMessageDecorators() {
_chatMessageDecorators = [];
}
export const MENTION_KEYWORDS = ["here", "all"];
export const REACTIONS = { add: "add", remove: "remove" };
export default class ChatMessage extends Component {
@service site;
@service dialog;
@service currentUser;
@service appEvents;
@service chat;
@service chatEmojiReactionStore;
@service chatEmojiPickerManager;
@service chatChannelsManager;
@service router;
@tracked chatMessageActionsMobileAnchor = null;
@tracked chatMessageActionsDesktopAnchor = null;
@optionalService adminTools;
cachedFavoritesReactions = null;
reacting = false;
constructor() {
super(...arguments);
this.cachedFavoritesReactions = this.chatEmojiReactionStore.favorites;
}
get deletedAndCollapsed() {
return this.args.message?.deletedAt && this.collapsed;
}
get hiddenAndCollapsed() {
return this.args.message?.hidden && this.collapsed;
}
get collapsed() {
return !this.args.message?.expanded;
}
@action
setMessageActionsAnchors() {
schedule("afterRender", () => {
this.chatMessageActionsDesktopAnchor = document.querySelector(
".chat-message-actions-desktop-anchor"
);
this.chatMessageActionsMobileAnchor = document.querySelector(
".chat-message-actions-mobile-anchor"
);
});
}
@action
teardownChatMessage() {
cancel(this._invitationSentTimer);
}
@action
decorateCookedMessage() {
schedule("afterRender", () => {
if (!this.messageContainer) {
return;
}
_chatMessageDecorators.forEach((decorator) => {
decorator.call(this, this.messageContainer, this.args.channel);
});
});
}
get messageContainer() {
const id = this.args.message?.id;
if (id) {
return document.querySelector(`.chat-message-container[data-id='${id}']`);
}
}
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 ||
this.currentUser.id === this.args.message?.user?.id ||
this.currentUser.staff ||
this.args.channel?.canModerate
);
}
@action
handleTouchStart() {
// if zoomed don't track long press
if (isZoomed()) {
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);
}
}
@action
handleTouchMove() {
if (!this.args.isHovered) {
cancel(this._isPressingHandler);
}
}
@action
handleTouchEnd() {
cancel(this._isPressingHandler);
}
@action
_handleLongPress() {
if (isZoomed()) {
// if zoomed don't handle long press
return;
}
document.activeElement.blur();
document.querySelector(".chat-composer-input")?.blur();
this.args.onHoverMessage?.(this.args.message);
}
get hideUserInfo() {
return (
!this.args.message?.chatWebhookEvent &&
!this.args.message?.inReplyTo &&
!this.args.message?.previousMessage?.deletedAt &&
Math.abs(
new Date(this.args.message?.createdAt) -
new Date(this.args.message?.createdAt)
) < 300000 && // If the time between messages is over 5 minutes, break.
this.args.message?.user?.id ===
this.args.message?.previousMessage?.user?.id
);
}
get hideReplyToInfo() {
return (
this.args.message?.inReplyTo?.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;
}
get mentionedCannotSeeText() {
return this._findTranslatedWarning(
"chat.mention_warning.cannot_see",
"chat.mention_warning.cannot_see_multiple",
{
username: this.mentionWarning?.cannot_see?.[0]?.username,
count: this.mentionWarning?.cannot_see?.length,
}
);
}
get mentionedWithoutMembershipText() {
return this._findTranslatedWarning(
"chat.mention_warning.without_membership",
"chat.mention_warning.without_membership_multiple",
{
username: this.mentionWarning?.without_membership?.[0]?.username,
count: this.mentionWarning?.without_membership?.length,
}
);
}
get groupsWithDisabledMentions() {
return this._findTranslatedWarning(
"chat.mention_warning.group_mentions_disabled",
"chat.mention_warning.group_mentions_disabled_multiple",
{
group_name: this.mentionWarning?.group_mentions_disabled?.[0],
count: this.mentionWarning?.group_mentions_disabled?.length,
}
);
}
get groupsWithTooManyMembers() {
return this._findTranslatedWarning(
"chat.mention_warning.too_many_members",
"chat.mention_warning.too_many_members_multiple",
{
group_name: this.mentionWarning.groups_with_too_many_members?.[0],
count: this.mentionWarning.groups_with_too_many_members?.length,
}
);
}
_findTranslatedWarning(oneKey, multipleKey, args) {
const translationKey = args.count === 1 ? oneKey : multipleKey;
args.count--;
return I18n.t(translationKey, args);
}
@action
inviteMentioned() {
const userIds = this.mentionWarning.without_membership.mapBy("id");
ajax(`/chat/${this.args.message.channelId}/invite`, {
method: "PUT",
data: { user_ids: userIds, chat_message_id: this.args.message.id },
}).then(() => {
this.args.message.mentionWarning.set("invitationSent", true);
this._invitationSentTimer = discourseLater(() => {
this.dismissMentionWarning();
}, 3000);
});
return false;
}
@action
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);
}
get capabilities() {
return getOwner(this).lookup("capabilities:main");
}
@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,
"ChatMessage",
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 ajax(
`/chat/${this.args.message.channelId}/${this.args.message.id}`,
{
type: "DELETE",
}
).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,
})
);
});
}
}