Chore: Restrict `ChatMessages API` - Phase 2 (#27457)
parent
af3ec69236
commit
6163275cd7
@ -1,755 +1,200 @@ |
||||
import { Emitter } from '@rocket.chat/emitter'; |
||||
import { escapeHTML } from '@rocket.chat/string-helpers'; |
||||
import $ from 'jquery'; |
||||
import { Meteor } from 'meteor/meteor'; |
||||
import { Random } from 'meteor/random'; |
||||
import { Session } from 'meteor/session'; |
||||
import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; |
||||
import moment from 'moment'; |
||||
import type { IMessage, IRoom } from '@rocket.chat/core-typings'; |
||||
import type { Mongo } from 'meteor/mongo'; |
||||
|
||||
import { KonchatNotification } from './notification'; |
||||
import { fileUpload } from './fileUpload'; |
||||
import { t, slashCommands, APIClient } from '../../../utils/client'; |
||||
import { messageProperties, MessageTypes, readMessage } from '../../../ui-utils/client'; |
||||
import { settings } from '../../../settings/client'; |
||||
import { hasAtLeastOnePermission } from '../../../authorization/client'; |
||||
import { 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 { prependReplies } from '../../../../client/lib/utils/prependReplies'; |
||||
import { callWithErrorHandling } from '../../../../client/lib/utils/callWithErrorHandling'; |
||||
import { dispatchToastMessage } from '../../../../client/lib/toast'; |
||||
import { onClientBeforeSendMessage } from '../../../../client/lib/onClientBeforeSendMessage'; |
||||
|
||||
import { createUploadsAPI } from '../../../../client/lib/chats/uploads'; |
||||
import { |
||||
setHighlightMessage, |
||||
clearHighlightMessage, |
||||
} from '../../../../client/views/room/MessageList/providers/messageHighlightSubscription'; |
||||
import { UserAction, USER_ACTIVITIES } from './UserAction'; |
||||
import { keyCodes } from '../../../../client/lib/utils/keyCodes'; |
||||
import { withDebouncing } from '../../../../lib/utils/highOrderFunctions'; |
||||
|
||||
class QuotedMessages { |
||||
private emitter = new Emitter<{ update: void }>(); |
||||
|
||||
private messages: IMessage[] = []; |
||||
|
||||
public get(): IMessage[] { |
||||
return this.messages; |
||||
} |
||||
|
||||
public add(message: IMessage): void { |
||||
this.messages = [...this.messages.filter((_message) => _message._id !== message._id), message]; |
||||
this.emitter.emit('update'); |
||||
} |
||||
|
||||
public remove(mid: IMessage['_id']): void { |
||||
this.messages = this.messages.filter((message) => message._id !== mid); |
||||
this.emitter.emit('update'); |
||||
} |
||||
|
||||
public clear(): void { |
||||
this.messages = []; |
||||
this.emitter.emit('update'); |
||||
} |
||||
|
||||
public subscribe(callback: () => void): () => void { |
||||
return this.emitter.on('update', callback); |
||||
} |
||||
} |
||||
|
||||
class ComposerState { |
||||
private emitter = new Emitter<{ update: void }>(); |
||||
|
||||
private key: string; |
||||
|
||||
private state: string | undefined; |
||||
|
||||
public constructor(id: string) { |
||||
this.key = `messagebox_${id}`; |
||||
this.state = Meteor._localStorage.getItem(this.key) ?? undefined; |
||||
} |
||||
|
||||
public get() { |
||||
return this.state; |
||||
} |
||||
|
||||
private persist = withDebouncing({ wait: 1000 })(() => { |
||||
if (this.state) { |
||||
Meteor._localStorage.setItem(this.key, this.state); |
||||
return; |
||||
} |
||||
|
||||
Meteor._localStorage.removeItem(this.key); |
||||
}); |
||||
|
||||
public update(value: string | undefined) { |
||||
this.state = value; |
||||
this.persist(); |
||||
this.emitter.emit('update'); |
||||
} |
||||
|
||||
public subscribe(callback: () => void): () => void { |
||||
return this.emitter.on('update', callback); |
||||
} |
||||
|
||||
public static purgeAll() { |
||||
Object.keys(Meteor._localStorage) |
||||
.filter((key) => key.indexOf('messagebox_') === 0) |
||||
.forEach((key) => Meteor._localStorage.removeItem(key)); |
||||
} |
||||
} |
||||
|
||||
type ChatMessagesParams = |
||||
| { rid: IRoom['_id']; tmid?: IMessage['_id']; input?: never } |
||||
| { rid?: never; tmid?: never; input: HTMLInputElement | HTMLTextAreaElement }; |
||||
|
||||
export class ChatMessages { |
||||
public quotedMessages: QuotedMessages = new QuotedMessages(); |
||||
|
||||
public composerState: ComposerState; |
||||
|
||||
private editing: { |
||||
element?: HTMLElement; |
||||
id?: string; |
||||
saved?: string; |
||||
savedCursor?: number; |
||||
} = {}; |
||||
|
||||
private records: Record< |
||||
IMessage['_id'], |
||||
| { |
||||
draft: string; |
||||
} |
||||
| undefined |
||||
> = {}; |
||||
|
||||
public wrapper: HTMLElement | undefined; |
||||
|
||||
public input: HTMLTextAreaElement | undefined; |
||||
|
||||
public constructor( |
||||
private params: { rid: IRoom['_id']; tmid?: IMessage['_id'] }, |
||||
private collection: Mongo.Collection<Omit<IMessage, '_id'>, IMessage> = ChatMessage, |
||||
) { |
||||
this.quotedMessages.subscribe(() => { |
||||
if (this.input) $(this.input).trigger('dataChange'); |
||||
}); |
||||
|
||||
this.composerState = new ComposerState(params.rid + (params.tmid ? `-${params.tmid}` : '')); |
||||
} |
||||
|
||||
private setDraftAndUpdateInput(value: string | undefined) { |
||||
this.composerState.update(value); |
||||
|
||||
if (value === undefined) return; |
||||
|
||||
if (!this.input) return; |
||||
|
||||
this.input.value = value; |
||||
$(this.input).trigger('change').trigger('input'); |
||||
} |
||||
|
||||
public initializeWrapper(wrapper: HTMLElement) { |
||||
this.wrapper = wrapper; |
||||
} |
||||
|
||||
public initializeInput(input: HTMLTextAreaElement) { |
||||
this.input = input; |
||||
this.setDraftAndUpdateInput(this.composerState.get()); |
||||
} |
||||
|
||||
private recordInputAsDraft() { |
||||
const { input } = this; |
||||
if (!input) { |
||||
return; |
||||
} |
||||
|
||||
const { id } = this.editing; |
||||
if (!id) { |
||||
return; |
||||
} |
||||
|
||||
const message = this.collection.findOne(id); |
||||
if (!message) { |
||||
throw new Error('Message not found'); |
||||
} |
||||
const draft = input.value; |
||||
|
||||
if (draft === message.msg) { |
||||
this.clearCurrentDraft(); |
||||
return; |
||||
} |
||||
|
||||
const record = this.records[id] || { draft }; |
||||
record.draft = draft; |
||||
this.records[id] = record; |
||||
} |
||||
|
||||
private clearCurrentDraft() { |
||||
const { id } = this.editing; |
||||
if (!id) { |
||||
return; |
||||
} |
||||
|
||||
const hasValue = this.records[id]; |
||||
delete this.records[id]; |
||||
return !!hasValue; |
||||
} |
||||
|
||||
private resetToDraft(id: string) { |
||||
const { input } = this; |
||||
if (!input) { |
||||
return; |
||||
} |
||||
|
||||
const message = this.collection.findOne(id); |
||||
if (!message) { |
||||
throw new Error('Message not found'); |
||||
} |
||||
|
||||
const oldValue = input.value; |
||||
this.setDraftAndUpdateInput(message.msg); |
||||
return oldValue !== message.msg; |
||||
} |
||||
|
||||
private toPrevMessage() { |
||||
const { element } = this.editing; |
||||
if (!element) { |
||||
const messages = Array.from(this.wrapper?.querySelectorAll('[data-own="true"]') ?? []); |
||||
const message = messages.pop(); |
||||
return message && this.edit(message as HTMLElement, false); |
||||
} |
||||
|
||||
for (let previous = element.previousElementSibling; previous; previous = previous.previousElementSibling) { |
||||
if (previous.matches('[data-own="true"]')) { |
||||
return this.edit(previous as HTMLElement, false); |
||||
} |
||||
} |
||||
this.clearEditing(); |
||||
} |
||||
|
||||
private toNextMessage() { |
||||
const { element } = this.editing; |
||||
if (element) { |
||||
let next; |
||||
for (next = element.nextElementSibling; next; next = next.nextElementSibling) { |
||||
if (next.matches('[data-own="true"]')) { |
||||
break; |
||||
} |
||||
import type { ChatAPI, ComposerAPI, DataAPI, UploadsAPI } from '../../../../client/lib/chats/ChatAPI'; |
||||
import { createDataAPI } from '../../../../client/lib/chats/data'; |
||||
import { uploadFiles } from '../../../../client/lib/chats/flows/uploadFiles'; |
||||
import { processSlashCommand } from '../../../../client/lib/chats/flows/processSlashCommand'; |
||||
import { requestMessageDeletion } from '../../../../client/lib/chats/flows/requestMessageDeletion'; |
||||
import { processMessageEditing } from '../../../../client/lib/chats/flows/processMessageEditing'; |
||||
import { processTooLongMessage } from '../../../../client/lib/chats/flows/processTooLongMessage'; |
||||
import { processSetReaction } from '../../../../client/lib/chats/flows/processSetReaction'; |
||||
import { sendMessage } from '../../../../client/lib/chats/flows/sendMessage'; |
||||
|
||||
export class ChatMessages implements ChatAPI { |
||||
private currentEditingMID?: string; |
||||
|
||||
public messageEditing: ChatAPI['messageEditing'] = { |
||||
toPreviousMessage: async () => { |
||||
if (!this.composer) { |
||||
return; |
||||
} |
||||
|
||||
next ? this.edit(next as HTMLElement, true) : this.clearEditing(); |
||||
} else { |
||||
this.clearEditing(); |
||||
} |
||||
} |
||||
|
||||
public edit(element: HTMLElement, isEditingTheNextOne?: boolean) { |
||||
const message = this.collection.findOne(element.dataset.id); |
||||
if (!message) { |
||||
throw new Error('Message not found'); |
||||
} |
||||
|
||||
const hasPermission = hasAtLeastOnePermission('edit-message', message.rid); |
||||
const editAllowed = settings.get('Message_AllowEditing'); |
||||
const editOwn = message?.u && message.u._id === Meteor.userId(); |
||||
|
||||
if (!hasPermission && (!editAllowed || !editOwn)) { |
||||
return; |
||||
} |
||||
|
||||
if (MessageTypes.isSystemMessage(message)) { |
||||
return; |
||||
} |
||||
if (!this.currentEditing) { |
||||
const lastMessage = await this.data.findLastOwnMessage(); |
||||
|
||||
const blockEditInMinutes = settings.get('Message_AllowEditing_BlockEditInMinutes'); |
||||
if (blockEditInMinutes && blockEditInMinutes !== 0) { |
||||
let msgTs; |
||||
if (message.ts) { |
||||
msgTs = moment(message.ts); |
||||
} |
||||
if (msgTs) { |
||||
const currentTsDiff = moment().diff(msgTs, 'minutes'); |
||||
|
||||
if (currentTsDiff > blockEditInMinutes) { |
||||
return; |
||||
if (lastMessage) { |
||||
await this.data.saveDraft(undefined, this.composer.text); |
||||
await this.messageEditing.editMessage(lastMessage); |
||||
} |
||||
} |
||||
} |
||||
|
||||
const draft = this.records[message._id]; |
||||
let msg = draft?.draft; |
||||
msg = msg || message.msg; |
||||
|
||||
this.clearEditing(); |
||||
|
||||
const { input } = this; |
||||
|
||||
if (!input) { |
||||
return; |
||||
} |
||||
|
||||
this.editing.element = element; |
||||
this.editing.id = message._id; |
||||
input.parentElement?.classList.add('editing'); |
||||
element.classList.add('editing'); |
||||
setHighlightMessage(message._id); |
||||
|
||||
if (message.attachments?.[0].description) { |
||||
this.setDraftAndUpdateInput(message.attachments[0].description); |
||||
} else if (msg) { |
||||
this.setDraftAndUpdateInput(msg); |
||||
} |
||||
|
||||
const cursorPosition = isEditingTheNextOne ? 0 : input.value.length; |
||||
input.focus(); |
||||
input.setSelectionRange(cursorPosition, cursorPosition); |
||||
} |
||||
|
||||
private clearEditing() { |
||||
const { input } = this; |
||||
|
||||
if (!input) { |
||||
return; |
||||
} |
||||
|
||||
if (!this.editing.element) { |
||||
this.editing.saved = this.input?.value; |
||||
this.editing.savedCursor = this.input?.selectionEnd; |
||||
return; |
||||
} |
||||
|
||||
this.recordInputAsDraft(); |
||||
input.parentElement?.classList.remove('editing'); |
||||
this.editing.element.classList.remove('editing'); |
||||
delete this.editing.id; |
||||
delete this.editing.element; |
||||
clearHighlightMessage(); |
||||
|
||||
this.setDraftAndUpdateInput(this.editing.saved || ''); |
||||
const cursorPosition = this.editing.savedCursor ? this.editing.savedCursor : input.value.length; |
||||
input.setSelectionRange(cursorPosition, cursorPosition); |
||||
} |
||||
|
||||
public async send({ value, tshow }: { value: string; tshow?: boolean }) { |
||||
const { rid } = this.params; |
||||
let { tmid } = this.params; |
||||
|
||||
if (!rid) { |
||||
throw new Error('Room ID is required'); |
||||
} |
||||
|
||||
const threadsEnabled = settings.get('Threads_enabled'); |
||||
|
||||
UserAction.stop(rid, USER_ACTIVITIES.USER_TYPING, { tmid }); |
||||
|
||||
if (!ChatSubscription.findOne({ rid })) { |
||||
await callWithErrorHandling('joinRoom', rid); |
||||
} |
||||
|
||||
if (!this.input) { |
||||
throw new Error('Input is not defined'); |
||||
} |
||||
|
||||
let msg = value.trim(); |
||||
if (msg) { |
||||
const mention = $(this.input).data('mention-user') ?? false; |
||||
const replies = this.quotedMessages.get(); |
||||
if (!mention || !threadsEnabled) { |
||||
msg = await prependReplies(msg, replies, mention); |
||||
} |
||||
|
||||
if (mention && threadsEnabled && replies.length) { |
||||
tmid = replies[0]._id; |
||||
return; |
||||
} |
||||
} |
||||
|
||||
// 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; |
||||
} |
||||
const currentMessage = await this.data.findMessageByID(this.currentEditing.mid); |
||||
const previousMessage = currentMessage ? await this.data.findPreviousOwnMessage(currentMessage) : undefined; |
||||
|
||||
if (msg) { |
||||
readMessage.readNow(rid); |
||||
readMessage.refreshUnreadMark(rid); |
||||
|
||||
const message = (await onClientBeforeSendMessage({ |
||||
_id: Random.id(), |
||||
rid, |
||||
tshow, |
||||
tmid, |
||||
msg, |
||||
})) as IMessage; |
||||
|
||||
try { |
||||
await this.processMessageSend(message); |
||||
this.quotedMessages.clear(); |
||||
} catch (error) { |
||||
dispatchToastMessage({ type: 'error', message: error }); |
||||
if (previousMessage) { |
||||
await this.messageEditing.editMessage(previousMessage); |
||||
return; |
||||
} |
||||
return; |
||||
} |
||||
|
||||
if (this.editing.id) { |
||||
const message = this.collection.findOne(this.editing.id); |
||||
if (!message) { |
||||
throw new Error('Message not found'); |
||||
this.composer.setText((await this.data.getDraft(undefined)) ?? ''); |
||||
await this.currentEditing.stop(); |
||||
}, |
||||
toNextMessage: async () => { |
||||
if (!this.composer || !this.currentEditing) { |
||||
return; |
||||
} |
||||
|
||||
try { |
||||
if (message.attachments && message.attachments?.length > 0) { |
||||
await this.processMessageEditing({ _id: this.editing.id, rid, msg: '' } as IMessage); |
||||
return; |
||||
} |
||||
const currentMessage = await this.data.findMessageByID(this.currentEditing.mid); |
||||
const nextMessage = currentMessage ? await this.data.findNextOwnMessage(currentMessage) : undefined; |
||||
|
||||
this.resetToDraft(this.editing.id); |
||||
await this.confirmDeleteMsg(message); |
||||
if (nextMessage) { |
||||
this.messageEditing.editMessage(nextMessage, { cursorAtStart: true }); |
||||
return; |
||||
} catch (error) { |
||||
dispatchToastMessage({ type: 'error', message: error }); |
||||
} |
||||
} |
||||
} |
||||
|
||||
private async processMessageSend(message: IMessage) { |
||||
if (await this.processSetReaction(message)) { |
||||
return; |
||||
} |
||||
await this.currentEditing.stop(); |
||||
this.composer.setText((await this.data.getDraft(undefined)) ?? ''); |
||||
}, |
||||
editMessage: async (message: IMessage, { cursorAtStart = false }: { cursorAtStart?: boolean } = {}) => { |
||||
const text = (await this.data.getDraft(message._id)) || message.attachments?.[0].description || message.msg; |
||||
const cursorPosition = cursorAtStart ? 0 : text.length; |
||||
|
||||
this.clearCurrentDraft(); |
||||
this.currentEditing?.stop(); |
||||
|
||||
if (await this.processTooLongMessage(message)) { |
||||
return; |
||||
} |
||||
|
||||
if (this.editing.id && (await this.processMessageEditing({ ...message, _id: this.editing.id }))) { |
||||
return; |
||||
} |
||||
|
||||
KonchatNotification.removeRoomNotification(message.rid); |
||||
|
||||
if (await this.processSlashCommand(message)) { |
||||
return; |
||||
} |
||||
|
||||
await callWithErrorHandling('sendMessage', message); |
||||
} |
||||
|
||||
private async processSetReaction({ rid, tmid, msg }: Pick<IMessage, 'msg' | 'rid' | 'tmid'>) { |
||||
if (msg.slice(0, 2) !== '+:') { |
||||
return false; |
||||
} |
||||
|
||||
const reaction = msg.slice(1).trim(); |
||||
if (!emoji.list[reaction]) { |
||||
return false; |
||||
} |
||||
if (!this.composer || !(await this.data.canUpdateMessage(message))) { |
||||
return; |
||||
} |
||||
|
||||
const lastMessage = this.collection.findOne({ rid, tmid }, { fields: { ts: 1 }, sort: { ts: -1 } }); |
||||
if (!lastMessage) { |
||||
throw new Error('Message not found'); |
||||
} |
||||
await callWithErrorHandling('setReaction', reaction, lastMessage._id); |
||||
return true; |
||||
} |
||||
this.currentEditingMID = message._id; |
||||
setHighlightMessage(message._id); |
||||
this.composer?.setEditingMode(true); |
||||
|
||||
private async processTooLongMessage({ msg, rid, tmid }: Pick<IMessage, 'msg' | 'rid' | 'tmid'>) { |
||||
const adjustedMessage = messageProperties.messageWithoutEmojiShortnames(msg); |
||||
if (messageProperties.length(adjustedMessage) <= settings.get('Message_MaxAllowedSize') && msg) { |
||||
return false; |
||||
} |
||||
this.composer.setText(text, { selection: { start: cursorPosition, end: cursorPosition } }); |
||||
this.composer?.focus(); |
||||
}, |
||||
}; |
||||
|
||||
if (!settings.get('FileUpload_Enabled') || !settings.get('Message_AllowConvertLongMessagesToAttachment') || this.editing.id) { |
||||
throw new Error(t('Message_too_long')); |
||||
} |
||||
public composer: ComposerAPI | undefined; |
||||
|
||||
const { input } = this; |
||||
const user = Meteor.user(); |
||||
public readonly data: DataAPI; |
||||
|
||||
if (!input || !user) { |
||||
throw new Error('Input or user is not defined'); |
||||
} |
||||
public readonly uploads: UploadsAPI; |
||||
|
||||
const onConfirm = () => { |
||||
const contentType = 'text/plain'; |
||||
const messageBlob = new Blob([msg], { type: contentType }); |
||||
const fileName = `${user.username} - ${new Date()}.txt`; |
||||
const file = new File([messageBlob], fileName, { |
||||
type: contentType, |
||||
lastModified: Date.now(), |
||||
}); |
||||
fileUpload([{ file, name: fileName }], input, { rid, tmid }); |
||||
imperativeModal.close(); |
||||
}; |
||||
public readonly flows: ChatAPI['flows']; |
||||
|
||||
const onClose = () => { |
||||
this.setDraftAndUpdateInput(msg); |
||||
imperativeModal.close(); |
||||
public constructor(private params: { rid: IRoom['_id']; tmid?: IMessage['_id'] }) { |
||||
this.data = createDataAPI({ rid: params.rid, tmid: params.tmid }); |
||||
this.uploads = createUploadsAPI({ rid: params.rid, tmid: params.tmid }); |
||||
this.flows = { |
||||
uploadFiles: uploadFiles.bind(null, this), |
||||
sendMessage: sendMessage.bind(this, this), |
||||
processSlashCommand: processSlashCommand.bind(null, this), |
||||
processTooLongMessage: processTooLongMessage.bind(null, this), |
||||
processMessageEditing: processMessageEditing.bind(null, this), |
||||
processSetReaction: processSetReaction.bind(null, this), |
||||
requestMessageDeletion: requestMessageDeletion.bind(this, this), |
||||
}; |
||||
|
||||
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; |
||||
} |
||||
|
||||
private async processMessageEditing(message: IMessage) { |
||||
if (!message._id) { |
||||
return false; |
||||
} |
||||
public setComposerAPI(composer: ComposerAPI): void { |
||||
this.composer?.release(); |
||||
this.composer = composer; |
||||
} |
||||
|
||||
if (MessageTypes.isSystemMessage(message)) { |
||||
return false; |
||||
public get currentEditing() { |
||||
if (!this.composer || !this.currentEditingMID) { |
||||
return undefined; |
||||
} |
||||
|
||||
this.clearEditing(); |
||||
await callWithErrorHandling('updateMessage', message); |
||||
return true; |
||||
} |
||||
|
||||
public async processSlashCommand(msgObject: IMessage) { |
||||
if (msgObject.msg[0] === '/') { |
||||
const match = msgObject.msg.match(/^\/([^\s]+)/m); |
||||
if (match) { |
||||
const command = match[1]; |
||||
|
||||
if (slashCommands.commands[command]) { |
||||
const commandOptions = slashCommands.commands[command]; |
||||
const param = msgObject.msg.replace(/^\/([^\s]+)/m, ''); |
||||
|
||||
if (!commandOptions.permission || hasAtLeastOnePermission(commandOptions.permission, Session.get('openedRoom'))) { |
||||
if (commandOptions.clientOnly) { |
||||
commandOptions.callback?.(command, param, msgObject); |
||||
} else { |
||||
APIClient.post('/v1/statistics.telemetry', { params: [{ eventName: 'slashCommandsStats', timestamp: Date.now(), command }] }); |
||||
const triggerId = generateTriggerId(slashCommands.commands[command].appId); |
||||
Meteor.call('slashCommand', { cmd: command, params: param, msg: msgObject, triggerId }, (err: Error, result: never) => { |
||||
typeof commandOptions.result === 'function' && |
||||
commandOptions.result(err, result, { |
||||
cmd: command, |
||||
params: param, |
||||
msg: msgObject, |
||||
}); |
||||
}); |
||||
} |
||||
|
||||
return true; |
||||
} |
||||
return { |
||||
mid: this.currentEditingMID, |
||||
reset: async (): Promise<boolean> => { |
||||
if (!this.composer || !this.currentEditingMID) { |
||||
return false; |
||||
} |
||||
|
||||
if (!settings.get('Message_AllowUnrecognizedSlashCommand')) { |
||||
console.error(TAPi18n.__('No_such_command', { command: escapeHTML(command) })); |
||||
const invalidCommandMsg = { |
||||
_id: Random.id(), |
||||
rid: msgObject.rid, |
||||
ts: new Date(), |
||||
msg: TAPi18n.__('No_such_command', { command: escapeHTML(command) }), |
||||
u: { |
||||
_id: 'rocket.cat', |
||||
username: 'rocket.cat', |
||||
name: 'Rocket.Cat', |
||||
}, |
||||
private: true, |
||||
}; |
||||
|
||||
this.collection.upsert({ _id: invalidCommandMsg._id }, { $set: invalidCommandMsg }); |
||||
const message = await this.data.findMessageByID(this.currentEditingMID); |
||||
if (this.composer.text !== message?.msg) { |
||||
this.composer.setText(message?.msg ?? ''); |
||||
return true; |
||||
} |
||||
} |
||||
} |
||||
|
||||
return false; |
||||
} |
||||
|
||||
public async confirmDeleteMsg(message: IMessage) { |
||||
if (MessageTypes.isSystemMessage(message)) { |
||||
return; |
||||
} |
||||
|
||||
const room = |
||||
message.drid && |
||||
Rooms.findOne({ |
||||
_id: message.drid, |
||||
prid: { $exists: true }, |
||||
}); |
||||
|
||||
await new Promise<void>((resolve) => { |
||||
const onConfirm = () => { |
||||
if (this.editing.id === message._id) { |
||||
this.clearEditing(); |
||||
} |
||||
|
||||
this.deleteMsg(message); |
||||
|
||||
this.input?.focus(); |
||||
resolve(); |
||||
|
||||
imperativeModal.close(); |
||||
dispatchToastMessage({ type: 'success', message: t('Your_entry_has_been_deleted') }); |
||||
}; |
||||
|
||||
const onCloseModal = () => { |
||||
imperativeModal.close(); |
||||
if (this.editing.id === message._id) { |
||||
this.clearEditing(); |
||||
return false; |
||||
}, |
||||
stop: async (): Promise<void> => { |
||||
if (!this.composer || !this.currentEditingMID) { |
||||
return; |
||||
} |
||||
this.input?.focus(); |
||||
resolve(); |
||||
}; |
||||
|
||||
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, |
||||
}, |
||||
}); |
||||
}); |
||||
} |
||||
|
||||
public async deleteMsg({ _id, rid, ts }: Pick<IMessage, '_id' | 'rid' | 'ts'>) { |
||||
const forceDelete = hasAtLeastOnePermission('force-delete-message', rid); |
||||
const blockDeleteInMinutes = settings.get('Message_AllowDeleting_BlockDeleteInMinutes'); |
||||
if (blockDeleteInMinutes && forceDelete === false) { |
||||
const msgTs = moment(ts); |
||||
const currentTsDiff = moment().diff(msgTs, 'minutes'); |
||||
|
||||
if (currentTsDiff > blockDeleteInMinutes) { |
||||
dispatchToastMessage({ type: 'error', message: t('Message_deleting_blocked') }); |
||||
return; |
||||
} |
||||
} |
||||
|
||||
await callWithErrorHandling('deleteMessage', { _id }); |
||||
} |
||||
|
||||
public keydown(event: KeyboardEvent) { |
||||
const input = event.currentTarget as HTMLTextAreaElement; |
||||
const keyCode = event.which; |
||||
const { id } = this.editing; |
||||
|
||||
if (keyCode === keyCodes.ESCAPE && this.editing.element) { |
||||
event.preventDefault(); |
||||
event.stopPropagation(); |
||||
|
||||
if (!id || !this.resetToDraft(id)) { |
||||
this.clearCurrentDraft(); |
||||
this.clearEditing(); |
||||
return true; |
||||
} |
||||
|
||||
return; |
||||
} |
||||
|
||||
if (keyCode === keyCodes.ARROW_UP || keyCode === keyCodes.ARROW_DOWN) { |
||||
if (event.shiftKey) { |
||||
return; |
||||
} |
||||
const message = await this.data.findMessageByID(this.currentEditingMID); |
||||
const draft = this.composer.text; |
||||
|
||||
const cursorPosition = input.selectionEnd; |
||||
|
||||
if (keyCode === keyCodes.ARROW_UP) { |
||||
if (cursorPosition === 0) { |
||||
this.toPrevMessage(); |
||||
} else if (!event.altKey) { |
||||
return; |
||||
if (draft === message?.msg) { |
||||
await this.data.discardDraft(this.currentEditingMID); |
||||
} else { |
||||
await this.data.saveDraft(this.currentEditingMID, (await this.data.getDraft(this.currentEditingMID)) || draft); |
||||
} |
||||
|
||||
if (event.altKey) { |
||||
this.input?.setSelectionRange(0, 0); |
||||
} |
||||
} else { |
||||
if (cursorPosition === input.value.length) { |
||||
this.toNextMessage(); |
||||
} else if (!event.altKey) { |
||||
this.composer.setEditingMode(false); |
||||
this.currentEditingMID = undefined; |
||||
clearHighlightMessage(); |
||||
}, |
||||
cancel: async (): Promise<void> => { |
||||
if (!this.currentEditingMID) { |
||||
return; |
||||
} |
||||
|
||||
if (event.altKey) { |
||||
this.input?.setSelectionRange(this.input.value.length, this.input.value.length); |
||||
} |
||||
} |
||||
|
||||
event.preventDefault(); |
||||
event.stopPropagation(); |
||||
} |
||||
} |
||||
|
||||
public keyup(event: KeyboardEvent, { rid, tmid }: { rid: IRoom['_id']; tmid?: IMessage['_id'] }) { |
||||
const input = event.currentTarget as HTMLTextAreaElement; |
||||
const keyCode = event.which; |
||||
|
||||
if (!Object.values<number>(keyCodes).includes(keyCode)) { |
||||
if (input?.value.trim()) { |
||||
UserAction.start(rid, USER_ACTIVITIES.USER_TYPING, { tmid }); |
||||
} else { |
||||
UserAction.stop(rid, USER_ACTIVITIES.USER_TYPING, { tmid }); |
||||
} |
||||
} |
||||
|
||||
this.setDraftAndUpdateInput(input.value); |
||||
await this.data.discardDraft(this.currentEditingMID); |
||||
this.currentEditing?.stop(); |
||||
}, |
||||
}; |
||||
} |
||||
|
||||
public onDestroyed(rid: IRoom['_id'], tmid?: IMessage['_id']) { |
||||
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(); |
||||
private release() { |
||||
this.composer?.release(); |
||||
if (this.currentEditing) { |
||||
if (!this.params.tmid) { |
||||
this.currentEditing.cancel(); |
||||
} |
||||
this.setDraftAndUpdateInput(''); |
||||
this.composer?.clear(); |
||||
} |
||||
} |
||||
|
||||
private static instances: Record<string, ChatMessages> = {}; |
||||
|
||||
private static getID({ rid, tmid, input }: ChatMessagesParams): string { |
||||
if (input) { |
||||
const id = Object.entries(this.instances).find(([, instance]) => instance.input === input)?.[0]; |
||||
if (!id) throw new Error('ChatMessages: input not found'); |
||||
|
||||
return id; |
||||
} |
||||
private static refs = new Map<string, { instance: ChatMessages; count: number }>(); |
||||
|
||||
private static getID({ rid, tmid }: { rid: IRoom['_id']; tmid?: IMessage['_id'] }): string { |
||||
return `${rid}${tmid ? `-${tmid}` : ''}`; |
||||
} |
||||
|
||||
public static get(params: ChatMessagesParams): ChatMessages | undefined { |
||||
return this.instances[this.getID(params)]; |
||||
} |
||||
public static hold({ rid, tmid }: { rid: IRoom['_id']; tmid?: IMessage['_id'] }) { |
||||
const id = this.getID({ rid, tmid }); |
||||
|
||||
const ref = this.refs.get(id) ?? { instance: new ChatMessages({ rid, tmid }), count: 0 }; |
||||
ref.count++; |
||||
this.refs.set(id, ref); |
||||
|
||||
public static set(params: ChatMessagesParams, instance: ChatMessages) { |
||||
this.instances[this.getID(params)] = instance; |
||||
return ref.instance; |
||||
} |
||||
|
||||
public static delete({ rid, tmid }: { rid: IRoom['_id']; tmid?: IMessage['_id'] }) { |
||||
public static release({ rid, tmid }: { rid: IRoom['_id']; tmid?: IMessage['_id'] }) { |
||||
const id = this.getID({ rid, tmid }); |
||||
this.instances[id].onDestroyed(rid, tmid); |
||||
delete this.instances[id]; |
||||
} |
||||
|
||||
public static purgeAllDrafts() { |
||||
ComposerState.purgeAll(); |
||||
const ref = this.refs.get(id); |
||||
if (!ref) { |
||||
return; |
||||
} |
||||
|
||||
ref.count--; |
||||
if (ref.count === 0) { |
||||
this.refs.delete(id); |
||||
ref.instance.release(); |
||||
} |
||||
} |
||||
} |
||||
|
||||
@ -1,245 +0,0 @@ |
||||
import { Tracker } from 'meteor/tracker'; |
||||
import { Session } from 'meteor/session'; |
||||
import { Random } from 'meteor/random'; |
||||
import { Meteor } from 'meteor/meteor'; |
||||
import { isRoomFederated } from '@rocket.chat/core-typings'; |
||||
|
||||
import { settings } from '../../../settings/client'; |
||||
import { UserAction, USER_ACTIVITIES } from './UserAction'; |
||||
import { fileUploadIsValidContentType, APIClient } from '../../../utils/client'; |
||||
import { imperativeModal } from '../../../../client/lib/imperativeModal'; |
||||
import FileUploadModal from '../../../../client/views/room/modals/FileUploadModal'; |
||||
import { prependReplies } from '../../../../client/lib/utils/prependReplies'; |
||||
import { ChatMessages } from './ChatMessages'; |
||||
import { getErrorMessage } from '../../../../client/lib/errorHandling'; |
||||
import { Rooms } from '../../../models/client'; |
||||
|
||||
export type Uploading = { |
||||
id: string; |
||||
name: string; |
||||
percentage: number; |
||||
error?: Error; |
||||
}; |
||||
|
||||
declare module 'meteor/session' { |
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace Session { |
||||
function get(key: 'uploading'): Uploading[]; |
||||
function set(key: 'uploading', param: Uploading[]): void; |
||||
} |
||||
} |
||||
|
||||
Session.setDefault('uploading', []); |
||||
|
||||
export const uploadFileWithMessage = async ( |
||||
rid: string, |
||||
{ |
||||
description, |
||||
msg, |
||||
file, |
||||
}: { |
||||
file: File; |
||||
description?: string; |
||||
msg?: string; |
||||
}, |
||||
tmid?: string, |
||||
): Promise<void> => { |
||||
const uploads = Session.get('uploading'); |
||||
|
||||
const upload = { |
||||
id: Random.id(), |
||||
name: file.name, |
||||
percentage: 0, |
||||
}; |
||||
|
||||
uploads.push(upload); |
||||
Session.set('uploading', uploads); |
||||
|
||||
try { |
||||
await new Promise((resolve, reject) => { |
||||
const xhr = APIClient.upload( |
||||
`/v1/rooms.upload/${rid}`, |
||||
{ |
||||
msg, |
||||
tmid, |
||||
file, |
||||
description, |
||||
}, |
||||
{ |
||||
load: (event) => { |
||||
return resolve(event); |
||||
}, |
||||
progress: (event) => { |
||||
if (!event.lengthComputable) { |
||||
return; |
||||
} |
||||
const progress = (event.loaded / event.total) * 100; |
||||
if (progress === 100) { |
||||
return; |
||||
} |
||||
|
||||
const uploads = Session.get('uploading'); |
||||
|
||||
uploads |
||||
.filter((u) => u.id === upload.id) |
||||
.forEach((u) => { |
||||
u.percentage = Math.round(progress) || 0; |
||||
}); |
||||
Session.set('uploading', uploads); |
||||
}, |
||||
error: (error) => { |
||||
const uploads = Session.get('uploading'); |
||||
uploads |
||||
.filter((u) => u.id === upload.id) |
||||
.forEach((u) => { |
||||
u.error = new Error(xhr.responseText); |
||||
u.percentage = 0; |
||||
}); |
||||
Session.set('uploading', uploads); |
||||
reject(error); |
||||
}, |
||||
}, |
||||
); |
||||
|
||||
if (Session.get('uploading').length) { |
||||
UserAction.performContinuously(rid, USER_ACTIVITIES.USER_UPLOADING, { tmid }); |
||||
} |
||||
|
||||
Tracker.autorun((computation) => { |
||||
const isCanceling = Session.get(`uploading-cancel-${upload.id}`); |
||||
if (!isCanceling) { |
||||
return; |
||||
} |
||||
computation.stop(); |
||||
Session.delete(`uploading-cancel-${upload.id}`); |
||||
|
||||
xhr.abort(); |
||||
|
||||
const uploads = Session.get('uploading'); |
||||
Session.set( |
||||
'uploading', |
||||
uploads.filter((u) => u.id !== upload.id), |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
const uploads = Session.get('uploading'); |
||||
Session.set( |
||||
'uploading', |
||||
uploads.filter((u) => u.id !== upload.id), |
||||
); |
||||
|
||||
if (!Session.get('uploading').length) { |
||||
UserAction.stop(rid, USER_ACTIVITIES.USER_UPLOADING, { tmid }); |
||||
} |
||||
} catch (error: unknown) { |
||||
const uploads = Session.get('uploading'); |
||||
uploads |
||||
.filter((u) => u.id === upload.id) |
||||
.forEach((u) => { |
||||
u.error = new Error(getErrorMessage(error)); |
||||
u.percentage = 0; |
||||
}); |
||||
if (!uploads.length) { |
||||
UserAction.stop(rid, USER_ACTIVITIES.USER_UPLOADING, { tmid }); |
||||
} |
||||
Session.set('uploading', uploads); |
||||
} |
||||
}; |
||||
|
||||
type SingleOrArray<T> = T | T[]; |
||||
|
||||
/* @deprecated */ |
||||
export type FileUploadProp = SingleOrArray<{ |
||||
file: File; |
||||
name: string; |
||||
}>; |
||||
|
||||
/* @deprecated */ |
||||
export const fileUpload = async ( |
||||
f: FileUploadProp, |
||||
input: HTMLInputElement | HTMLTextAreaElement | undefined, |
||||
{ |
||||
rid, |
||||
tmid, |
||||
}: { |
||||
rid: string; |
||||
tmid?: string; |
||||
}, |
||||
): Promise<void> => { |
||||
if (!f) { |
||||
throw new Error('No files to upload'); |
||||
} |
||||
|
||||
const threadsEnabled = settings.get('Threads_enabled'); |
||||
|
||||
const files = Array.isArray(f) ? f : [f]; |
||||
|
||||
const chatMessagesInstance = input ? ChatMessages.get({ input }) : undefined; |
||||
|
||||
const replies = chatMessagesInstance?.quotedMessages.get() ?? []; |
||||
const mention = input ? $(input).data('mention-user') : false; |
||||
|
||||
let msg = ''; |
||||
|
||||
if (!mention || !threadsEnabled) { |
||||
msg = await prependReplies('', replies, mention); |
||||
} |
||||
|
||||
if (mention && threadsEnabled && replies.length) { |
||||
tmid = replies[0]._id; |
||||
} |
||||
|
||||
const key = ['messagebox', rid, tmid].filter(Boolean).join('_'); |
||||
const messageBoxText = Meteor._localStorage.getItem(key) || ''; |
||||
const room = Rooms.findOne({ _id: rid }); |
||||
|
||||
const uploadNextFile = (): void => { |
||||
const file = files.pop(); |
||||
if (!file) { |
||||
chatMessagesInstance?.quotedMessages.clear(); |
||||
return; |
||||
} |
||||
|
||||
imperativeModal.open({ |
||||
component: FileUploadModal, |
||||
props: { |
||||
file: file.file, |
||||
fileName: file.name, |
||||
fileDescription: messageBoxText, |
||||
showDescription: room && !isRoomFederated(room), |
||||
onClose: (): void => { |
||||
imperativeModal.close(); |
||||
uploadNextFile(); |
||||
}, |
||||
onSubmit: (fileName: string, description?: string): void => { |
||||
Object.defineProperty(file.file, 'name', { |
||||
writable: true, |
||||
value: fileName, |
||||
}); |
||||
uploadFileWithMessage( |
||||
rid, |
||||
{ |
||||
description, |
||||
msg, |
||||
file: file.file, |
||||
}, |
||||
tmid, |
||||
); |
||||
const localStorageKey = ['messagebox', rid, tmid].filter(Boolean).join('_'); |
||||
const input = ChatMessages.get({ rid, tmid })?.input; |
||||
if (input) { |
||||
input.value = ''; |
||||
$(input).trigger('input'); |
||||
} |
||||
Meteor._localStorage.removeItem(localStorageKey); |
||||
imperativeModal.close(); |
||||
uploadNextFile(); |
||||
}, |
||||
invalidContentType: Boolean(file.file.type && !fileUploadIsValidContentType(file.file.type)), |
||||
}, |
||||
}); |
||||
}; |
||||
|
||||
uploadNextFile(); |
||||
}; |
||||
@ -1,8 +1,12 @@ |
||||
import type { ContextType } from 'react'; |
||||
|
||||
import type { ToolboxContextValue } from '../../../../../../client/views/room/contexts/ToolboxContext'; |
||||
import type { ChatContext } from '../../../../../../client/views/room/contexts/ChatContext'; |
||||
|
||||
export type CommonRoomTemplateInstance = { |
||||
data: { |
||||
rid: string; |
||||
tabBar: ToolboxContextValue; |
||||
chatContext: ContextType<typeof ChatContext>; |
||||
}; |
||||
}; |
||||
|
||||
@ -0,0 +1,84 @@ |
||||
import { IMessage, MessageAttachmentAction } from '@rocket.chat/core-typings'; |
||||
import { Button } from '@rocket.chat/fuselage'; |
||||
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; |
||||
import { useMutation, UseMutationOptions, UseMutationResult } from '@tanstack/react-query'; |
||||
import React, { ReactElement, ReactNode } from 'react'; |
||||
|
||||
import { useChat } from '../../../views/room/contexts/ChatContext'; |
||||
|
||||
type ProcessingType = Exclude<MessageAttachmentAction['actions'][number]['msg_processing_type'], undefined>; |
||||
|
||||
type UsePerfomActionMutationParams = { |
||||
processingType: ProcessingType; |
||||
msg?: string; |
||||
mid?: IMessage['_id']; |
||||
}; |
||||
|
||||
type ActionAttachmentButtonProps = { |
||||
children: ReactNode; |
||||
mid?: IMessage['_id']; |
||||
msg?: string; |
||||
processingType: ProcessingType; |
||||
}; |
||||
|
||||
const usePerformActionMutation = ( |
||||
options?: Omit<UseMutationOptions<void, Error, UsePerfomActionMutationParams>, 'mutationFn'>, |
||||
): UseMutationResult<void, Error, UsePerfomActionMutationParams> => { |
||||
const chat = useChat(); |
||||
|
||||
return useMutation(async ({ processingType, msg, mid }) => { |
||||
if (!chat) { |
||||
return; |
||||
} |
||||
|
||||
switch (processingType) { |
||||
case 'sendMessage': |
||||
if (!msg) return; |
||||
await chat.flows.sendMessage({ text: msg }); |
||||
return; |
||||
|
||||
case 'respondWithMessage': |
||||
if (!msg) return; |
||||
await chat.composer?.replyWith(msg); |
||||
return; |
||||
|
||||
case 'respondWithQuotedMessage': |
||||
if (!mid) return; |
||||
const message = await chat.data.getMessageByID(mid); |
||||
await chat.composer?.quoteMessage(message); |
||||
} |
||||
}, options); |
||||
}; |
||||
|
||||
const ActionAttachmentButton = ({ children, processingType, msg, mid }: ActionAttachmentButtonProps): ReactElement => { |
||||
const dispatchToastMessage = useToastMessageDispatch(); |
||||
|
||||
const performActionMutation = usePerformActionMutation({ |
||||
onError: (error) => { |
||||
console.error(error); |
||||
dispatchToastMessage({ type: 'error', message: error }); |
||||
}, |
||||
}); |
||||
|
||||
return ( |
||||
<Button |
||||
small |
||||
value={msg} |
||||
id={mid} |
||||
disabled={performActionMutation.isLoading} |
||||
onClick={(event): void => { |
||||
event.preventDefault(); |
||||
|
||||
performActionMutation.mutate({ |
||||
processingType, |
||||
msg, |
||||
mid, |
||||
}); |
||||
}} |
||||
> |
||||
{children} |
||||
</Button> |
||||
); |
||||
}; |
||||
|
||||
export default ActionAttachmentButton; |
||||
@ -0,0 +1,97 @@ |
||||
import { IMessage, IRoom } from '@rocket.chat/core-typings'; |
||||
|
||||
import { Upload } from './Upload'; |
||||
|
||||
export type ComposerAPI = { |
||||
release(): void; |
||||
readonly text: string; |
||||
readonly selection: { readonly start: number; readonly end: number }; |
||||
setText( |
||||
text: string, |
||||
options?: { |
||||
selection?: |
||||
| { readonly start?: number; readonly end?: number } |
||||
| ((previous: { readonly start: number; readonly end: number }) => { readonly start?: number; readonly end?: number }); |
||||
}, |
||||
): void; |
||||
clear(): void; |
||||
focus(): void; |
||||
replyWith(text: string): Promise<void>; |
||||
quoteMessage(message: IMessage): Promise<void>; |
||||
dismissQuotedMessage(mid: IMessage['_id']): Promise<void>; |
||||
dismissAllQuotedMessages(): Promise<void>; |
||||
readonly quotedMessages: { |
||||
get(): IMessage[]; |
||||
subscribe(callback: () => void): () => void; |
||||
}; |
||||
setEditingMode(editing: boolean): void; |
||||
}; |
||||
|
||||
export type DataAPI = { |
||||
composeMessage( |
||||
text: string, |
||||
options: { sendToChannel?: boolean; quotedMessages: IMessage[]; originalMessage?: IMessage }, |
||||
): Promise<IMessage>; |
||||
findMessageByID(mid: IMessage['_id']): Promise<IMessage | undefined>; |
||||
getMessageByID(mid: IMessage['_id']): Promise<IMessage>; |
||||
findLastMessage(): Promise<IMessage | undefined>; |
||||
getLastMessage(): Promise<IMessage>; |
||||
findLastOwnMessage(): Promise<IMessage | undefined>; |
||||
getLastOwnMessage(): Promise<IMessage>; |
||||
findPreviousOwnMessage(message: IMessage): Promise<IMessage | undefined>; |
||||
getPreviousOwnMessage(message: IMessage): Promise<IMessage>; |
||||
findNextOwnMessage(message: IMessage): Promise<IMessage | undefined>; |
||||
getNextOwnMessage(message: IMessage): Promise<IMessage>; |
||||
pushEphemeralMessage(message: Omit<IMessage, 'rid' | 'tmid'>): Promise<void>; |
||||
canUpdateMessage(message: IMessage): Promise<boolean>; |
||||
updateMessage(message: Pick<IMessage, '_id' | 't'> & Partial<Omit<IMessage, '_id' | 't'>>): Promise<void>; |
||||
canDeleteMessage(message: IMessage): Promise<boolean>; |
||||
deleteMessage(mid: IMessage['_id']): Promise<void>; |
||||
getDraft(mid: IMessage['_id'] | undefined): Promise<string | undefined>; |
||||
discardDraft(mid: IMessage['_id'] | undefined): Promise<void>; |
||||
saveDraft(mid: IMessage['_id'] | undefined, text: string): Promise<void>; |
||||
findRoom(): Promise<IRoom | undefined>; |
||||
getRoom(): Promise<IRoom>; |
||||
isSubscribedToRoom(): Promise<boolean>; |
||||
joinRoom(): Promise<void>; |
||||
markRoomAsRead(): Promise<void>; |
||||
findDiscussionByID(drid: IRoom['_id']): Promise<IRoom | undefined>; |
||||
getDiscussionByID(drid: IRoom['_id']): Promise<IRoom>; |
||||
}; |
||||
|
||||
export type UploadsAPI = { |
||||
get(): readonly Upload[]; |
||||
subscribe(callback: () => void): () => void; |
||||
wipeFailedOnes(): void; |
||||
cancel(id: Upload['id']): void; |
||||
send(file: File, { description, msg }: { description?: string; msg?: string }): Promise<void>; |
||||
}; |
||||
|
||||
export type ChatAPI = { |
||||
readonly composer?: ComposerAPI; |
||||
readonly setComposerAPI: (composer: ComposerAPI) => void; |
||||
readonly data: DataAPI; |
||||
readonly uploads: UploadsAPI; |
||||
readonly messageEditing: { |
||||
toPreviousMessage(): Promise<void>; |
||||
toNextMessage(): Promise<void>; |
||||
editMessage(message: IMessage, options?: { cursorAtStart?: boolean }): Promise<void>; |
||||
}; |
||||
readonly currentEditing: |
||||
| { |
||||
readonly mid: IMessage['_id']; |
||||
reset(): Promise<boolean>; |
||||
stop(): Promise<void>; |
||||
cancel(): Promise<void>; |
||||
} |
||||
| undefined; |
||||
readonly flows: { |
||||
readonly uploadFiles: (files: readonly File[]) => Promise<void>; |
||||
readonly sendMessage: ({ text, tshow }: { text: string; tshow?: boolean }) => Promise<void>; |
||||
readonly processSlashCommand: (message: IMessage) => Promise<boolean>; |
||||
readonly processTooLongMessage: (message: IMessage) => Promise<boolean>; |
||||
readonly processMessageEditing: (message: Pick<IMessage, '_id' | 't'> & Partial<Omit<IMessage, '_id' | 't'>>) => Promise<boolean>; |
||||
readonly processSetReaction: (message: Pick<IMessage, 'msg'>) => Promise<boolean>; |
||||
readonly requestMessageDeletion: (message: IMessage) => Promise<void>; |
||||
}; |
||||
}; |
||||
@ -0,0 +1,6 @@ |
||||
export type Upload = { |
||||
readonly id: string; |
||||
readonly name: string; |
||||
readonly percentage: number; |
||||
readonly error?: Error; |
||||
}; |
||||
@ -0,0 +1,262 @@ |
||||
import type { IMessage, IRoom, ISubscription } from '@rocket.chat/core-typings'; |
||||
import type { Mongo } from 'meteor/mongo'; |
||||
import moment from 'moment'; |
||||
|
||||
import { hasAtLeastOnePermission } from '../../../app/authorization/client'; |
||||
import { Messages, Rooms, Subscriptions } from '../../../app/models/client'; |
||||
import { settings } from '../../../app/settings/client'; |
||||
import { readMessage, MessageTypes } from '../../../app/ui-utils/client'; |
||||
import { getRandomId } from '../../../lib/random'; |
||||
import { onClientBeforeSendMessage } from '../onClientBeforeSendMessage'; |
||||
import { call } from '../utils/call'; |
||||
import { prependReplies } from '../utils/prependReplies'; |
||||
import { DataAPI } from './ChatAPI'; |
||||
|
||||
const messagesCollection = Messages as Mongo.Collection<IMessage>; |
||||
const roomsCollection = Rooms as Mongo.Collection<IRoom>; |
||||
const subscriptionsCollection = Subscriptions as Mongo.Collection<ISubscription>; |
||||
|
||||
export const createDataAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid: IMessage['_id'] | undefined }): DataAPI => { |
||||
const composeMessage = async ( |
||||
text: string, |
||||
{ sendToChannel, quotedMessages, originalMessage }: { sendToChannel?: boolean; quotedMessages: IMessage[]; originalMessage?: IMessage }, |
||||
): Promise<IMessage> => { |
||||
const msg = await prependReplies(text, quotedMessages); |
||||
|
||||
const effectiveRID = originalMessage?.rid ?? rid; |
||||
const effectiveTMID = originalMessage ? originalMessage.tmid : tmid; |
||||
|
||||
return (await onClientBeforeSendMessage({ |
||||
_id: originalMessage?._id ?? getRandomId(), |
||||
rid: effectiveRID, |
||||
...(effectiveTMID && { |
||||
tmid: effectiveTMID, |
||||
...(sendToChannel && { tshow: sendToChannel }), |
||||
}), |
||||
msg, |
||||
})) as IMessage; |
||||
}; |
||||
|
||||
const findMessageByID = async (mid: IMessage['_id']): Promise<IMessage | undefined> => |
||||
messagesCollection.findOne({ _id: mid, _hidden: { $ne: true } }, { reactive: false }) ?? call('getSingleMessage', mid); |
||||
|
||||
const getMessageByID = async (mid: IMessage['_id']): Promise<IMessage> => { |
||||
const message = await findMessageByID(mid); |
||||
|
||||
if (!message) { |
||||
throw new Error('Message not found'); |
||||
} |
||||
|
||||
return message; |
||||
}; |
||||
|
||||
const findLastMessage = async (): Promise<IMessage | undefined> => |
||||
messagesCollection.findOne({ rid, tmid: tmid ?? { $exists: false }, _hidden: { $ne: true } }, { sort: { ts: -1 }, reactive: false }); |
||||
|
||||
const getLastMessage = async (): Promise<IMessage> => { |
||||
const message = await findLastMessage(); |
||||
|
||||
if (!message) { |
||||
throw new Error('Message not found'); |
||||
} |
||||
|
||||
return message; |
||||
}; |
||||
|
||||
const findLastOwnMessage = async (): Promise<IMessage | undefined> => { |
||||
const uid = Meteor.userId(); |
||||
|
||||
if (!uid) { |
||||
return undefined; |
||||
} |
||||
|
||||
return messagesCollection.findOne( |
||||
{ rid, 'tmid': tmid ?? { $exists: false }, 'u._id': uid, '_hidden': { $ne: true } }, |
||||
{ sort: { ts: -1 }, reactive: false }, |
||||
); |
||||
}; |
||||
|
||||
const getLastOwnMessage = async (): Promise<IMessage> => { |
||||
const message = await findLastOwnMessage(); |
||||
|
||||
if (!message) { |
||||
throw new Error('Message not found'); |
||||
} |
||||
|
||||
return message; |
||||
}; |
||||
|
||||
const findPreviousOwnMessage = async (message: IMessage): Promise<IMessage | undefined> => { |
||||
const uid = Meteor.userId(); |
||||
|
||||
if (!uid) { |
||||
return undefined; |
||||
} |
||||
|
||||
return messagesCollection.findOne( |
||||
{ rid, 'tmid': tmid ?? { $exists: false }, 'u._id': uid, '_hidden': { $ne: true }, 'ts': { $lt: message.ts } }, |
||||
{ sort: { ts: -1 }, reactive: false }, |
||||
); |
||||
}; |
||||
|
||||
const getPreviousOwnMessage = async (message: IMessage): Promise<IMessage> => { |
||||
const previousMessage = await findPreviousOwnMessage(message); |
||||
|
||||
if (!previousMessage) { |
||||
throw new Error('Message not found'); |
||||
} |
||||
|
||||
return previousMessage; |
||||
}; |
||||
|
||||
const findNextOwnMessage = async (message: IMessage): Promise<IMessage | undefined> => { |
||||
const uid = Meteor.userId(); |
||||
|
||||
if (!uid) { |
||||
return undefined; |
||||
} |
||||
|
||||
return messagesCollection.findOne( |
||||
{ rid, 'tmid': tmid ?? { $exists: false }, 'u._id': uid, '_hidden': { $ne: true }, 'ts': { $gt: message.ts } }, |
||||
{ sort: { ts: 1 }, reactive: false }, |
||||
); |
||||
}; |
||||
|
||||
const getNextOwnMessage = async (message: IMessage): Promise<IMessage> => { |
||||
const nextMessage = await findNextOwnMessage(message); |
||||
|
||||
if (!nextMessage) { |
||||
throw new Error('Message not found'); |
||||
} |
||||
|
||||
return nextMessage; |
||||
}; |
||||
|
||||
const pushEphemeralMessage = async (message: Omit<IMessage, 'rid' | 'tmid'>): Promise<void> => { |
||||
messagesCollection.upsert({ _id: message._id }, { $set: { ...message, rid, ...(tmid && { tmid }) } }); |
||||
}; |
||||
|
||||
const canUpdateMessage = async (message: IMessage): Promise<boolean> => { |
||||
if (MessageTypes.isSystemMessage(message)) { |
||||
return false; |
||||
} |
||||
|
||||
const hasPermission = hasAtLeastOnePermission('edit-message', message.rid); |
||||
const editAllowed = (settings.get('Message_AllowEditing') as boolean | undefined) ?? false; |
||||
const editOwn = message?.u && message.u._id === Meteor.userId(); |
||||
|
||||
if (!hasPermission && (!editAllowed || !editOwn)) { |
||||
return false; |
||||
} |
||||
|
||||
const blockEditInMinutes = settings.get('Message_AllowEditing_BlockEditInMinutes') as number | undefined; |
||||
const elapsedMinutes = moment().diff(message.ts, 'minutes'); |
||||
if (elapsedMinutes && blockEditInMinutes && elapsedMinutes > blockEditInMinutes) { |
||||
return false; |
||||
} |
||||
|
||||
return true; |
||||
}; |
||||
|
||||
const updateMessage = async (message: Pick<IMessage, '_id' | 't'> & Partial<Omit<IMessage, '_id' | 't'>>): Promise<void> => |
||||
call('updateMessage', message); |
||||
|
||||
const canDeleteMessage = async (message: IMessage): Promise<boolean> => { |
||||
if (MessageTypes.isSystemMessage(message)) { |
||||
return false; |
||||
} |
||||
|
||||
const hasPermission = hasAtLeastOnePermission('force-delete-message', message.rid); |
||||
if (!hasPermission) { |
||||
return false; |
||||
} |
||||
|
||||
const blockDeleteInMinutes = settings.get('Message_AllowDeleting_BlockDeleteInMinutes') as number | undefined; |
||||
const elapsedMinutes = moment().diff(message.ts, 'minutes'); |
||||
|
||||
if (elapsedMinutes && blockDeleteInMinutes && elapsedMinutes > blockDeleteInMinutes) { |
||||
return false; |
||||
} |
||||
|
||||
return true; |
||||
}; |
||||
|
||||
const deleteMessage = async (mid: IMessage['_id']): Promise<void> => { |
||||
await call('deleteMessage', { _id: mid }); |
||||
}; |
||||
|
||||
const drafts = new Map<IMessage['_id'] | undefined, string>(); |
||||
|
||||
const getDraft = async (mid: IMessage['_id'] | undefined): Promise<string | undefined> => drafts.get(mid); |
||||
|
||||
const discardDraft = async (mid: IMessage['_id'] | undefined): Promise<void> => { |
||||
drafts.delete(mid); |
||||
}; |
||||
|
||||
const saveDraft = async (mid: IMessage['_id'] | undefined, draft: string): Promise<void> => { |
||||
drafts.set(mid, draft); |
||||
}; |
||||
|
||||
const findRoom = async (): Promise<IRoom | undefined> => roomsCollection.findOne({ _id: rid }, { reactive: false }); |
||||
|
||||
const getRoom = async (): Promise<IRoom> => { |
||||
const room = await findRoom(); |
||||
|
||||
if (!room) { |
||||
throw new Error('Room not found'); |
||||
} |
||||
|
||||
return room; |
||||
}; |
||||
|
||||
const isSubscribedToRoom = async (): Promise<boolean> => !!subscriptionsCollection.findOne({ rid }, { reactive: false }); |
||||
|
||||
const joinRoom = async (): Promise<void> => call('joinRoom', rid); |
||||
|
||||
const markRoomAsRead = async (): Promise<void> => { |
||||
readMessage.readNow(rid); |
||||
readMessage.refreshUnreadMark(rid); |
||||
}; |
||||
|
||||
const findDiscussionByID = async (drid: IRoom['_id']): Promise<IRoom | undefined> => |
||||
roomsCollection.findOne({ _id: drid, prid: { $exists: true } }, { reactive: false }); |
||||
|
||||
const getDiscussionByID = async (drid: IRoom['_id']): Promise<IRoom> => { |
||||
const discussion = await findDiscussionByID(drid); |
||||
|
||||
if (!discussion) { |
||||
throw new Error('Discussion not found'); |
||||
} |
||||
|
||||
return discussion; |
||||
}; |
||||
|
||||
return { |
||||
composeMessage, |
||||
findMessageByID, |
||||
getMessageByID, |
||||
findLastMessage, |
||||
getLastMessage, |
||||
findLastOwnMessage, |
||||
getLastOwnMessage, |
||||
findPreviousOwnMessage, |
||||
getPreviousOwnMessage, |
||||
findNextOwnMessage, |
||||
getNextOwnMessage, |
||||
pushEphemeralMessage, |
||||
canUpdateMessage, |
||||
updateMessage, |
||||
canDeleteMessage, |
||||
deleteMessage, |
||||
getDraft, |
||||
saveDraft, |
||||
discardDraft, |
||||
findRoom, |
||||
getRoom, |
||||
isSubscribedToRoom, |
||||
joinRoom, |
||||
markRoomAsRead, |
||||
findDiscussionByID, |
||||
getDiscussionByID, |
||||
}; |
||||
}; |
||||
@ -0,0 +1,32 @@ |
||||
import { IMessage } from '@rocket.chat/core-typings'; |
||||
|
||||
import { MessageTypes } from '../../../../app/ui-utils/client'; |
||||
import { dispatchToastMessage } from '../../toast'; |
||||
import { ChatAPI } from '../ChatAPI'; |
||||
|
||||
export const processMessageEditing = async ( |
||||
chat: ChatAPI, |
||||
message: Pick<IMessage, '_id' | 't'> & Partial<Omit<IMessage, '_id' | 't'>>, |
||||
): Promise<boolean> => { |
||||
if (!chat.currentEditing) { |
||||
return false; |
||||
} |
||||
|
||||
if (MessageTypes.isSystemMessage(message)) { |
||||
return false; |
||||
} |
||||
|
||||
if (!message.msg && !message.attachments?.length) { |
||||
return false; |
||||
} |
||||
|
||||
try { |
||||
await chat.data.updateMessage({ ...message, _id: chat.currentEditing.mid }); |
||||
} catch (error) { |
||||
dispatchToastMessage({ type: 'error', message: error }); |
||||
} |
||||
|
||||
chat.currentEditing.stop(); |
||||
|
||||
return true; |
||||
}; |
||||
@ -0,0 +1,26 @@ |
||||
import { IMessage } from '@rocket.chat/core-typings'; |
||||
|
||||
import { emoji } from '../../../../app/emoji/client'; |
||||
import { callWithErrorHandling } from '../../utils/callWithErrorHandling'; |
||||
import { ChatAPI } from '../ChatAPI'; |
||||
|
||||
export const processSetReaction = async (chat: ChatAPI, { msg }: Pick<IMessage, 'msg'>): Promise<boolean> => { |
||||
const match = msg.trim().match(/^\+(:.*?:)$/m); |
||||
if (!match) { |
||||
return false; |
||||
} |
||||
|
||||
const [, reaction] = match; |
||||
if (!emoji.list[reaction]) { |
||||
return false; |
||||
} |
||||
|
||||
const lastMessage = await chat.data.findLastMessage(); |
||||
|
||||
if (!lastMessage) { |
||||
return false; |
||||
} |
||||
|
||||
await callWithErrorHandling('setReaction', reaction, lastMessage._id); |
||||
return true; |
||||
}; |
||||
@ -0,0 +1,95 @@ |
||||
import type { IMessage, SlashCommand } from '@rocket.chat/core-typings'; |
||||
import { escapeHTML } from '@rocket.chat/string-helpers'; |
||||
|
||||
import { hasAtLeastOnePermission } from '../../../../app/authorization/client'; |
||||
import { settings } from '../../../../app/settings/client'; |
||||
import { generateTriggerId } from '../../../../app/ui-message/client/ActionManager'; |
||||
import { slashCommands, APIClient, t } from '../../../../app/utils/client'; |
||||
import { getRandomId } from '../../../../lib/random'; |
||||
import { call } from '../../utils/call'; |
||||
import type { ChatAPI } from '../ChatAPI'; |
||||
|
||||
const parse = (msg: string): { command: string; params: string } | { command: SlashCommand; params: string } | undefined => { |
||||
const match = msg.match(/^\/([^\s]+)(.*)/m); |
||||
|
||||
if (!match) { |
||||
return undefined; |
||||
} |
||||
|
||||
const [, cmd, params] = match; |
||||
const command = slashCommands.commands[cmd]; |
||||
|
||||
if (!command) { |
||||
return { command: cmd, params }; |
||||
} |
||||
|
||||
return { command, params }; |
||||
}; |
||||
|
||||
const warnUnrecognizedSlashCommand = async (chat: ChatAPI, command: string): Promise<void> => { |
||||
console.error(t('No_such_command', { command: escapeHTML(command) })); |
||||
|
||||
await chat.data.pushEphemeralMessage({ |
||||
_id: getRandomId(), |
||||
ts: new Date(), |
||||
msg: t('No_such_command', { command: escapeHTML(command) }), |
||||
u: { |
||||
_id: 'rocket.cat', |
||||
username: 'rocket.cat', |
||||
name: 'Rocket.Cat', |
||||
}, |
||||
private: true, |
||||
_updatedAt: new Date(), |
||||
}); |
||||
}; |
||||
|
||||
export const processSlashCommand = async (chat: ChatAPI, message: IMessage): Promise<boolean> => { |
||||
const match = parse(message.msg); |
||||
|
||||
if (!match) { |
||||
return false; |
||||
} |
||||
|
||||
const { command, params } = match; |
||||
|
||||
if (typeof command === 'string') { |
||||
if (!settings.get('Message_AllowUnrecognizedSlashCommand')) { |
||||
await warnUnrecognizedSlashCommand(chat, command); |
||||
return true; |
||||
} |
||||
|
||||
return false; |
||||
} |
||||
|
||||
const { permission, clientOnly, callback: handleOnClient, result: handleResult, appId, command: commandName } = command; |
||||
|
||||
if (permission && !hasAtLeastOnePermission(permission)) { |
||||
return false; |
||||
} |
||||
|
||||
if (clientOnly) { |
||||
handleOnClient?.(commandName, params, message); |
||||
return true; |
||||
} |
||||
|
||||
await APIClient.post('/v1/statistics.telemetry', { |
||||
params: [{ eventName: 'slashCommandsStats', timestamp: Date.now(), command: commandName }], |
||||
}); |
||||
|
||||
const triggerId = generateTriggerId(appId); |
||||
|
||||
const data = { |
||||
cmd: commandName, |
||||
params, |
||||
msg: message, |
||||
} as const; |
||||
|
||||
try { |
||||
const result = await call('slashCommand', { cmd: commandName, params, msg: message, triggerId }); |
||||
handleResult?.(undefined, result, data); |
||||
} catch (error: unknown) { |
||||
handleResult?.(error, undefined, data); |
||||
} |
||||
|
||||
return true; |
||||
}; |
||||
@ -0,0 +1,63 @@ |
||||
import { IMessage } from '@rocket.chat/core-typings'; |
||||
|
||||
import { settings } from '../../../../app/settings/client'; |
||||
import { messageProperties } from '../../../../app/ui-utils/client'; |
||||
import { t } from '../../../../app/utils/client'; |
||||
import GenericModal from '../../../components/GenericModal'; |
||||
import { imperativeModal } from '../../imperativeModal'; |
||||
import { dispatchToastMessage } from '../../toast'; |
||||
import { ChatAPI } from '../ChatAPI'; |
||||
|
||||
export const processTooLongMessage = async (chat: ChatAPI, { msg }: Pick<IMessage, 'msg'>): Promise<boolean> => { |
||||
const adjustedMessage = messageProperties.messageWithoutEmojiShortnames(msg); |
||||
const maxAllowedSize = settings.get('Message_MaxAllowedSize'); |
||||
|
||||
if (messageProperties.length(adjustedMessage) <= maxAllowedSize) { |
||||
return false; |
||||
} |
||||
const fileUploadsEnabled = settings.get('FileUpload_Enabled'); |
||||
const convertLongMessagesToAttachment = settings.get('Message_AllowConvertLongMessagesToAttachment'); |
||||
|
||||
if (chat.currentEditing || !fileUploadsEnabled || !convertLongMessagesToAttachment) { |
||||
dispatchToastMessage({ type: 'error', message: new Error(t('Message_too_long')) }); |
||||
chat.composer?.setText(msg); |
||||
return true; |
||||
} |
||||
|
||||
await new Promise<void>((resolve) => { |
||||
const onConfirm = async (): Promise<void> => { |
||||
const contentType = 'text/plain'; |
||||
const messageBlob = new Blob([msg], { type: contentType }); |
||||
const fileName = `${Meteor.user()?.username ?? 'anonymous'} - ${new Date()}.txt`; // TODO: proper naming and formatting
|
||||
const file = new File([messageBlob], fileName, { |
||||
type: contentType, |
||||
lastModified: Date.now(), |
||||
}); |
||||
await chat.flows.uploadFiles([file]); |
||||
|
||||
imperativeModal.close(); |
||||
resolve(); |
||||
}; |
||||
|
||||
const onClose = (): void => { |
||||
chat.composer?.setText(msg); |
||||
|
||||
imperativeModal.close(); |
||||
resolve(); |
||||
}; |
||||
|
||||
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; |
||||
}; |
||||
@ -0,0 +1,65 @@ |
||||
import { IMessage } from '@rocket.chat/core-typings'; |
||||
|
||||
import { t } from '../../../../app/utils/client'; |
||||
import GenericModal from '../../../components/GenericModal'; |
||||
import { imperativeModal } from '../../imperativeModal'; |
||||
import { dispatchToastMessage } from '../../toast'; |
||||
import { ChatAPI } from '../ChatAPI'; |
||||
|
||||
export const requestMessageDeletion = async (chat: ChatAPI, message: IMessage): Promise<void> => { |
||||
if (!(await chat.data.canDeleteMessage(message))) { |
||||
dispatchToastMessage({ type: 'error', message: t('Message_deleting_blocked') }); |
||||
return; |
||||
} |
||||
|
||||
const room = message.drid ? await chat.data.getDiscussionByID(message.drid) : undefined; |
||||
|
||||
await new Promise<void>((resolve, reject) => { |
||||
const onConfirm = async (): Promise<void> => { |
||||
try { |
||||
if (!(await chat.data.canDeleteMessage(message))) { |
||||
dispatchToastMessage({ type: 'error', message: t('Message_deleting_blocked') }); |
||||
return; |
||||
} |
||||
await chat.data.deleteMessage(message._id); |
||||
|
||||
imperativeModal.close(); |
||||
|
||||
if (chat.currentEditing?.mid === message._id) { |
||||
chat.currentEditing.stop(); |
||||
} |
||||
chat.composer?.focus(); |
||||
|
||||
dispatchToastMessage({ type: 'success', message: t('Your_entry_has_been_deleted') }); |
||||
resolve(); |
||||
} catch (error) { |
||||
dispatchToastMessage({ type: 'error', message: error }); |
||||
reject(error); |
||||
} |
||||
}; |
||||
|
||||
const onCloseModal = async (): Promise<void> => { |
||||
imperativeModal.close(); |
||||
|
||||
if (chat.currentEditing?.mid === message._id) { |
||||
chat.currentEditing.stop(); |
||||
} |
||||
chat.composer?.focus(); |
||||
|
||||
resolve(); |
||||
}; |
||||
|
||||
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, |
||||
}, |
||||
}); |
||||
}); |
||||
}; |
||||
@ -0,0 +1,91 @@ |
||||
import { IMessage } from '@rocket.chat/core-typings'; |
||||
|
||||
import { KonchatNotification } from '../../../../app/ui/client'; |
||||
import { t } from '../../../../app/utils/client'; |
||||
import { dispatchToastMessage } from '../../toast'; |
||||
import { call } from '../../utils/call'; |
||||
import { ChatAPI } from '../ChatAPI'; |
||||
import { processMessageEditing } from './processMessageEditing'; |
||||
import { processSetReaction } from './processSetReaction'; |
||||
import { processSlashCommand } from './processSlashCommand'; |
||||
import { processTooLongMessage } from './processTooLongMessage'; |
||||
|
||||
const process = async (chat: ChatAPI, message: IMessage): Promise<void> => { |
||||
KonchatNotification.removeRoomNotification(message.rid); |
||||
|
||||
if (await processSetReaction(chat, message)) { |
||||
return; |
||||
} |
||||
|
||||
if (await processTooLongMessage(chat, message)) { |
||||
return; |
||||
} |
||||
|
||||
if (await processMessageEditing(chat, message)) { |
||||
return; |
||||
} |
||||
|
||||
if (await processSlashCommand(chat, message)) { |
||||
return; |
||||
} |
||||
|
||||
await call('sendMessage', message); |
||||
}; |
||||
|
||||
export const sendMessage = async (chat: ChatAPI, { text, tshow }: { text: string; tshow?: boolean }): Promise<void> => { |
||||
if (!(await chat.data.isSubscribedToRoom())) { |
||||
try { |
||||
await chat.data.joinRoom(); |
||||
} catch (error) { |
||||
dispatchToastMessage({ type: 'error', message: error }); |
||||
return; |
||||
} |
||||
} |
||||
|
||||
await chat.data.markRoomAsRead(); |
||||
|
||||
text = text.trim(); |
||||
|
||||
if (!text && !chat.currentEditing) { |
||||
// Nothing to do
|
||||
return; |
||||
} |
||||
|
||||
if (text) { |
||||
const message = await chat.data.composeMessage(text, { |
||||
sendToChannel: tshow, |
||||
quotedMessages: chat.composer?.quotedMessages.get() ?? [], |
||||
originalMessage: chat.currentEditing ? await chat.data.findMessageByID(chat.currentEditing.mid) : undefined, |
||||
}); |
||||
|
||||
try { |
||||
await process(chat, message); |
||||
chat.composer?.dismissAllQuotedMessages(); |
||||
} catch (error) { |
||||
dispatchToastMessage({ type: 'error', message: error }); |
||||
} |
||||
return; |
||||
} |
||||
|
||||
if (chat.currentEditing) { |
||||
const originalMessage = await chat.data.findMessageByID(chat.currentEditing.mid); |
||||
|
||||
if (!originalMessage) { |
||||
dispatchToastMessage({ type: 'warning', message: t('Message_not_found') }); |
||||
return; |
||||
} |
||||
|
||||
try { |
||||
if (await chat.flows.processMessageEditing({ ...originalMessage, msg: '' })) { |
||||
chat.currentEditing.stop(); |
||||
return; |
||||
} |
||||
|
||||
await chat.currentEditing?.reset(); |
||||
await chat.flows.requestMessageDeletion(originalMessage); |
||||
return; |
||||
} catch (error) { |
||||
dispatchToastMessage({ type: 'error', message: error }); |
||||
} |
||||
} |
||||
}; |
||||
@ -0,0 +1,55 @@ |
||||
import { isRoomFederated } from '@rocket.chat/core-typings'; |
||||
|
||||
import { fileUploadIsValidContentType } from '../../../../app/utils/client'; |
||||
import FileUploadModal from '../../../views/room/modals/FileUploadModal'; |
||||
import { imperativeModal } from '../../imperativeModal'; |
||||
import { prependReplies } from '../../utils/prependReplies'; |
||||
import { ChatAPI } from '../ChatAPI'; |
||||
|
||||
export const uploadFiles = async (chat: ChatAPI, files: readonly File[]): Promise<void> => { |
||||
const replies = chat.composer?.quotedMessages.get() ?? []; |
||||
|
||||
const msg = await prependReplies('', replies); |
||||
|
||||
const room = await chat.data.getRoom(); |
||||
|
||||
const queue = [...files]; |
||||
|
||||
const uploadNextFile = (): void => { |
||||
const file = queue.pop(); |
||||
if (!file) { |
||||
chat.composer?.dismissAllQuotedMessages(); |
||||
return; |
||||
} |
||||
|
||||
imperativeModal.open({ |
||||
component: FileUploadModal, |
||||
props: { |
||||
file, |
||||
fileName: file.name, |
||||
fileDescription: chat.composer?.text ?? '', |
||||
showDescription: room && !isRoomFederated(room), |
||||
onClose: (): void => { |
||||
imperativeModal.close(); |
||||
uploadNextFile(); |
||||
}, |
||||
onSubmit: (fileName: string, description?: string): void => { |
||||
Object.defineProperty(file, 'name', { |
||||
writable: true, |
||||
value: fileName, |
||||
}); |
||||
chat.uploads.send(file, { |
||||
description, |
||||
msg, |
||||
}); |
||||
chat.composer?.clear(); |
||||
imperativeModal.close(); |
||||
uploadNextFile(); |
||||
}, |
||||
invalidContentType: Boolean(file.type && !fileUploadIsValidContentType(file.type)), |
||||
}, |
||||
}); |
||||
}; |
||||
|
||||
uploadNextFile(); |
||||
}; |
||||
@ -0,0 +1,151 @@ |
||||
import { IMessage, IRoom } from '@rocket.chat/core-typings'; |
||||
import { Emitter } from '@rocket.chat/emitter'; |
||||
|
||||
import { UserAction, USER_ACTIVITIES } from '../../../app/ui/client/lib/UserAction'; |
||||
import { APIClient } from '../../../app/utils/client'; |
||||
import { getRandomId } from '../../../lib/random'; |
||||
import { getErrorMessage } from '../errorHandling'; |
||||
import { UploadsAPI } from './ChatAPI'; |
||||
import type { Upload } from './Upload'; |
||||
|
||||
let uploads: readonly Upload[] = []; |
||||
|
||||
const emitter = new Emitter<{ update: void; [x: `cancelling-${Upload['id']}`]: void }>(); |
||||
|
||||
const updateUploads = (update: (uploads: readonly Upload[]) => readonly Upload[]): void => { |
||||
uploads = update(uploads); |
||||
emitter.emit('update'); |
||||
}; |
||||
|
||||
const get = (): readonly Upload[] => uploads; |
||||
|
||||
const subscribe = (callback: () => void): (() => void) => emitter.on('update', callback); |
||||
|
||||
const cancel = (id: Upload['id']): void => { |
||||
emitter.emit(`cancelling-${id}`); |
||||
}; |
||||
|
||||
const wipeFailedOnes = (): void => { |
||||
updateUploads((uploads) => uploads.filter((upload) => !upload.error)); |
||||
}; |
||||
|
||||
const send = async ( |
||||
file: File, |
||||
{ |
||||
description, |
||||
msg, |
||||
rid, |
||||
tmid, |
||||
}: { |
||||
description?: string; |
||||
msg?: string; |
||||
rid: string; |
||||
tmid?: string; |
||||
}, |
||||
): Promise<void> => { |
||||
const id = getRandomId(); |
||||
|
||||
updateUploads((uploads) => [ |
||||
...uploads, |
||||
{ |
||||
id, |
||||
name: file.name, |
||||
percentage: 0, |
||||
}, |
||||
]); |
||||
|
||||
try { |
||||
await new Promise((resolve, reject) => { |
||||
const xhr = APIClient.upload( |
||||
`/v1/rooms.upload/${rid}`, |
||||
{ |
||||
msg, |
||||
tmid, |
||||
file, |
||||
description, |
||||
}, |
||||
{ |
||||
load: (event) => { |
||||
resolve(event); |
||||
}, |
||||
progress: (event) => { |
||||
if (!event.lengthComputable) { |
||||
return; |
||||
} |
||||
const progress = (event.loaded / event.total) * 100; |
||||
if (progress === 100) { |
||||
return; |
||||
} |
||||
|
||||
updateUploads((uploads) => |
||||
uploads.map((upload) => { |
||||
if (upload.id !== id) { |
||||
return upload; |
||||
} |
||||
|
||||
return { |
||||
...upload, |
||||
percentage: Math.round(progress) || 0, |
||||
}; |
||||
}), |
||||
); |
||||
}, |
||||
error: (event) => { |
||||
updateUploads((uploads) => |
||||
uploads.map((upload) => { |
||||
if (upload.id !== id) { |
||||
return upload; |
||||
} |
||||
|
||||
return { |
||||
...upload, |
||||
percentage: 0, |
||||
error: new Error(xhr.responseText), |
||||
}; |
||||
}), |
||||
); |
||||
reject(event); |
||||
}, |
||||
}, |
||||
); |
||||
|
||||
if (uploads.length) { |
||||
UserAction.performContinuously(rid, USER_ACTIVITIES.USER_UPLOADING, { tmid }); |
||||
} |
||||
|
||||
emitter.once(`cancelling-${id}`, () => { |
||||
xhr.abort(); |
||||
updateUploads((uploads) => uploads.filter((upload) => upload.id !== id)); |
||||
}); |
||||
}); |
||||
|
||||
updateUploads((uploads) => uploads.filter((upload) => upload.id !== id)); |
||||
} catch (error: unknown) { |
||||
updateUploads((uploads) => |
||||
uploads.map((upload) => { |
||||
if (upload.id !== id) { |
||||
return upload; |
||||
} |
||||
|
||||
return { |
||||
...upload, |
||||
percentage: 0, |
||||
error: new Error(getErrorMessage(error)), |
||||
}; |
||||
}), |
||||
); |
||||
} finally { |
||||
if (!uploads.length) { |
||||
UserAction.stop(rid, USER_ACTIVITIES.USER_UPLOADING, { tmid }); |
||||
} |
||||
} |
||||
}; |
||||
|
||||
export const createUploadsAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid?: IMessage['_id'] }): UploadsAPI => ({ |
||||
get, |
||||
subscribe, |
||||
wipeFailedOnes, |
||||
cancel, |
||||
send: (file: File, { description, msg }: { description?: string; msg?: string }): Promise<void> => |
||||
send(file, { description, msg, rid, tmid }), |
||||
}); |
||||
@ -1,3 +1,3 @@ |
||||
import './customScriptOnLogout'; |
||||
import './messageBoxState'; |
||||
import './purgeAllDrafts'; |
||||
import './roomManager'; |
||||
|
||||
@ -1,8 +0,0 @@ |
||||
import { Meteor } from 'meteor/meteor'; |
||||
|
||||
import { ChatMessages } from '../../../app/ui/client'; |
||||
import { callbacks } from '../../../lib/callbacks'; |
||||
|
||||
Meteor.startup(() => { |
||||
callbacks.add('afterLogoutCleanUp', ChatMessages.purgeAllDrafts, callbacks.priority.MEDIUM, 'chatMessages-after-logout-cleanup'); |
||||
}); |
||||
@ -0,0 +1,13 @@ |
||||
import { Meteor } from 'meteor/meteor'; |
||||
|
||||
import { callbacks } from '../../../lib/callbacks'; |
||||
|
||||
Meteor.startup(() => { |
||||
const purgeAllDrafts = (): void => { |
||||
Object.keys(Meteor._localStorage) |
||||
.filter((key) => key.indexOf('messagebox_') === 0) |
||||
.forEach((key) => Meteor._localStorage.removeItem(key)); |
||||
}; |
||||
|
||||
callbacks.add('afterLogoutCleanUp', purgeAllDrafts, callbacks.priority.MEDIUM, 'chatMessages-after-logout-cleanup'); |
||||
}); |
||||
@ -1,27 +0,0 @@ |
||||
import { IMessage, ISubscription } from '@rocket.chat/core-typings'; |
||||
import { FlowRouter } from 'meteor/kadira:flow-router'; |
||||
|
||||
import { Messages } from '../../../app/models/client'; |
||||
import { ChatMessages } from '../../../app/ui/client'; |
||||
import { callbacks } from '../../../lib/callbacks'; |
||||
import { callWithErrorHandling } from '../../lib/utils/callWithErrorHandling'; |
||||
|
||||
callbacks.add('enter-room', async (sub?: ISubscription) => { |
||||
if (!sub) { |
||||
return; |
||||
} |
||||
|
||||
const mid = FlowRouter.getQueryParam('reply'); |
||||
if (!mid) { |
||||
return; |
||||
} |
||||
|
||||
const getSingleMessage = (mid: IMessage['_id']): Promise<IMessage> => callWithErrorHandling('getSingleMessage', mid); |
||||
|
||||
const message = (Messages as Mongo.Collection<IMessage>).findOne(mid) ?? (await getSingleMessage(mid)); |
||||
if (!message) { |
||||
return; |
||||
} |
||||
|
||||
ChatMessages.get({ rid: sub.rid })?.quotedMessages.add(message); |
||||
}); |
||||
@ -1,17 +0,0 @@ |
||||
import { Session } from 'meteor/session'; |
||||
|
||||
import type { Uploading } from '../../../app/ui/client/lib/fileUpload'; |
||||
import { callbacks } from '../../../lib/callbacks'; |
||||
|
||||
function wipeFailedUploads(): void { |
||||
const uploads: Uploading[] = Session.get('uploading'); |
||||
|
||||
if (uploads) { |
||||
Session.set( |
||||
'uploading', |
||||
uploads.filter((upload) => !upload.error), |
||||
); |
||||
} |
||||
} |
||||
|
||||
callbacks.add('enter-room', wipeFailedUploads); |
||||
@ -1,27 +0,0 @@ |
||||
import { IRoom } from '@rocket.chat/core-typings'; |
||||
import { RefObject, useEffect, useMemo } from 'react'; |
||||
|
||||
import { ChatMessages } from '../../../../../app/ui/client'; |
||||
|
||||
export const useChatMessages = (rid: IRoom['_id'], wrapperRef: RefObject<HTMLElement | null>): ChatMessages => { |
||||
const chatMessagesInstance = useMemo(() => { |
||||
const instance = ChatMessages.get({ rid }) ?? new ChatMessages({ rid }); |
||||
ChatMessages.set({ rid }, instance); |
||||
return instance; |
||||
}, [rid]); |
||||
|
||||
useEffect(() => { |
||||
const wrapper = wrapperRef.current; |
||||
|
||||
if (!wrapper) { |
||||
return; |
||||
} |
||||
|
||||
chatMessagesInstance.initializeWrapper(wrapper); |
||||
return (): void => { |
||||
chatMessagesInstance.onDestroyed?.(rid); |
||||
}; |
||||
}, [chatMessagesInstance, rid, wrapperRef]); |
||||
|
||||
return chatMessagesInstance; |
||||
}; |
||||
@ -0,0 +1,9 @@ |
||||
import { createContext, useContext } from 'react'; |
||||
|
||||
import { ChatAPI } from '../../../lib/chats/ChatAPI'; |
||||
|
||||
type ChatContextValue = ChatAPI | undefined; |
||||
|
||||
export const ChatContext = createContext<ChatContextValue>(undefined); |
||||
|
||||
export const useChat = (): ChatContextValue => useContext(ChatContext); |
||||
@ -0,0 +1,29 @@ |
||||
import React, { ReactElement, ReactNode, useEffect, useMemo } from 'react'; |
||||
|
||||
import { ChatMessages } from '../../../../app/ui/client/lib/ChatMessages'; |
||||
import { ChatContext } from '../contexts/ChatContext'; |
||||
import { useRoom } from '../contexts/RoomContext'; |
||||
|
||||
type ChatProviderProps = { |
||||
children: ReactNode; |
||||
tmid?: string; |
||||
}; |
||||
|
||||
const ChatProvider = ({ children, tmid }: ChatProviderProps): ReactElement => { |
||||
const { _id: rid } = useRoom(); |
||||
|
||||
const chatMessages = useMemo(() => ChatMessages.hold({ rid, tmid }), [rid, tmid]); |
||||
|
||||
useEffect( |
||||
() => (): void => { |
||||
ChatMessages.release({ rid, tmid }); |
||||
}, |
||||
[rid, tmid], |
||||
); |
||||
|
||||
const value = useMemo(() => chatMessages, [chatMessages]); |
||||
|
||||
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>; |
||||
}; |
||||
|
||||
export default ChatProvider; |
||||
Loading…
Reference in new issue