import Bookmark from "discourse/models/bookmark"; import { openBookmarkModal } from "discourse/controllers/bookmark"; import { isTesting } from "discourse-common/config/environment"; import Component from "@ember/component"; import I18n from "I18n"; import getURL from "discourse-common/lib/get-url"; import optionalService from "discourse/lib/optional-service"; import discourseComputed, { afterRender, bind, } from "discourse-common/utils/decorators"; import EmberObject, { action, computed } from "@ember/object"; import { and, not } from "@ember/object/computed"; import { ajax } from "discourse/lib/ajax"; import { cancel, once } 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"; let _chatMessageDecorators = []; export function addChatMessageDecorator(decorator) { _chatMessageDecorators.push(decorator); } export function resetChatMessageDecorators() { _chatMessageDecorators = []; } export const MENTION_KEYWORDS = ["here", "all"]; export default Component.extend({ ADD_REACTION: "add", REMOVE_REACTION: "remove", SHOW_LEFT: "showLeft", SHOW_RIGHT: "showRight", canInteractWithChat: false, isHovered: false, onHoverMessage: null, chatEmojiReactionStore: service("chat-emoji-reaction-store"), chatEmojiPickerManager: service("chat-emoji-picker-manager"), adminTools: optionalService(), _hasSubscribedToAppEvents: false, tagName: "", chat: service(), dialog: service(), chatMessageActionsMobileAnchor: null, chatMessageActionsDesktopAnchor: null, chatMessageEmojiPickerAnchor: null, cachedFavoritesReactions: null, init() { this._super(...arguments); this.set("_loadingReactions", []); this.message.set("reactions", EmberObject.create(this.message.reactions)); this.message.id ? this._subscribeToAppEvents() : this._waitForIdToBePopulated(); if (this.message.bookmark) { this.set("message.bookmark", Bookmark.create(this.message.bookmark)); } }, didInsertElement() { this._super(...arguments); this.set( "chatMessageActionsMobileAnchor", document.querySelector(".chat-message-actions-mobile-anchor") ); this.set( "chatMessageActionsDesktopAnchor", document.querySelector(".chat-message-actions-desktop-anchor") ); this.set("cachedFavoritesReactions", this.chatEmojiReactionStore.favorites); }, willDestroyElement() { this._super(...arguments); if (this.message.stagedId) { this.appEvents.off( `chat-message-staged-${this.message.stagedId}:id-populated`, this, "_subscribeToAppEvents" ); } this.appEvents.off("chat:refresh-message", this, "_refreshedMessage"); this.appEvents.off( `chat-message-${this.message.id}:reaction`, this, "_handleReactionMessage" ); cancel(this._invitationSentTimer); }, didReceiveAttrs() { this._super(...arguments); if (!this.show || this.deletedAndCollapsed) { this._decoratedMessageCooked = null; } else if (this.message.cooked !== this._decoratedMessageCooked) { once("afterRender", this.decorateMessageCooked); this._decoratedMessageCooked = this.message.cooked; } }, @bind _refreshedMessage(message) { if (message.id === this.message.id) { this.decorateMessageCooked(); } }, @bind decorateMessageCooked() { if (!this.messageContainer) { return; } _chatMessageDecorators.forEach((decorator) => { decorator.call(this, this.messageContainer, this.chatChannel); }); }, @computed("message.{id,stagedId}") get messageContainer() { const id = this.message?.id || this.message?.stagedId; return ( id && document.querySelector(`.chat-message-container[data-id='${id}']`) ); }, _subscribeToAppEvents() { if (!this.message.id || this._hasSubscribedToAppEvents) { return; } this.appEvents.on("chat:refresh-message", this, "_refreshedMessage"); this.appEvents.on( `chat-message-${this.message.id}:reaction`, this, "_handleReactionMessage" ); this._hasSubscribedToAppEvents = true; }, _waitForIdToBePopulated() { this.appEvents.on( `chat-message-staged-${this.message.stagedId}:id-populated`, this, "_subscribeToAppEvents" ); }, @discourseComputed("canInteractWithChat", "message.staged", "isHovered") showActions(canInteractWithChat, messageStaged, isHovered) { return canInteractWithChat && !messageStaged && isHovered; }, deletedAndCollapsed: and("message.deleted_at", "collapsed"), hiddenAndCollapsed: and("message.hidden", "collapsed"), collapsed: not("message.expanded"), @discourseComputed( "selectingMessages", "canFlagMessage", "showDeleteButton", "showRestoreButton", "showEditButton", "showRebakeButton" ) 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.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", }); } 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, startReactionForMessageActions: this.startReactionForMessageActions, }; }, get messageCapabilities() { return { canReact: this.canReact, canReply: this.canReply, canBookmark: this.showBookmarkButton, }; }, @discourseComputed("message", "details.can_moderate") show(message, canModerate) { return ( !message.deleted_at || this.currentUser.id === this.message.user.id || this.currentUser.staff || canModerate ); }, @action handleTouchStart() { // if zoomed don't track long press if (isZoomed()) { return; } if (!this.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.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.onHoverMessage(this.message); }, @discourseComputed("message.hideUserInfo", "message.chat_webhook_event") hideUserInfo(hide, webhookEvent) { return hide && !webhookEvent; }, @discourseComputed( "message.staged", "message.deleted_at", "message.in_reply_to", "message.error", "message.bookmark", "isHovered" ) chatMessageClasses(staged, deletedAt, inReplyTo, error, bookmark, isHovered) { let classNames = ["chat-message"]; if (staged) { classNames.push("chat-message-staged"); } if (deletedAt) { classNames.push("deleted"); } if (inReplyTo) { classNames.push("is-reply"); } if (this.hideUserInfo) { classNames.push("user-info-hidden"); } if (error) { classNames.push("errored"); } if (isHovered) { classNames.push("chat-message-selected"); } if (bookmark) { classNames.push("chat-message-bookmarked"); } return classNames.join(" "); }, @discourseComputed("message", "message.deleted_at", "chatChannel.status") showEditButton(message, deletedAt) { return ( !deletedAt && this.currentUser.id === message.user?.id && this.chatChannel.canModifyMessages(this.currentUser) ); }, @discourseComputed( "message", "message.user_flag_status", "details.can_flag", "message.deleted_at" ) canFlagMessage(message, userFlagStatus, canFlag, deletedAt) { return ( this.currentUser?.id !== message.user?.id && userFlagStatus === undefined && canFlag && !message.chat_webhook_event && !deletedAt ); }, @discourseComputed("message") canManageDeletion(message) { return this.currentUser?.id === message.user?.id ? this.details.can_delete_self : this.details.can_delete_others; }, @discourseComputed("message.deleted_at", "chatChannel.status") canReply(deletedAt) { return !deletedAt && this.chatChannel.canModifyMessages(this.currentUser); }, @discourseComputed("message.deleted_at", "chatChannel.status") canReact(deletedAt) { return !deletedAt && this.chatChannel.canModifyMessages(this.currentUser); }, @discourseComputed( "canManageDeletion", "message.deleted_at", "chatChannel.status" ) showDeleteButton(canManageDeletion, deletedAt) { return ( canManageDeletion && !deletedAt && this.chatChannel.canModifyMessages(this.currentUser) ); }, @discourseComputed( "canManageDeletion", "message.deleted_at", "chatChannel.status" ) showRestoreButton(canManageDeletion, deletedAt) { return ( canManageDeletion && deletedAt && this.chatChannel.canModifyMessages(this.currentUser) ); }, @discourseComputed("chatChannel.status") showBookmarkButton() { return this.chatChannel.canModifyMessages(this.currentUser); }, @discourseComputed("chatChannel.status") showRebakeButton() { return ( this.currentUser?.staff && this.chatChannel.canModifyMessages(this.currentUser) ); }, @discourseComputed("message.reactions.@each") hasReactions(reactions) { return Object.values(reactions).some((r) => r.count > 0); }, @discourseComputed("message.mentionWarning") mentionWarning() { return this.message.mentionWarning; }, @discourseComputed("mentionWarning.cannot_see") mentionedCannotSeeText(users) { return I18n.t("chat.mention_warning.cannot_see", { username: users[0].username, count: users.length, others: this._othersTranslation(users.length - 1), }); }, @discourseComputed("mentionWarning.without_membership") mentionedWithoutMembershipText(users) { return I18n.t("chat.mention_warning.without_membership", { username: users[0].username, count: users.length, others: this._othersTranslation(users.length - 1), }); }, @discourseComputed("mentionWarning.group_mentions_disabled") groupsWithDisabledMentions(groups) { return I18n.t("chat.mention_warning.group_mentions_disabled", { group_name: groups[0], count: groups.length, others: this._othersTranslation(groups.length - 1), }); }, @discourseComputed("mentionWarning.groups_with_too_many_members") groupsWithTooManyMembers(groups) { return I18n.t("chat.mention_warning.too_many_members", { group_name: groups[0], count: groups.length, others: this._othersTranslation(groups.length - 1), }); }, _othersTranslation(othersCount) { return I18n.t("chat.mention_warning.warning_multiple", { count: othersCount, }); }, @action inviteMentioned() { const user_ids = this.mentionWarning.without_membership.mapBy("id"); ajax(`/chat/${this.details.chat_channel_id}/invite`, { method: "PUT", data: { user_ids, chat_message_id: this.message.id }, }).then(() => { this.message.set("mentionWarning.invitationSent", true); this._invitationSentTimer = discourseLater(() => { this.message.set("mentionWarning", null); }, 3000); }); return false; }, @action dismissMentionWarning() { this.message.set("mentionWarning", null); }, @action startReactionForMessageActions() { this.chatEmojiPickerManager.startFromMessageActions( this.message, this.site.desktopView, this.selectReaction ); }, @action startReactionForReactionList() { this.chatEmojiPickerManager.startFromMessageReactionList( this.message, this.site.desktopView, this.selectReaction ); }, deselectReaction(emoji) { if (!this.canInteractWithChat) { return; } this.react(emoji, this.REMOVE_REACTION); this.notifyPropertyChange("emojiReactions"); }, @action selectReaction(emoji) { if (!this.canInteractWithChat) { return; } this.react(emoji, this.ADD_REACTION); this.notifyPropertyChange("emojiReactions"); }, @bind _handleReactionMessage(busData) { const loadingReactionIndex = this._loadingReactions.indexOf(busData.emoji); if (loadingReactionIndex > -1) { return this._loadingReactions.splice(loadingReactionIndex, 1); } this._updateReactionsList(busData.emoji, busData.action, busData.user); this.afterReactionAdded(); }, @action react(emoji, reactAction) { if (!this.canInteractWithChat || this._loadingReactions.includes(emoji)) { return; } if (this.capabilities.canVibrate && !isTesting()) { navigator.vibrate(5); } if (this.site.mobileView) { this.set("isHovered", false); } this._loadingReactions.push(emoji); this._updateReactionsList(emoji, reactAction, this.currentUser); if (reactAction === this.ADD_REACTION) { this.chatEmojiReactionStore.track(`:${emoji}:`); } return this._publishReaction(emoji, reactAction).then(() => { this.notifyPropertyChange("emojiReactions"); // creating reaction will create a membership if not present // so we will fully refresh if we were not members of the channel // already if (!this.chatChannel.isFollowing || this.chatChannel.isDraft) { this.chat.forceRefreshChannels().then(() => { return this.chat .getChannelBy("id", this.chatChannel.id) .then((reactedChannel) => { this.onSwitchChannel(reactedChannel); }); }); } }); }, _updateReactionsList(emoji, reactAction, user) { const selfReacted = this.currentUser.id === user.id; if (this.message.reactions[emoji]) { if ( selfReacted && reactAction === this.ADD_REACTION && this.message.reactions[emoji].reacted ) { // User is already has reaction added; do nothing return false; } let newCount = reactAction === this.ADD_REACTION ? this.message.reactions[emoji].count + 1 : this.message.reactions[emoji].count - 1; this.message.reactions.set(`${emoji}.count`, newCount); if (selfReacted) { this.message.reactions.set( `${emoji}.reacted`, reactAction === this.ADD_REACTION ); } else { this.message.reactions[emoji].users.pushObject(user); } } else { if (reactAction === this.ADD_REACTION) { this.message.reactions.set(emoji, { count: 1, reacted: selfReacted, users: selfReacted ? [] : [user], }); } } this.message.notifyPropertyChange("reactions"); }, _publishReaction(emoji, reactAction) { return ajax( `/chat/${this.details.chat_channel_id}/react/${this.message.id}`, { type: "PUT", data: { react_action: reactAction, emoji, }, } ).catch((errResult) => { popupAjaxError(errResult); this._updateReactionsList(emoji, this.REMOVE_REACTION, this.currentUser); }); }, // TODO(roman): For backwards-compatibility. // Remove after the 3.0 release. _legacyFlag() { this.dialog.yesNoConfirm({ message: I18n.t("chat.confirm_flag", { username: this.message.user?.username, }), didConfirm: () => { return ajax("/chat/flag", { method: "PUT", data: { chat_message_id: this.message.id, flag_type_id: 7, // notify_moderators }, }).catch(popupAjaxError); }, }); }, @action reply() { this.setReplyTo(this.message.id); }, @action viewReply() { this.replyMessageClicked(this.message.in_reply_to); }, @action edit() { this.editButtonClicked(this.message.id); }, @action flag() { const targetFlagSupported = requirejs.entries["discourse/lib/flag-targets/flag"]; if (targetFlagSupported) { const model = EmberObject.create(this.message); model.set("username", model.get("user.username")); model.set("user_id", model.get("user.id")); let controller = showModal("flag", { model }); controller.setProperties({ flagTarget: new ChatMessageFlag() }); } else { this._legacyFlag(); } }, @action expand() { this.message.set("expanded", true); }, @action restore() { return ajax( `/chat/${this.details.chat_channel_id}/restore/${this.message.id}`, { type: "PUT", } ).catch(popupAjaxError); }, @action toggleBookmark() { return openBookmarkModal( this.message.bookmark || Bookmark.create({ bookmarkable_type: "ChatMessage", bookmarkable_id: this.message.id, user_id: this.currentUser.id, }), { onAfterSave: (savedData) => { const bookmark = Bookmark.create(savedData); this.set("message.bookmark", bookmark); this.appEvents.trigger( "bookmarks:changed", savedData, bookmark.attachedTo() ); }, onAfterDelete: () => { this.set("message.bookmark", null); }, } ); }, @action rebakeMessage() { return ajax( `/chat/${this.details.chat_channel_id}/${this.message.id}/rebake`, { type: "PUT", } ).catch(popupAjaxError); }, @action deleteMessage() { return ajax(`/chat/${this.details.chat_channel_id}/${this.message.id}`, { type: "DELETE", }).catch(popupAjaxError); }, @action selectMessage() { this.message.set("selected", true); this.onStartSelectingMessages(this.message); }, @action @afterRender toggleChecked(e) { if (e.shiftKey) { this.bulkSelectMessages(this.message, e.target.checked); } this.onSelectMessage(this.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/channel/${this.details.chat_channel_id}/-?messageId=${this.message.id}` ); url = url.indexOf("/") === 0 ? protocol + "//" + host + url : url; clipboardCopy(url); discourseLater(() => { this.messageContainer ?.querySelector(".link-to-message-btn") ?.classList?.remove("copied"); }, 250); }, @computed get emojiReactions() { const favorites = this.cachedFavoritesReactions; // may be a {} if no defaults defined in some production builds if (!favorites || !favorites.slice) { return []; } const userReactions = Object.keys(this.message.reactions).filter((key) => { return this.message.reactions[key].reacted; }); return favorites.slice(0, 3).map((emoji) => { if (userReactions.includes(emoji)) { return { emoji, reacted: true }; } else { return { emoji, reacted: false }; } }); }, });