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 12a18d4d55
DEV: properly namespace chat (#20690)
This commit main goal was to comply with Zeitwerk and properly rely on autoloading. To achieve this, most resources have been namespaced under the `Chat` module.

- Given all models are now namespaced with `Chat::` and would change the stored types in DB when using polymorphism or STI (single table inheritance), this commit uses various Rails methods to ensure proper class is loaded and the stored name in DB is unchanged, eg: `Chat::Message` model will be stored as `"ChatMessage"`, and `"ChatMessage"` will correctly load `Chat::Message` model.
- Jobs are now using constants only, eg: `Jobs::Chat::Foo` and should only be enqueued this way

Notes:
- This commit also used this opportunity to limit the number of registered css files in plugin.rb
- `discourse_dev` support has been removed within this commit and will be reintroduced later

<!-- NOTE: All pull requests should have tests (rspec in Ruby, qunit in JavaScript). If your code does not include test coverage, please include an explanation of why it was omitted. -->
2023-03-17 14:24:38 +01:00

716 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() {
const message = this.args.message;
const previousMessage = message?.previousMessage;
if (!previousMessage) {
return false;
}
// this is a micro optimization to avoid layout changes when we load more messages
if (message?.firstOfResults) {
return false;
}
return (
!message?.chatWebhookEvent &&
(!message?.inReplyTo ||
message?.inReplyTo?.user?.id !== message?.user?.id) &&
!message?.previousMessage?.deletedAt &&
Math.abs(
new Date(message?.createdAt) - new Date(previousMessage?.createdAt)
) < 300000 && // If the time between messages is over 5 minutes, break.
message?.user?.id === 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
);
this.args.forceRendering?.();
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 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,
})
);
});
}
}