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
Roman Rizzi d07b472b79
DEV: /channel -> /c chat route rename (#19782)
* DEV: Rnemae channel path to just c

Also swap the channel id and channel slug params to be consistent with core.

* linting

* channel_path

* params in wrong order

* Drop slugify helper and channel route without slug

* Request slug and route models through the channel model if possible

* Add client side redirection for backwards-compatibility

Co-authored-by: Joffrey JAFFEUX <j.jaffeux@gmail.com>
2023-01-27 09:58:12 -03:00

823 lines
21 KiB
JavaScript

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"),
chatChannelsManager: service("chat-channels-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) {
return this.chatChannelsManager
.getChannel(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.createFor(this.currentUser, "ChatMessage", this.message.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/c/-/${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 };
}
});
},
});