The communications platform that puts data protection first.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
Rocket.Chat/app/ui/client/lib/chatMessages.js

607 lines
16 KiB

import moment from 'moment';
import _ from 'underscore';
import { Meteor } from 'meteor/meteor';
import { Random } from 'meteor/random';
import { FlowRouter } from 'meteor/kadira:flow-router';
import { Session } from 'meteor/session';
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
import { escapeHTML } from '@rocket.chat/string-helpers';
import { KonchatNotification } from './notification';
import { UserAction, USER_ACTIVITIES } from '../index';
import { fileUpload } from './fileUpload';
import { t, slashCommands } from '../../../utils/client';
import { messageProperties, MessageTypes, readMessage } from '../../../ui-utils/client';
import { settings } from '../../../settings/client';
import { callbacks } from '../../../../lib/callbacks';
import { hasAtLeastOnePermission } from '../../../authorization/client';
import { Messages, Rooms, ChatMessage, ChatSubscription } from '../../../models/client';
import { emoji } from '../../../emoji/client';
import { generateTriggerId } from '../../../ui-message/client/ActionManager';
import { imperativeModal } from '../../../../client/lib/imperativeModal';
import GenericModal from '../../../../client/components/GenericModal';
import { keyCodes } from '../../../../client/lib/utils/keyCodes';
import { prependReplies } from '../../../../client/lib/utils/prependReplies';
import { callWithErrorHandling } from '../../../../client/lib/utils/callWithErrorHandling';
import { handleError } from '../../../../client/lib/utils/handleError';
import { dispatchToastMessage } from '../../../../client/lib/toast';
import { onClientBeforeSendMessage } from '../../../../client/lib/onClientBeforeSendMessage';
const messageBoxState = {
saveValue: _.debounce(({ rid, tmid }, value) => {
const key = ['messagebox', rid, tmid].filter(Boolean).join('_');
value ? Meteor._localStorage.setItem(key, value) : Meteor._localStorage.removeItem(key);
}, 1000),
restoreValue: ({ rid, tmid }) => {
const key = ['messagebox', rid, tmid].filter(Boolean).join('_');
return Meteor._localStorage.getItem(key);
},
restore: ({ rid, tmid }, input) => {
const value = messageBoxState.restoreValue({ rid, tmid });
if (typeof value === 'string') {
messageBoxState.set(input, value);
}
},
save: ({ rid, tmid }, input) => {
messageBoxState.saveValue({ rid, tmid }, input.value);
},
set: (input, value) => {
input.value = value;
$(input).trigger('change').trigger('input');
},
purgeAll: () => {
Object.keys(Meteor._localStorage)
.filter((key) => key.indexOf('messagebox_') === 0)
.forEach((key) => Meteor._localStorage.removeItem(key));
},
};
callbacks.add('afterLogoutCleanUp', messageBoxState.purgeAll, callbacks.priority.MEDIUM, 'chatMessages-after-logout-cleanup');
export class ChatMessages {
constructor(collection = ChatMessage) {
this.collection = collection;
}
editing = {};
records = {};
initializeWrapper(wrapper) {
this.wrapper = wrapper;
}
initializeInput(input, { rid, tmid }) {
this.input = input;
this.$input = $(this.input);
if (!input || !rid) {
return;
}
messageBoxState.restore({ rid, tmid }, input);
this.restoreReplies();
this.requestInputFocus();
}
async restoreReplies() {
const mid = FlowRouter.getQueryParam('reply');
if (!mid) {
return;
}
const message = Messages.findOne(mid) || (await callWithErrorHandling('getSingleMessage', mid));
if (!message) {
return;
}
this.$input.data('reply', [message]).trigger('dataChange');
}
requestInputFocus() {
setTimeout(() => {
if (this.input && window.matchMedia('screen and (min-device-width: 500px)').matches) {
this.input.focus();
}
}, 200);
}
recordInputAsDraft() {
const message = this.collection.findOne(this.editing.id);
const record = this.records[this.editing.id] || {};
const draft = this.input.value;
if (draft === message.msg) {
this.clearCurrentDraft();
return;
}
record.draft = draft;
this.records[this.editing.id] = record;
}
clearCurrentDraft() {
const hasValue = this.records[this.editing.id];
delete this.records[this.editing.id];
return !!hasValue;
}
resetToDraft(id) {
const message = this.collection.findOne(id);
const oldValue = this.input.value;
messageBoxState.set(this.input, message.msg);
return oldValue !== message.msg;
}
toPrevMessage() {
const { element } = this.editing;
if (!element) {
const messages = Array.from(this.wrapper.querySelectorAll('.own:not(.system)'));
const message = messages.pop();
return message && this.edit(message, false);
}
for (let previous = element.previousElementSibling; previous; previous = previous.previousElementSibling) {
if (previous.matches('.own:not(.system)')) {
return this.edit(previous, false);
}
}
this.clearEditing();
}
toNextMessage() {
const { element } = this.editing;
if (element) {
let next;
for (next = element.nextElementSibling; next; next = next.nextElementSibling) {
if (next.matches('.own:not(.system)')) {
break;
}
}
next ? this.edit(next, true) : this.clearEditing();
} else {
this.clearEditing();
}
}
edit(element, isEditingTheNextOne) {
const message = this.collection.findOne(element.dataset.id);
const hasPermission = hasAtLeastOnePermission('edit-message', message.rid);
const editAllowed = settings.get('Message_AllowEditing');
const editOwn = message && message.u && message.u._id === Meteor.userId();
if (!hasPermission && (!editAllowed || !editOwn)) {
return;
}
if (element.classList.contains('system')) {
return;
}
const blockEditInMinutes = settings.get('Message_AllowEditing_BlockEditInMinutes');
if (blockEditInMinutes && blockEditInMinutes !== 0) {
let currentTsDiff;
let msgTs;
if (message.ts) {
msgTs = moment(message.ts);
}
if (msgTs) {
currentTsDiff = moment().diff(msgTs, 'minutes');
}
if (currentTsDiff > blockEditInMinutes) {
return;
}
}
const draft = this.records[message._id];
let msg = draft && draft.draft;
msg = msg || message.msg;
this.clearEditing();
this.editing.element = element;
this.editing.id = message._id;
this.input.parentElement.classList.add('editing');
element.classList.add('editing');
if (message.attachments && message.attachments[0].description) {
messageBoxState.set(this.input, message.attachments[0].description);
} else {
messageBoxState.set(this.input, msg);
}
const cursorPosition = isEditingTheNextOne ? 0 : -1;
this.input.focus();
this.$input.setCursorPosition(cursorPosition);
}
clearEditing() {
if (!this.editing.element) {
this.editing.saved = this.input.value;
this.editing.savedCursor = this.input.selectionEnd;
return;
}
this.recordInputAsDraft();
this.input.parentElement.classList.remove('editing');
this.editing.element.classList.remove('editing');
delete this.editing.id;
delete this.editing.element;
messageBoxState.set(this.input, this.editing.saved || '');
const cursorPosition = this.editing.savedCursor ? this.editing.savedCursor : -1;
this.$input.setCursorPosition(cursorPosition);
}
async send(event, { rid, tmid, value, tshow }, done = () => {}) {
const threadsEnabled = settings.get('Threads_enabled');
UserAction.stop(rid, USER_ACTIVITIES.USER_TYPING, { tmid });
if (!ChatSubscription.findOne({ rid })) {
await callWithErrorHandling('joinRoom', rid);
}
messageBoxState.save({ rid, tmid }, this.input);
let msg = value.trim();
if (msg) {
const mention = this.$input.data('mention-user') || false;
const replies = this.$input.data('reply') || [];
if (!mention || !threadsEnabled) {
msg = await prependReplies(msg, replies, mention);
}
if (mention && threadsEnabled && replies.length) {
tmid = replies[0]._id;
}
}
// don't add tmid or tshow if the message isn't part of a thread (it can happen if editing the main message of a thread)
const originalMessage = this.collection.findOne({ _id: this.editing.id }, { fields: { tmid: 1 }, reactive: false });
if (originalMessage && tmid && !originalMessage.tmid) {
tmid = undefined;
tshow = undefined;
}
if (msg) {
readMessage.readNow(rid);
readMessage.refreshUnreadMark(rid);
const message = await onClientBeforeSendMessage({
_id: Random.id(),
rid,
tshow,
tmid,
msg,
});
try {
await this.processMessageSend(message);
this.$input.removeData('reply').trigger('dataChange');
} catch (error) {
handleError(error);
}
return done();
}
if (this.editing.id) {
const message = this.collection.findOne(this.editing.id);
const isDescription = message.attachments && message.attachments[0] && message.attachments[0].description;
try {
if (isDescription) {
await this.processMessageEditing({ _id: this.editing.id, rid, msg: '' });
return done();
}
this.resetToDraft(this.editing.id);
this.confirmDeleteMsg(message, done);
return;
} catch (error) {
handleError(error);
}
}
return done();
}
async processMessageSend(message) {
if (await this.processSetReaction(message)) {
return;
}
this.clearCurrentDraft();
if (await this.processTooLongMessage(message)) {
return;
}
if (await this.processMessageEditing({ ...message, _id: this.editing.id })) {
return;
}
KonchatNotification.removeRoomNotification(message.rid);
if (await this.processSlashCommand(message)) {
return;
}
await callWithErrorHandling('sendMessage', message);
}
async processSetReaction({ rid, tmid, msg }) {
if (msg.slice(0, 2) !== '+:') {
return false;
}
const reaction = msg.slice(1).trim();
if (!emoji.list[reaction]) {
return false;
}
const lastMessage = this.collection.findOne({ rid, tmid }, { fields: { ts: 1 }, sort: { ts: -1 } });
await callWithErrorHandling('setReaction', reaction, lastMessage._id);
return true;
}
async processTooLongMessage({ msg, rid, tmid }) {
const adjustedMessage = messageProperties.messageWithoutEmojiShortnames(msg);
if (messageProperties.length(adjustedMessage) <= settings.get('Message_MaxAllowedSize') && msg) {
return false;
}
if (!settings.get('FileUpload_Enabled') || !settings.get('Message_AllowConvertLongMessagesToAttachment') || this.editing.id) {
throw new Error({ error: 'Message_too_long' });
}
const onConfirm = () => {
const contentType = 'text/plain';
const messageBlob = new Blob([msg], { type: contentType });
const fileName = `${Meteor.user().username} - ${new Date()}.txt`;
const file = new File([messageBlob], fileName, {
type: contentType,
lastModified: Date.now(),
});
fileUpload([{ file, name: fileName }], this.input, { rid, tmid });
imperativeModal.close();
};
const onClose = () => {
messageBoxState.set(this.input, msg);
imperativeModal.close();
};
imperativeModal.open({
component: GenericModal,
props: {
title: t('Message_too_long'),
children: t('Send_it_as_attachment_instead_question'),
onConfirm,
onClose,
onCancel: onClose,
variant: 'warning',
},
});
return true;
}
async processMessageEditing(message) {
if (!message._id) {
return false;
}
if (MessageTypes.isSystemMessage(message)) {
return false;
}
this.clearEditing();
await callWithErrorHandling('updateMessage', message);
return true;
}
async processSlashCommand(msgObject) {
if (msgObject.msg[0] === '/') {
const match = msgObject.msg.match(/^\/([^\s]+)(?:\s+(.*))?$/m);
if (match) {
let command;
if (slashCommands.commands[match[1]]) {
const commandOptions = slashCommands.commands[match[1]];
command = match[1];
const param = match[2] || '';
if (!commandOptions.permission || hasAtLeastOnePermission(commandOptions.permission, Session.get('openedRoom'))) {
if (commandOptions.clientOnly) {
commandOptions.callback(command, param, msgObject);
} else {
const triggerId = generateTriggerId(slashCommands.commands[command].appId);
Meteor.call('slashCommand', { cmd: command, params: param, msg: msgObject, triggerId }, (err, result) => {
typeof commandOptions.result === 'function' &&
commandOptions.result(err, result, {
cmd: command,
params: param,
msg: msgObject,
});
});
}
return true;
}
}
if (!settings.get('Message_AllowUnrecognizedSlashCommand')) {
const invalidCommandMsg = {
_id: Random.id(),
rid: msgObject.rid,
ts: new Date(),
msg: TAPi18n.__('No_such_command', { command: escapeHTML(match[1]) }),
u: {
username: settings.get('InternalHubot_Username') || 'rocket.cat',
},
private: true,
};
this.collection.upsert({ _id: invalidCommandMsg._id }, invalidCommandMsg);
return true;
}
}
}
return false;
}
confirmDeleteMsg(message, done = () => {}) {
if (MessageTypes.isSystemMessage(message)) {
return done();
}
const room =
message.drid &&
Rooms.findOne({
_id: message.drid,
prid: { $exists: true },
});
const onConfirm = () => {
if (this.editing.id === message._id) {
this.clearEditing();
}
this.deleteMsg(message);
this.$input.focus();
done();
imperativeModal.close();
dispatchToastMessage({ type: 'success', message: t('Your_entry_has_been_deleted') });
};
const onCloseModal = () => {
imperativeModal.close();
if (this.editing.id === message._id) {
this.clearEditing();
}
this.$input.focus();
done();
};
imperativeModal.open({
component: GenericModal,
props: {
title: t('Are_you_sure'),
children: room ? t('The_message_is_a_discussion_you_will_not_be_able_to_recover') : t('You_will_not_be_able_to_recover'),
variant: 'danger',
confirmText: t('Yes_delete_it'),
onConfirm,
onClose: onCloseModal,
onCancel: onCloseModal,
},
});
}
async deleteMsg({ _id, rid, ts }) {
const forceDelete = hasAtLeastOnePermission('force-delete-message', rid);
const blockDeleteInMinutes = settings.get('Message_AllowDeleting_BlockDeleteInMinutes');
if (blockDeleteInMinutes && forceDelete === false) {
let msgTs;
if (ts) {
msgTs = moment(ts);
}
let currentTsDiff;
if (msgTs) {
currentTsDiff = moment().diff(msgTs, 'minutes');
}
if (currentTsDiff > blockDeleteInMinutes) {
dispatchToastMessage({ type: 'error', message: t('Message_deleting_blocked') });
return;
}
}
await callWithErrorHandling('deleteMessage', { _id });
}
keydown(event) {
const { currentTarget: input, which: keyCode } = event;
if (keyCode === keyCodes.ESCAPE && this.editing.element) {
event.preventDefault();
event.stopPropagation();
if (!this.resetToDraft(this.editing.id)) {
this.clearCurrentDraft();
this.clearEditing();
return true;
}
return;
}
if (keyCode === keyCodes.ARROW_UP || keyCode === keyCodes.ARROW_DOWN) {
if (event.shiftKey) {
return;
}
const cursorPosition = input.selectionEnd;
if (keyCode === keyCodes.ARROW_UP) {
if (cursorPosition === 0) {
this.toPrevMessage();
} else if (!event.altKey) {
return;
}
if (event.altKey) {
this.$input.setCursorPosition(0);
}
} else {
if (cursorPosition === input.value.length) {
this.toNextMessage();
} else if (!event.altKey) {
return;
}
if (event.altKey) {
this.$input.setCursorPosition(-1);
}
}
event.preventDefault();
event.stopPropagation();
}
}
keyup(event, { rid, tmid }) {
const { currentTarget: input, which: keyCode } = event;
if (!Object.values(keyCodes).includes(keyCode)) {
if (input.value.trim()) {
UserAction.start(rid, USER_ACTIVITIES.USER_TYPING, { tmid });
} else {
UserAction.stop(rid, USER_ACTIVITIES.USER_TYPING, { tmid });
}
}
messageBoxState.save({ rid, tmid }, input);
}
onDestroyed(rid, tmid) {
UserAction.cancel(rid);
// TODO: check why we need too many ?. here :(
if (this.input?.parentElement?.classList.contains('editing') === true) {
if (!tmid) {
this.clearCurrentDraft();
this.clearEditing();
}
messageBoxState.set(this.input, '');
messageBoxState.save({ rid, tmid }, this.$input);
}
}
}