diff --git a/.changeset/mighty-moose-return.md b/.changeset/mighty-moose-return.md new file mode 100644 index 0000000000000..ca0c8bbd3ccbf --- /dev/null +++ b/.changeset/mighty-moose-return.md @@ -0,0 +1,9 @@ +--- +'@rocket.chat/model-typings': minor +'@rocket.chat/core-typings': minor +'@rocket.chat/models': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Introduces the ability to upload multiple files and send them in a single message diff --git a/apps/meteor/app/api/server/v1/rooms.ts b/apps/meteor/app/api/server/v1/rooms.ts index 48bc71ca5e491..6369845c5ffa1 100644 --- a/apps/meteor/app/api/server/v1/rooms.ts +++ b/apps/meteor/app/api/server/v1/rooms.ts @@ -274,7 +274,7 @@ API.v1.addRoute( sendFileMessage(this.userId, { roomId: this.urlParams.rid, file, msgData: this.bodyParams }, { parseAttachmentsForE2EE: false }), ); - await Uploads.confirmTemporaryFile(this.urlParams.fileId, this.userId); + await Uploads.confirmTemporaryFile(file._id, this.userId); const message = await Messages.getMessageByFileIdAndUsername(file._id, this.userId); diff --git a/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts b/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts index ba372a45707cf..753a47b56f9df 100644 --- a/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts +++ b/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts @@ -9,6 +9,7 @@ import type { FileProp, } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Logger } from '@rocket.chat/logger'; import { Rooms, Uploads, Users } from '@rocket.chat/models'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -16,124 +17,162 @@ import { Meteor } from 'meteor/meteor'; import { getFileExtension } from '../../../../lib/utils/getFileExtension'; import { omit } from '../../../../lib/utils/omit'; import { callbacks } from '../../../../server/lib/callbacks'; -import { SystemLogger } from '../../../../server/lib/logger/system'; import { canAccessRoomAsync } from '../../../authorization/server/functions/canAccessRoom'; import { executeSendMessage } from '../../../lib/server/methods/sendMessage'; import { FileUpload } from '../lib/FileUpload'; -function validateFileRequiredFields(file: Partial): asserts file is AtLeast { +type MinimalUploadData = AtLeast; + +function validateFilesRequiredFields(files: Partial[]): asserts files is MinimalUploadData[] { const requiredFields = ['_id', 'name', 'type', 'size']; - requiredFields.forEach((field) => { - if (!Object.keys(file).includes(field)) { - throw new Meteor.Error('error-invalid-file', 'Invalid file'); + for (const file of files) { + const fields = Object.keys(file); + + for (const field of requiredFields) { + if (!fields.includes(field)) { + throw new Meteor.Error('error-invalid-file', 'Invalid file'); + } } - }); + } } -export const parseFileIntoMessageAttachments = async ( - file: Partial, +const logger = new Logger('sendFileMessage'); + +export const parseMultipleFilesIntoMessageAttachments = async ( + filesToConfirm: Partial[], roomId: string, user: IUser, ): Promise => { - validateFileRequiredFields(file); - - await Uploads.updateFileComplete(file._id, user._id, omit(file, '_id')); - - const fileUrl = FileUpload.getPath(`${file._id}/${encodeURI(file.name || '')}`); + // Validate every file before we process any of them + validateFilesRequiredFields(filesToConfirm); const attachments: MessageAttachment[] = []; + const files: FileProp[] = []; + + // Process one file at a time, to avoid loading too many images into memory at the same time, or sending too many simultaneous requests to external services + for await (const file of filesToConfirm) { + await Uploads.updateFileComplete(file._id, user._id, omit(file, '_id')); + const fileUrl = FileUpload.getPath(`${file._id}/${encodeURI(file.name || '')}`); - const files: FileProp[] = [ - { + files.push({ _id: file._id, name: file.name || '', type: file.type || 'file', size: file.size || 0, format: file.identify?.format || '', typeGroup: file.typeGroup, - }, - ]; - - if (/^image\/.+/.test(file.type as string)) { - const attachment: FileAttachmentProps = { - title: file.name, - type: 'file', - description: file?.description, - title_link: fileUrl, - title_link_download: true, - image_url: fileUrl, - image_type: file.type as string, - image_size: file.size, - fileId: file._id, - }; + }); - if (file.identify?.size) { - attachment.image_dimensions = file.identify.size; + const { attachment, thumbnail } = await createFileAttachment(file, { fileUrl, roomId, uid: user._id }); + if (thumbnail) { + files.push(thumbnail); } + attachments.push(attachment); + } + + return { files, attachments }; +}; + +async function createFileAttachment( + file: MinimalUploadData, + extraData: { fileUrl: string; roomId: string; uid: string }, +): Promise<{ attachment: FileAttachmentProps; thumbnail?: FileProp }> { + const { fileUrl, roomId, uid } = extraData; - try { - attachment.image_preview = await FileUpload.resizeImagePreview(file); - const thumbResult = await FileUpload.createImageThumbnail(file); - if (thumbResult) { - const { data: thumbBuffer, width, height, thumbFileType, thumbFileName, originalFileId } = thumbResult; - const thumbnail = await FileUpload.uploadImageThumbnail( - { - thumbFileName, - thumbFileType, - originalFileId, - }, - thumbBuffer, - roomId, - user._id, - ); - const thumbUrl = FileUpload.getPath(`${thumbnail._id}/${encodeURI(file.name || '')}`); - attachment.image_url = thumbUrl; - attachment.image_type = thumbnail.type; - attachment.image_dimensions = { - width, - height, - }; - files.push({ - _id: thumbnail._id, - name: thumbnail.name || '', - type: thumbnail.type || 'file', - size: thumbnail.size || 0, - format: thumbnail.identify?.format || '', - typeGroup: thumbnail.typeGroup || '', - }); + if (file.type) { + if (/^image\/.+/.test(file.type)) { + const attachment: FileAttachmentProps = { + title: file.name, + type: 'file', + description: file?.description, + title_link: fileUrl, + title_link_download: true, + image_url: fileUrl, + image_type: file.type, + image_size: file.size, + fileId: file._id, + }; + + if (file.identify?.size) { + attachment.image_dimensions = file.identify.size; + } + + try { + attachment.image_preview = await FileUpload.resizeImagePreview(file); + const thumbResult = await FileUpload.createImageThumbnail(file); + if (thumbResult) { + const { data: thumbBuffer, width, height, thumbFileType, thumbFileName, originalFileId } = thumbResult; + const thumbnail = await FileUpload.uploadImageThumbnail( + { + thumbFileName, + thumbFileType, + originalFileId, + }, + thumbBuffer, + roomId, + uid, + ); + const thumbUrl = FileUpload.getPath(`${thumbnail._id}/${encodeURI(file.name || '')}`); + attachment.image_url = thumbUrl; + attachment.image_type = thumbnail.type; + attachment.image_dimensions = { + width, + height, + }; + return { + attachment, + thumbnail: { + _id: thumbnail._id, + name: thumbnail.name || '', + type: thumbnail.type || 'file', + size: thumbnail.size || 0, + format: thumbnail.identify?.format || '', + typeGroup: thumbnail.typeGroup || '', + }, + }; + } + } catch (err) { + logger.error({ err }); } - } catch (err) { - SystemLogger.error({ err }); + + return { attachment }; } - attachments.push(attachment); - } else if (/^audio\/.+/.test(file.type as string)) { - const attachment: FileAttachmentProps = { - title: file.name, - type: 'file', - description: file.description, - title_link: fileUrl, - title_link_download: true, - audio_url: fileUrl, - audio_type: file.type as string, - audio_size: file.size, - fileId: file._id, - }; - attachments.push(attachment); - } else if (/^video\/.+/.test(file.type as string)) { - const attachment: FileAttachmentProps = { - title: file.name, - type: 'file', - description: file.description, - title_link: fileUrl, - title_link_download: true, - video_url: fileUrl, - video_type: file.type as string, - video_size: file.size as number, - fileId: file._id, - }; - attachments.push(attachment); - } else { - const attachment = { + + if (/^audio\/.+/.test(file.type)) { + return { + attachment: { + title: file.name, + type: 'file', + description: file.description, + title_link: fileUrl, + title_link_download: true, + audio_url: fileUrl, + audio_type: file.type, + audio_size: file.size, + fileId: file._id, + }, + }; + } + + if (/^video\/.+/.test(file.type)) { + return { + attachment: { + title: file.name, + type: 'file', + description: file.description, + title_link: fileUrl, + title_link_download: true, + video_url: fileUrl, + video_type: file.type, + video_size: file.size as number, + fileId: file._id, + }, + }; + } + } + + return { + attachment: { title: file.name, type: 'file', format: getFileExtension(file.name), @@ -142,10 +181,16 @@ export const parseFileIntoMessageAttachments = async ( title_link_download: true, size: file.size as number, fileId: file._id, - }; - attachments.push(attachment); - } - return { files, attachments }; + }, + }; +} + +export const parseFileIntoMessageAttachments = async ( + file: Partial, + roomId: string, + user: IUser, +): Promise => { + return parseMultipleFilesIntoMessageAttachments([file], roomId, user); }; declare module '@rocket.chat/ddp-client' { @@ -179,7 +224,7 @@ export const sendFileMessage = async ( if (!user) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'sendFileMessage', - } as any); + }); } const room = await Rooms.findOneById(roomId); @@ -240,7 +285,7 @@ Meteor.methods({ if (!userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'sendFileMessage', - } as any); + }); } return sendFileMessage(userId, { roomId, file, msgData }); diff --git a/apps/meteor/app/lib/server/functions/sendMessage.ts b/apps/meteor/app/lib/server/functions/sendMessage.ts index c41b52d959ad4..68bcae9638e92 100644 --- a/apps/meteor/app/lib/server/functions/sendMessage.ts +++ b/apps/meteor/app/lib/server/functions/sendMessage.ts @@ -1,7 +1,8 @@ import { AppEvents, Apps } from '@rocket.chat/apps'; import { Message } from '@rocket.chat/core-services'; -import type { IMessage, IRoom } from '@rocket.chat/core-typings'; -import { Messages } from '@rocket.chat/models'; +import { isE2EEMessage } from '@rocket.chat/core-typings'; +import type { IMessage, IRoom, IUpload, IUploadToConfirm } from '@rocket.chat/core-typings'; +import { Messages, Uploads } from '@rocket.chat/models'; import { Match, check } from 'meteor/check'; import { parseUrlsInMessage } from './parseUrlsInMessage'; @@ -9,6 +10,7 @@ import { isRelativeURL } from '../../../../lib/utils/isRelativeURL'; import { isURL } from '../../../../lib/utils/isURL'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { FileUpload } from '../../../file-upload/server'; +import { parseMultipleFilesIntoMessageAttachments } from '../../../file-upload/server/methods/sendFileMessage'; import { settings } from '../../../settings/server'; import { afterSaveMessage } from '../lib/afterSaveMessage'; import { notifyOnRoomChangedById } from '../lib/notifyListener'; @@ -213,15 +215,62 @@ export function prepareMessageObject( } /** + * Update file names on the Uploads collection, as the names may have changed between the upload and the sending of the message + * For encrypted rooms, the full `content` of the file is updated as well, as the name is included there + **/ +const updateFileNames = async (filesToConfirm: IUploadToConfirm[], isE2E: boolean) => { + return Promise.all( + filesToConfirm.map(async (upload) => { + if (isE2E) { + // on encrypted files, the `upload.name` is an useless attribute, so it doesn't need to be updated + // the name will be loaded from the encrypted data on `upload.content` instead + if (upload.content) { + await Uploads.updateFileContentById(upload._id, upload.content); + } + } else if (upload.name) { + await Uploads.updateFileNameById(upload._id, upload.name); + } + }), + ); +}; + +/** + * Validates and sends the message object. * Validates and sends the message object. This function does not verify the Message_MaxAllowedSize settings. * Caller of the function should verify the Message_MaxAllowedSize if needed. * There might be same use cases which needs to override this setting. Example - sending error logs. */ -export const sendMessage = async function (user: any, message: any, room: any, upsert = false, previewUrls?: string[]) { +export const sendMessage = async ( + user: any, + message: any, + room: any, + upsert = false, + previewUrls?: string[], + filesToConfirm?: IUploadToConfirm[], +) => { if (!user || !message || !room._id) { return false; } + const isE2E = isE2EEMessage(message); + + if (filesToConfirm) { + await updateFileNames(filesToConfirm, isE2E); + } + + const uploadIdsToConfirm = filesToConfirm?.map(({ _id }) => _id); + + if (uploadIdsToConfirm?.length && !isE2E) { + const uploadsToConfirm: Partial[] = await Uploads.findByIds(uploadIdsToConfirm).toArray(); + const { files, attachments } = await parseMultipleFilesIntoMessageAttachments(uploadsToConfirm, message.rid, user); + message.files = files; + message.attachments = attachments; + // For compatibility with older integrations, we save the first file to the `file` attribute of the message + if (files.length) { + message.file = files[0]; + } + } + await validateMessage(message, room, user); prepareMessageObject(message, room._id, user); @@ -287,6 +336,10 @@ export const sendMessage = async function (user: any, message: any, room: any, u await afterSaveMessage(message, room, user); + if (uploadIdsToConfirm?.length) { + await Uploads.confirmTemporaryFiles(uploadIdsToConfirm, user._id); + } + void notifyOnRoomChangedById(message.rid); return message; diff --git a/apps/meteor/app/lib/server/methods/sendMessage.ts b/apps/meteor/app/lib/server/methods/sendMessage.ts index db7a017ee7a01..8c81d31cdcb63 100644 --- a/apps/meteor/app/lib/server/methods/sendMessage.ts +++ b/apps/meteor/app/lib/server/methods/sendMessage.ts @@ -1,5 +1,5 @@ import { api } from '@rocket.chat/core-services'; -import type { AtLeast, IMessage, IUser } from '@rocket.chat/core-typings'; +import type { AtLeast, IMessage, IUser, IUploadToConfirm } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import type { RocketchatI18nKeys } from '@rocket.chat/i18n'; import { MessageTypes } from '@rocket.chat/message-types'; @@ -9,6 +9,7 @@ import { check, Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import moment from 'moment'; +import { MAX_MULTIPLE_UPLOADED_FILES } from '../../../../lib/constants'; import { i18n } from '../../../../server/lib/i18n'; import { SystemLogger } from '../../../../server/lib/logger/system'; import { canSendMessageAsync } from '../../../authorization/server/functions/canSendMessage'; @@ -32,7 +33,7 @@ import { RateLimiter } from '../lib'; export async function executeSendMessage( uid: IUser['_id'], message: AtLeast, - extraInfo?: { ts?: Date; previewUrls?: string[] }, + extraInfo?: { ts?: Date; previewUrls?: string[]; filesToConfirm?: IUploadToConfirm[] }, ) { if (message.tshow && !message.tmid) { throw new Meteor.Error('invalid-params', 'tshow provided but missing tmid', { @@ -106,7 +107,7 @@ export async function executeSendMessage( } metrics.messagesSent.inc(); // TODO This line needs to be moved to it's proper place. See the comments on: https://github.com/RocketChat/Rocket.Chat/pull/5736 - return await sendMessage(user, message, room, false, extraInfo?.previewUrls); + return await sendMessage(user, message, room, false, extraInfo?.previewUrls, extraInfo?.filesToConfirm); } catch (err: any) { SystemLogger.error({ msg: 'Error sending message:', err }); @@ -127,12 +128,12 @@ export async function executeSendMessage( declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { - sendMessage(message: AtLeast, previewUrls?: string[]): any; + sendMessage(message: AtLeast, previewUrls?: string[], filesToConfirm?: IUploadToConfirm[]): any; } } Meteor.methods({ - async sendMessage(message, previewUrls) { + async sendMessage(message, previewUrls, filesToConfirm) { check(message, { _id: Match.Maybe(String), rid: Match.Maybe(String), @@ -151,6 +152,36 @@ Meteor.methods({ sentByEmail: Match.Maybe(Boolean), }); + check( + filesToConfirm, + Match.Maybe([ + Match.ObjectIncluding({ + _id: String, + name: Match.Maybe(String), + content: Match.Maybe( + Match.OneOf( + { + algorithm: 'rc.v1.aes-sha2', + ciphertext: String, + }, + { + algorithm: 'rc.v2.aes-sha2', + ciphertext: String, + kid: String, + iv: String, + }, + ), + ), + }), + ]), + ); + + if (filesToConfirm && filesToConfirm.length > MAX_MULTIPLE_UPLOADED_FILES) { + throw new Meteor.Error('error-too-many-files', `Cannot send more than ${MAX_MULTIPLE_UPLOADED_FILES} files in one message`, { + method: 'sendMessage', + }); + } + const uid = Meteor.userId(); if (!uid) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { @@ -163,7 +194,7 @@ Meteor.methods({ } try { - return await applyAirGappedRestrictionsValidation(() => executeSendMessage(uid, message, { previewUrls })); + return await applyAirGappedRestrictionsValidation(() => executeSendMessage(uid, message, { previewUrls, filesToConfirm })); } catch (error: any) { if (['error-not-allowed', 'restricted-workspace'].includes(error.error || error.message)) { throw new Meteor.Error(error.error || error.message, error.reason, { diff --git a/apps/meteor/app/ui/client/lib/ChatMessages.ts b/apps/meteor/app/ui/client/lib/ChatMessages.ts index 44cbf6bdbea43..7617e15b8b7db 100644 --- a/apps/meteor/app/ui/client/lib/ChatMessages.ts +++ b/apps/meteor/app/ui/client/lib/ChatMessages.ts @@ -7,6 +7,7 @@ import { UserAction } from './UserAction'; import type { ChatAPI, ComposerAPI, DataAPI, UploadsAPI } from '../../../../client/lib/chats/ChatAPI'; import { createDataAPI } from '../../../../client/lib/chats/data'; import { processMessageEditing } from '../../../../client/lib/chats/flows/processMessageEditing'; +import { processMessageUploads } from '../../../../client/lib/chats/flows/processMessageUploads'; import { processSetReaction } from '../../../../client/lib/chats/flows/processSetReaction'; import { processSlashCommand } from '../../../../client/lib/chats/flows/processSlashCommand'; import { processTooLongMessage } from '../../../../client/lib/chats/flows/processTooLongMessage'; @@ -44,6 +45,8 @@ export class ChatMessages implements ChatAPI { public uploads: UploadsAPI; + public threadUploads: UploadsAPI; + public ActionManager: any; public emojiPicker: { @@ -147,7 +150,8 @@ export class ChatMessages implements ChatAPI { this.tmid = tmid; this.uid = params.uid; this.data = createDataAPI({ rid, tmid }); - this.uploads = createUploadsAPI({ rid, tmid }); + this.uploads = createUploadsAPI({ rid }); + this.threadUploads = createUploadsAPI({ rid }); this.ActionManager = params.actionManager; this.currentEditingMessage = new CurrentEditingMessage(this); @@ -180,6 +184,7 @@ export class ChatMessages implements ChatAPI { processSlashCommand: processSlashCommand.bind(null, this), processTooLongMessage: processTooLongMessage.bind(null, this), processMessageEditing: processMessageEditing.bind(null, this), + processMessageUploads: processMessageUploads.bind(null, this), processSetReaction: processSetReaction.bind(null, this), requestMessageDeletion: requestMessageDeletion.bind(this, this), replyBroadcast: replyBroadcast.bind(null, this), diff --git a/apps/meteor/client/lib/chats/ChatAPI.ts b/apps/meteor/client/lib/chats/ChatAPI.ts index 96f6c6f03a5f4..0a3139ebbc58f 100644 --- a/apps/meteor/client/lib/chats/ChatAPI.ts +++ b/apps/meteor/client/lib/chats/ChatAPI.ts @@ -2,7 +2,7 @@ import type { IMessage, IRoom, ISubscription, IE2EEMessage, IUpload } from '@roc import type { IActionManager } from '@rocket.chat/ui-contexts'; import type { RefObject } from 'react'; -import type { Upload } from './Upload'; +import type { Upload, EncryptedFile } from './Upload'; import type { ReadStateManager } from './readStateManager'; import type { FormattingButton } from '../../../app/ui-message/client/messageBox/messageBoxFormatting'; @@ -100,17 +100,22 @@ export type DataAPI = { getSubscriptionFromMessage(message: IMessage): Promise; }; +export type EncryptedFileUploadContent = { + rawFile: File; + fileContent: { raw: Partial; encrypted?: IE2EEMessage['content'] | undefined }; + encryptedFile: EncryptedFile; +}; + export type UploadsAPI = { get(): readonly Upload[]; subscribe(callback: () => void): () => void; wipeFailedOnes(): void; + clear(): void; cancel(id: Upload['id']): void; - send( - file: File, - { description, msg, t, e2e }: { description?: string; msg?: string; t?: IMessage['t']; e2e?: IMessage['e2e'] }, - getContent?: (fileId: string, fileUrl: string) => Promise, - fileContent?: { raw: Partial; encrypted: IE2EEMessage['content'] }, - ): Promise; + removeUpload(id: Upload['id']): void; + editUploadFileName: (id: Upload['id'], fileName: string) => void; + send(file: File, encrypted?: never): Promise; + send(file: File, encrypted: EncryptedFileUploadContent): Promise; }; export type ChatAPI = { @@ -119,6 +124,7 @@ export type ChatAPI = { readonly setComposerAPI: (composer?: ComposerAPI) => void; readonly data: DataAPI; readonly uploads: UploadsAPI; + readonly threadUploads: UploadsAPI; readonly readStateManager: ReadStateManager; readonly messageEditing: { toPreviousMessage(): Promise; @@ -148,7 +154,15 @@ export type ChatAPI = { ActionManager: IActionManager; readonly flows: { - readonly uploadFiles: (files: readonly File[], resetFileInput?: () => void) => Promise; + readonly uploadFiles: ({ + files, + uploadsStore, + resetFileInput, + }: { + files: readonly File[]; + uploadsStore: UploadsAPI; + resetFileInput?: () => void; + }) => Promise; readonly sendMessage: ({ text, tshow, @@ -157,6 +171,7 @@ export type ChatAPI = { tshow?: boolean; previewUrls?: string[]; isSlashCommandAllowed?: boolean; + tmid?: IMessage['tmid']; }) => Promise; readonly processSlashCommand: (message: IMessage, userId: string | null) => Promise; readonly processTooLongMessage: (message: IMessage) => Promise; @@ -164,6 +179,7 @@ export type ChatAPI = { message: Pick & Partial>, previewUrls?: string[], ) => Promise; + readonly processMessageUploads: (message: IMessage) => Promise; readonly processSetReaction: (message: Pick) => Promise; readonly requestMessageDeletion: (message: IMessage) => Promise; readonly replyBroadcast: (message: IMessage) => Promise; diff --git a/apps/meteor/client/lib/chats/Upload.ts b/apps/meteor/client/lib/chats/Upload.ts index a2d6bf18cd3ce..daa07cc03c66e 100644 --- a/apps/meteor/client/lib/chats/Upload.ts +++ b/apps/meteor/client/lib/chats/Upload.ts @@ -1,6 +1,26 @@ -export type Upload = { +import type { IUpload } from '@rocket.chat/core-typings'; + +export type NonEncryptedUpload = { readonly id: string; - readonly name: string; + readonly file: File; + readonly url?: string; readonly percentage: number; readonly error?: Error; }; + +export type EncryptedUpload = NonEncryptedUpload & { + readonly encryptedFile: EncryptedFile; + readonly metadataForEncryption: Partial; +}; + +export type Upload = EncryptedUpload | NonEncryptedUpload; + +export type EncryptedFile = { + file: File; + key: JsonWebKey; + iv: string; + type: File['type']; + hash: string; +}; + +export const isEncryptedUpload = (upload: Upload): upload is EncryptedUpload => 'encryptedFile' in upload; diff --git a/apps/meteor/client/lib/chats/flows/processMessageUploads.ts b/apps/meteor/client/lib/chats/flows/processMessageUploads.ts new file mode 100644 index 0000000000000..4700f5ffc4135 --- /dev/null +++ b/apps/meteor/client/lib/chats/flows/processMessageUploads.ts @@ -0,0 +1,215 @@ +import type { AtLeast, FileAttachmentProps, IMessage, IUploadToConfirm } from '@rocket.chat/core-typings'; +import { isOmnichannelRoom } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import { imperativeModal, GenericModal } from '@rocket.chat/ui-client'; + +import { sdk } from '../../../../app/utils/client/lib/SDKClient'; +import { t } from '../../../../app/utils/lib/i18n'; +import { getFileExtension } from '../../../../lib/utils/getFileExtension'; +import { e2e } from '../../e2ee/rocketchat.e2e'; +import type { E2ERoom } from '../../e2ee/rocketchat.e2e.room'; +import { settings } from '../../settings'; +import { dispatchToastMessage } from '../../toast'; +import type { ChatAPI } from '../ChatAPI'; +import { isEncryptedUpload, type EncryptedUpload } from '../Upload'; + +const getHeightAndWidthFromDataUrl = (dataURL: string): Promise<{ height: number; width: number }> => { + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => { + resolve({ + height: img.height, + width: img.width, + }); + }; + img.src = dataURL; + }); +}; + +const getAttachmentForFile = async (fileToUpload: EncryptedUpload): Promise => { + const attachment: FileAttachmentProps = { + title: fileToUpload.file.name, + type: 'file', + title_link: fileToUpload.url, + title_link_download: true, + encryption: { + key: fileToUpload.encryptedFile.key, + iv: fileToUpload.encryptedFile.iv, + }, + hashes: { + sha256: fileToUpload.encryptedFile.hash, + }, + }; + + const fileType = fileToUpload.file.type.match(/^(image|audio|video)\/.+/)?.[1] as 'image' | 'audio' | 'video' | undefined; + + if (!fileType) { + return { + ...attachment, + size: fileToUpload.file.size, + format: getFileExtension(fileToUpload.file.name), + }; + } + + return { + ...attachment, + [`${fileType}_url`]: fileToUpload.url, + [`${fileType}_type`]: fileToUpload.file.type, + [`${fileType}_size`]: fileToUpload.file.size, + ...(fileType === 'image' && { + image_dimensions: await getHeightAndWidthFromDataUrl(window.URL.createObjectURL(fileToUpload.file)), + }), + }; +}; + +const getEncryptedContent = async (filesToUpload: readonly EncryptedUpload[], e2eRoom: E2ERoom, msg: string) => { + const attachments: FileAttachmentProps[] = []; + + const arrayOfFiles = await Promise.all( + filesToUpload.map(async (fileToUpload) => { + attachments.push(await getAttachmentForFile(fileToUpload)); + + const file = { + _id: fileToUpload.id, + name: fileToUpload.file.name, + type: fileToUpload.file.type, + size: fileToUpload.file.size, + format: getFileExtension(fileToUpload.file.name), + }; + + return file; + }), + ); + + return e2eRoom.encryptMessageContent({ + attachments, + files: arrayOfFiles, + file: arrayOfFiles[0], + msg, + }); +}; + +export const processMessageUploads = async (chat: ChatAPI, message: IMessage): Promise => { + const { tmid, msg } = message; + const room = await chat.data.getRoom(); + const e2eRoom = await e2e.getInstanceByRoomId(room._id); + + const store = tmid ? chat.threadUploads : chat.uploads; + const filesToUpload = store.get(); + const multiFilePerMessageEnabled = settings.peek('FileUpload_EnableMultipleFilesPerMessage') as boolean; + + if (filesToUpload.length === 0) { + return false; + } + + const failedUploads = filesToUpload.filter((upload) => upload.error); + + if (!failedUploads.length) { + return continueSendingMessage(); + } + + if (failedUploads.length > 0) { + const allUploadsFailed = failedUploads.length === filesToUpload.length; + + return new Promise((resolve) => { + imperativeModal.open({ + component: GenericModal, + props: { + variant: 'warning', + children: t('__count__files_failed_to_upload', { + count: failedUploads.length, + ...(failedUploads.length === 1 && { name: failedUploads[0].file.name }), + }), + ...(allUploadsFailed && { + title: t('Warning'), + confirmText: t('Ok'), + onConfirm: () => { + imperativeModal.close(); + resolve(true); + }, + }), + ...(!allUploadsFailed && { + title: t('Are_you_sure'), + confirmText: t('Send_anyway'), + cancelText: t('Cancel'), + onConfirm: () => { + imperativeModal.close(); + resolve(continueSendingMessage()); + }, + onCancel: () => { + imperativeModal.close(); + resolve(true); + }, + }), + onClose: () => { + imperativeModal.close(); + resolve(true); + }, + }, + }); + }); + } + + return continueSendingMessage(); + + async function continueSendingMessage() { + const fileUrls: string[] = []; + const filesToConfirm: IUploadToConfirm[] = []; + + for await (const upload of filesToUpload) { + if (!upload.url || !upload.id) { + continue; + } + + let content; + if (e2eRoom && isEncryptedUpload(upload)) { + content = await e2eRoom.encryptMessageContent(upload.metadataForEncryption); + } + + fileUrls.push(upload.url); + filesToConfirm.push({ _id: upload.id, name: upload.file.name, content }); + } + + const shouldConvertSentMessages = await e2eRoom?.shouldConvertSentMessages({ msg }); + + let content; + if (e2eRoom && shouldConvertSentMessages) { + content = await getEncryptedContent(filesToUpload as EncryptedUpload[], e2eRoom, msg); + } + + const composedMessage: AtLeast = { + ...message, + tmid, + msg, + content, + ...(e2eRoom && { + t: 'e2e', + msg: '', + }), + } as const; + + try { + if ((!multiFilePerMessageEnabled || isOmnichannelRoom(room)) && filesToConfirm.length > 1) { + await Promise.all( + filesToConfirm.map((fileToConfirm, index) => { + /** + * The first message will keep the composedMessage, + * subsequent messages will have a new ID with empty text + * */ + const messageToSend = index === 0 ? composedMessage : { ...composedMessage, _id: Random.id(), msg: '' }; + return sdk.call('sendMessage', messageToSend, [fileUrls[index]], [fileToConfirm]); + }), + ); + } else { + await sdk.call('sendMessage', composedMessage, fileUrls, filesToConfirm); + } + store.clear(); + } catch (error: unknown) { + dispatchToastMessage({ type: 'error', message: error }); + } finally { + chat.action.stop('uploading'); + } + + return true; + } +}; diff --git a/apps/meteor/client/lib/chats/flows/processTooLongMessage.ts b/apps/meteor/client/lib/chats/flows/processTooLongMessage.ts index 972984dd6ffd8..307a552a7d726 100644 --- a/apps/meteor/client/lib/chats/flows/processTooLongMessage.ts +++ b/apps/meteor/client/lib/chats/flows/processTooLongMessage.ts @@ -7,7 +7,7 @@ import { dispatchToastMessage } from '../../toast'; import { getUser } from '../../user'; import type { ChatAPI } from '../ChatAPI'; -export const processTooLongMessage = async (chat: ChatAPI, { msg }: Pick): Promise => { +export const processTooLongMessage = async (chat: ChatAPI, { msg, tmid }: Pick): Promise => { const maxAllowedSize = settings.peek('Message_MaxAllowedSize'); if (msg.length <= maxAllowedSize) { @@ -33,7 +33,7 @@ export const processTooLongMessage = async (chat: ChatAPI, { msg }: Pick => { if (!(await chat.data.isSubscribedToRoom())) { try { @@ -58,14 +64,17 @@ export const sendMessage = async ( chat.readStateManager.clearUnreadMark(); + const uploadsStore = tmid ? chat.threadUploads : chat.uploads; + const hasFiles = uploadsStore.get().length > 0; + text = text.trim(); const mid = chat.currentEditingMessage.getMID(); - if (!text && !mid) { + if (!text && !mid && !hasFiles) { // Nothing to do return false; } - if (text) { + if (text || hasFiles) { const message = await chat.data.composeMessage(text, { sendToChannel: tshow, quotedMessages: chat.composer?.quotedMessages.get() ?? [], diff --git a/apps/meteor/client/lib/chats/flows/uploadFiles.ts b/apps/meteor/client/lib/chats/flows/uploadFiles.ts index 2b7a228579182..3433cf1fcdbc4 100644 --- a/apps/meteor/client/lib/chats/flows/uploadFiles.ts +++ b/apps/meteor/client/lib/chats/flows/uploadFiles.ts @@ -1,206 +1,93 @@ -import type { IMessage, FileAttachmentProps, IE2EEMessage, IUpload } from '@rocket.chat/core-typings'; -import { isRoomFederated } from '@rocket.chat/core-typings'; -import { imperativeModal } from '@rocket.chat/ui-client'; - -import { fileUploadIsValidContentType } from '../../../../app/utils/client'; -import { getFileExtension } from '../../../../lib/utils/getFileExtension'; -import FileUploadModal from '../../../views/room/modals/FileUploadModal'; +import { t } from '../../../../app/utils/lib/i18n'; +import { MAX_MULTIPLE_UPLOADED_FILES } from '../../../../lib/constants'; import { e2e } from '../../e2ee'; import { settings } from '../../settings'; -import { prependReplies } from '../../utils/prependReplies'; -import type { ChatAPI } from '../ChatAPI'; - -const getHeightAndWidthFromDataUrl = (dataURL: string): Promise<{ height: number; width: number }> => { - return new Promise((resolve) => { - const img = new Image(); - img.onload = () => { - resolve({ - height: img.height, - width: img.width, - }); - }; - img.src = dataURL; - }); -}; - -export const uploadFiles = async (chat: ChatAPI, files: readonly File[], resetFileInput?: () => void): Promise => { - const replies = chat.composer?.quotedMessages.get() ?? []; - - const msg = await prependReplies('', replies); +import { dispatchToastMessage } from '../../toast'; +import type { ChatAPI, UploadsAPI, EncryptedFileUploadContent } from '../ChatAPI'; + +export const uploadFiles = async ( + chat: ChatAPI, + { files, uploadsStore, resetFileInput }: { files: readonly File[]; uploadsStore: UploadsAPI; resetFileInput?: () => void }, +): Promise => { + const mergedFilesLength = files.length + uploadsStore.get().length; + if (mergedFilesLength > MAX_MULTIPLE_UPLOADED_FILES) { + return dispatchToastMessage({ + type: 'error', + message: t('You_cant_upload_more_than__count__files', { count: MAX_MULTIPLE_UPLOADED_FILES }), + }); + } const room = await chat.data.getRoom(); - const queue = [...files]; - const uploadFile = ( - file: File, - extraData?: Pick & { description?: string }, - getContent?: (fileId: string, fileUrl: string) => Promise, - fileContent?: { raw: Partial; encrypted: IE2EEMessage['content'] }, - ) => { - chat.uploads.send( - file, - { - msg, - ...extraData, - }, - getContent, - fileContent, - ); - chat.composer?.clear(); - imperativeModal.close(); + const uploadFile = (file: File, encrypted?: EncryptedFileUploadContent) => { + if (encrypted) { + uploadsStore.send(file, encrypted); + } else { + uploadsStore.send(file); + } + uploadNextFile(); }; - const uploadNextFile = (): void => { - const file = queue.pop(); + const uploadNextFile = async (): Promise => { + const file = queue.shift(); 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: async (fileName, description): Promise => { - Object.defineProperty(file, 'name', { - writable: true, - value: fileName, - }); - - // encrypt attachment description - const e2eRoom = await e2e.getInstanceByRoomId(room._id); - - if (!e2eRoom) { - uploadFile(file, { description }); - return; - } - - if (!settings.peek('E2E_Enable_Encrypt_Files')) { - uploadFile(file, { description }); - return; - } - - const shouldConvertSentMessages = await e2eRoom.shouldConvertSentMessages({ msg }); - - if (!shouldConvertSentMessages) { - uploadFile(file, { description }); - return; - } - - const encryptedFile = await e2eRoom.encryptFile(file); - - if (encryptedFile) { - const getContent = async (_id: string, fileUrl: string): Promise => { - const attachments = []; + Object.defineProperty(file, 'name', { + writable: true, + value: file.name, + }); - const attachment: FileAttachmentProps = { - title: file.name, - type: 'file', - description, - title_link: fileUrl, - title_link_download: true, - encryption: { - key: encryptedFile.key, - iv: encryptedFile.iv, - }, - hashes: { - sha256: encryptedFile.hash, - }, - }; + const e2eRoom = await e2e.getInstanceByRoomId(room._id); - if (/^image\/.+/.test(file.type)) { - const dimensions = await getHeightAndWidthFromDataUrl(window.URL.createObjectURL(file)); + if (!e2eRoom) { + uploadFile(file); + return; + } - attachments.push({ - ...attachment, - image_url: fileUrl, - image_type: file.type, - image_size: file.size, - ...(dimensions && { - image_dimensions: dimensions, - }), - }); - } else if (/^audio\/.+/.test(file.type)) { - attachments.push({ - ...attachment, - audio_url: fileUrl, - audio_type: file.type, - audio_size: file.size, - }); - } else if (/^video\/.+/.test(file.type)) { - attachments.push({ - ...attachment, - video_url: fileUrl, - video_type: file.type, - video_size: file.size, - }); - } else { - attachments.push({ - ...attachment, - size: file.size, - format: getFileExtension(file.name), - }); - } + if (!settings.peek('E2E_Enable_Encrypt_Files')) { + dispatchToastMessage({ + type: 'error', + message: t('Encrypted_file_not_allowed'), + }); + return; + } - const files = [ - { - _id, - name: file.name, - type: file.type, - size: file.size, - // "format": "png" - }, - ] as IMessage['files']; + if (!e2eRoom.isReady()) { + uploadFile(file); + return; + } - return e2eRoom.encryptMessageContent({ - attachments, - files, - file: files?.[0], - }); - }; + const encryptedFile = await e2eRoom.encryptFile(file); - const fileContentData = { - type: file.type, - typeGroup: file.type.split('/')[0], - name: fileName, - encryption: { - key: encryptedFile.key, - iv: encryptedFile.iv, - }, - hashes: { - sha256: encryptedFile.hash, - }, - }; + if (encryptedFile) { + const fileContentData = { + type: file.type, + typeGroup: file.type.split('/')[0], + name: file.name, + encryption: { + key: encryptedFile.key, + iv: encryptedFile.iv, + }, + hashes: { + sha256: encryptedFile.hash, + }, + }; - const fileContent = { - raw: fileContentData, - encrypted: await e2eRoom.encryptMessageContent(fileContentData), - }; + const fileContent = { + raw: fileContentData, + encrypted: await e2eRoom.encryptMessageContent(fileContentData), + }; - uploadFile( - encryptedFile.file, - { - t: 'e2e', - }, - getContent, - fileContent, - ); - } - }, - invalidContentType: !fileUploadIsValidContentType(file.type), - }, - }); + uploadFile(encryptedFile.file, { rawFile: file, fileContent, encryptedFile }); + } }; uploadNextFile(); resetFileInput?.(); + chat?.action.performContinuously('uploading'); }; diff --git a/apps/meteor/client/lib/chats/uploads.ts b/apps/meteor/client/lib/chats/uploads.ts index ce475b891e689..8d75822ba1a97 100644 --- a/apps/meteor/client/lib/chats/uploads.ts +++ b/apps/meteor/client/lib/chats/uploads.ts @@ -1,184 +1,224 @@ -import type { IMessage, IRoom, IE2EEMessage, IUpload } from '@rocket.chat/core-typings'; +import type { IRoom } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { Random } from '@rocket.chat/random'; +import fileSize from 'filesize'; -import { UserAction, USER_ACTIVITIES } from '../../../app/ui/client/lib/UserAction'; -import { sdk } from '../../../app/utils/client/lib/SDKClient'; import { getErrorMessage } from '../errorHandling'; -import type { 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, - t, - }: { - description?: string; - msg?: string; - rid: string; - tmid?: string; - t?: IMessage['t']; - }, - getContent?: (fileId: string, fileUrl: string) => Promise, - fileContent?: { raw: Partial; encrypted: IE2EEMessage['content'] }, -): Promise => { - const id = Random.id(); - - const upload: Upload = { - id, - name: fileContent?.raw.name || file.name, - percentage: 0, +import type { UploadsAPI, EncryptedFileUploadContent } from './ChatAPI'; +import { isEncryptedUpload, type Upload } from './Upload'; +import { fileUploadIsValidContentType } from '../../../app/utils/client'; +import { sdk } from '../../../app/utils/client/lib/SDKClient'; +import { i18n } from '../../../app/utils/lib/i18n'; +import { settings } from '../settings'; + +class UploadsStore extends Emitter<{ update: void; [x: `cancelling-${Upload['id']}`]: void }> implements UploadsAPI { + private rid: string; + + constructor({ rid }: { rid: string }) { + super(); + + this.rid = rid; + } + + uploads: readonly Upload[] = []; + + set = (uploads: Upload[]): void => { + this.uploads = uploads; + this.emit('update'); }; - updateUploads((uploads) => [...uploads, upload]); - - try { - await new Promise((resolve, reject) => { - const xhr = sdk.rest.upload( - `/v1/rooms.media/${rid}`, - { - file, - ...(fileContent && { - content: JSON.stringify(fileContent.encrypted), - }), - }, - { - load: (event) => { - resolve(event); - }, - progress: (event) => { - if (!event.lengthComputable) { - return; - } - const progress = (event.loaded / event.total) * 100; - if (progress === 100) { - return; - } + get = (): readonly Upload[] => this.uploads; - 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); - }, - }, - ); + subscribe = (callback: () => void): (() => void) => this.on('update', callback); - xhr.onload = async () => { - if (xhr.readyState === xhr.DONE) { - if (xhr.status === 400) { - const error = JSON.parse(xhr.responseText); - updateUploads((uploads) => [...uploads, { ...upload, error: new Error(error.error) }]); - return; - } + cancel = (id: Upload['id']): void => { + this.emit(`cancelling-${id}`); + }; - if (xhr.status === 200) { - const result = JSON.parse(xhr.responseText); - let content; - if (getContent) { - content = await getContent(result.file._id, result.file.url); - } + wipeFailedOnes = (): void => { + this.set(this.uploads.filter((upload) => !upload.error)); + }; - await sdk.rest.post(`/v1/rooms.mediaConfirm/${rid}/${result.file._id}`, { - msg, - tmid, - description, - t, - content, - }); + removeUpload = (id: Upload['id']): void => { + this.set(this.uploads.filter((upload) => upload.id !== id)); + }; + + editUploadFileName = async (uploadId: Upload['id'], fileName: Upload['file']['name']): Promise => { + try { + this.set( + this.uploads.map((upload) => { + if (upload.id !== uploadId) { + return upload; } + + return { + ...upload, + file: new File([upload.file], fileName, upload.file), + ...(isEncryptedUpload(upload) && { + metadataForEncryption: { ...upload.metadataForEncryption, name: fileName }, + }), + }; + }), + ); + } catch (error) { + this.set( + this.uploads.map((upload) => { + if (upload.id !== uploadId) { + return upload; + } + + return { + ...upload, + percentage: 0, + error: new Error(i18n.t('FileUpload_Update_Failed')), + }; + }), + ); + } + }; + + clear = () => this.set([]); + + async send(file: File, encrypted?: EncryptedFileUploadContent): Promise { + const maxFileSize = settings.peek('FileUpload_MaxFileSize'); + const invalidContentType = !fileUploadIsValidContentType(file.type); + const id = Random.id(); + + this.set([ + ...this.uploads, + { + id, + file: encrypted ? encrypted.rawFile : file, + percentage: 0, + encryptedFile: encrypted?.encryptedFile, + metadataForEncryption: encrypted?.fileContent.raw, + }, + ]); + + try { + await new Promise((resolve, reject) => { + if (file.size === 0) { + reject(new Error(i18n.t('FileUpload_File_Empty'))); } - }; - if (uploads.length) { - UserAction.performContinuously(rid, USER_ACTIVITIES.USER_UPLOADING, { tmid }); - } + // -1 maxFileSize means there is no limit + if (maxFileSize > -1 && (file.size || 0) > maxFileSize) { + reject(new Error(i18n.t('File_exceeds_allowed_size_of_bytes', { size: fileSize(maxFileSize) }))); + } - 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; + if (invalidContentType) { + reject(new Error(i18n.t('FileUpload_MediaType_NotAccepted__type__', { type: file.type }))); } - return { - ...upload, - percentage: 0, - error: new Error(getErrorMessage(error)), + const xhr = sdk.rest.upload( + `/v1/rooms.media/${this.rid}`, + { + file, + ...(encrypted && { + content: JSON.stringify(encrypted.fileContent.encrypted), + }), + }, + { + load: (event) => { + resolve(event); + }, + progress: (event) => { + if (!event.lengthComputable) { + return; + } + const progress = (event.loaded / event.total) * 100; + this.set( + this.uploads.map((upload) => { + if (upload.id !== id) { + return upload; + } + + return { + ...upload, + percentage: Math.round(progress) || 0, + }; + }), + ); + }, + error: (event) => { + this.set( + this.uploads.map((upload) => { + if (upload.id !== id) { + return upload; + } + + return { + ...upload, + percentage: 0, + error: new Error(xhr.responseText), + }; + }), + ); + reject(event); + }, + }, + ); + + xhr.onload = () => { + if (xhr.readyState === xhr.DONE) { + if (xhr.status === 400) { + const error = JSON.parse(xhr.responseText); + this.set( + this.uploads.map((upload) => { + if (upload.id !== id) { + return upload; + } + + return { + ...upload, + error: new Error(error.error), + }; + }), + ); + return; + } + + if (xhr.status === 200) { + const result = JSON.parse(xhr.responseText); + this.set( + this.uploads.map((upload) => { + if (upload.id !== id) { + return upload; + } + + return { + ...upload, + id: result.file._id, + url: result.file.url, + }; + }), + ); + } + } }; - }), - ); - } finally { - if (!uploads.length) { - UserAction.stop(rid, USER_ACTIVITIES.USER_UPLOADING, { tmid }); + + this.once(`cancelling-${id}`, () => { + xhr.abort(); + this.set(this.uploads.filter((upload) => upload.id !== id)); + reject(new Error(i18n.t('FileUpload_Cancelled'))); + }); + }); + } catch (error: unknown) { + this.set( + this.uploads.map((upload) => { + if (upload.id !== id) { + return upload; + } + + return { + ...upload, + percentage: 0, + error: new Error(getErrorMessage(error)), + }; + }), + ); } } -}; - -export const createUploadsAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid?: IMessage['_id'] }): UploadsAPI => ({ - get, - subscribe, - wipeFailedOnes, - cancel, - send: ( - file: File, - { description, msg, t }: { description?: string; msg?: string; t?: IMessage['t'] }, - getContent?: (fileId: string, fileUrl: string) => Promise, - fileContent?: { raw: Partial; encrypted: IE2EEMessage['content'] }, - ): Promise => send(file, { description, msg, rid, tmid, t }, getContent, fileContent), -}); +} + +export const createUploadsAPI = ({ rid }: { rid: IRoom['_id'] }): UploadsAPI => new UploadsStore({ rid }); diff --git a/apps/meteor/client/providers/ImageGalleryProvider.tsx b/apps/meteor/client/providers/ImageGalleryProvider.tsx index c5533ec8ad4da..1681126e6054f 100644 --- a/apps/meteor/client/providers/ImageGalleryProvider.tsx +++ b/apps/meteor/client/providers/ImageGalleryProvider.tsx @@ -23,8 +23,17 @@ const ImageGalleryProvider = ({ children }: ImageGalleryProviderProps) => { return setSingleImageUrl(target.dataset.id); } if (target?.classList.contains('gallery-item')) { + /** + * When sharing multiple files, the preview incorrectly always showed the first image. + * This was add to ensure the clicked image is displayed in the preview. + * ROOT CAUSE: `RoomMessageContent` component was only passing the first file ID to attachment elements. + * SOLUTION: We likely need to store the individual file ID within each attachment element + * and use the initially passed first ID as a fallback for older records. + */ + const idFromSrc = target.dataset.src?.split('/file-upload/')[1]?.split('/')[0]; + const id = target.closest('.gallery-item-container')?.getAttribute('data-id') || undefined; - return setImageId(target.dataset.id || id); + return setImageId(idFromSrc || target.dataset.id || id); } if (target?.classList.contains('gallery-item-container')) { return setImageId(target.dataset.id); diff --git a/apps/meteor/client/views/composer/AudioMessageRecorder/AudioMessageRecorder.tsx b/apps/meteor/client/views/composer/AudioMessageRecorder/AudioMessageRecorder.tsx index 3a57324c70c13..62a2b2ec56276 100644 --- a/apps/meteor/client/views/composer/AudioMessageRecorder/AudioMessageRecorder.tsx +++ b/apps/meteor/client/views/composer/AudioMessageRecorder/AudioMessageRecorder.tsx @@ -7,18 +7,18 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { AudioRecorder } from '../../../../app/ui/client/lib/recorderjs/AudioRecorder'; -import type { ChatAPI } from '../../../lib/chats/ChatAPI'; +import type { UploadsAPI } from '../../../lib/chats/ChatAPI'; import { useChat } from '../../room/contexts/ChatContext'; const audioRecorder = new AudioRecorder(); type AudioMessageRecorderProps = { rid: IRoom['_id']; - chatContext?: ChatAPI; // TODO: remove this when the composer is migrated to React + uploadsStore: UploadsAPI; isMicrophoneDenied?: boolean; }; -const AudioMessageRecorder = ({ rid, chatContext, isMicrophoneDenied }: AudioMessageRecorderProps): ReactElement | null => { +const AudioMessageRecorder = ({ rid, uploadsStore, isMicrophoneDenied }: AudioMessageRecorderProps): ReactElement | null => { const { t } = useTranslation(); const [state, setState] = useState<'loading' | 'recording'>('recording'); @@ -81,7 +81,7 @@ const AudioMessageRecorder = ({ rid, chatContext, isMicrophoneDenied }: AudioMes await stopRecording(); }); - const chat = useChat() ?? chatContext; + const chat = useChat(); const handleDoneButtonClick = useEffectEvent(async () => { setState('loading'); @@ -91,7 +91,7 @@ const AudioMessageRecorder = ({ rid, chatContext, isMicrophoneDenied }: AudioMes const fileName = `${t('Audio_record')}.mp3`; const file = new File([blob], fileName, { type: 'audio/mpeg' }); - await chat?.flows.uploadFiles([file]); + await chat?.flows.uploadFiles({ files: [file], uploadsStore }); }); useEffect(() => { diff --git a/apps/meteor/client/views/composer/VideoMessageRecorder/VideoMessageRecorder.tsx b/apps/meteor/client/views/composer/VideoMessageRecorder/VideoMessageRecorder.tsx index 0c50a6013ae60..685fe624703ab 100644 --- a/apps/meteor/client/views/composer/VideoMessageRecorder/VideoMessageRecorder.tsx +++ b/apps/meteor/client/views/composer/VideoMessageRecorder/VideoMessageRecorder.tsx @@ -8,13 +8,13 @@ import { useRef, useEffect, useState } from 'react'; import { UserAction, USER_ACTIVITIES } from '../../../../app/ui/client/lib/UserAction'; import { VideoRecorder } from '../../../../app/ui/client/lib/recorderjs/videoRecorder'; -import type { ChatAPI } from '../../../lib/chats/ChatAPI'; +import type { UploadsAPI } from '../../../lib/chats/ChatAPI'; import { useChat } from '../../room/contexts/ChatContext'; type VideoMessageRecorderProps = { rid: IRoom['_id']; tmid?: IMessage['_id']; - chatContext?: ChatAPI; // TODO: remove this when the composer is migrated to React + uploadsStore: UploadsAPI; reference: RefObject; } & Omit, 'is'>; @@ -38,7 +38,7 @@ const getVideoRecordingExtension = () => { return 'mp4'; }; -const VideoMessageRecorder = ({ rid, tmid, chatContext, reference }: VideoMessageRecorderProps) => { +const VideoMessageRecorder = ({ rid, tmid, uploadsStore, reference }: VideoMessageRecorderProps) => { const t = useTranslation(); const videoRef = useRef(null); const dispatchToastMessage = useToastMessageDispatch(); @@ -49,7 +49,7 @@ const VideoMessageRecorder = ({ rid, tmid, chatContext, reference }: VideoMessag const isRecording = recordingState === 'recording'; const sendButtonDisabled = !(VideoRecorder.cameraStarted.get() && !(recordingState === 'recording')); - const chat = useChat() ?? chatContext; + const chat = useChat(); const stopVideoRecording = async (rid: IRoom['_id'], tmid?: IMessage['_id']) => { if (recordingInterval) { @@ -86,7 +86,7 @@ const VideoMessageRecorder = ({ rid, tmid, chatContext, reference }: VideoMessag const cb = async (blob: Blob) => { const fileName = `${t('Video_record')}.${getVideoRecordingExtension()}`; const file = new File([blob], fileName, { type: VideoRecorder.getSupportedMimeTypes().split(';')[0] }); - await chat?.flows.uploadFiles([file]); + await chat?.flows.uploadFiles({ files: [file], uploadsStore }); chat?.composer?.setRecordingVideo(false); }; diff --git a/apps/meteor/client/views/room/body/RoomBody.tsx b/apps/meteor/client/views/room/body/RoomBody.tsx index cff19dec9641d..8bd79457b70cf 100644 --- a/apps/meteor/client/views/room/body/RoomBody.tsx +++ b/apps/meteor/client/views/room/body/RoomBody.tsx @@ -12,6 +12,9 @@ import DropTargetOverlay from './DropTargetOverlay'; import JumpToRecentMessageButton from './JumpToRecentMessageButton'; import LoadingMessagesIndicator from './LoadingMessagesIndicator'; import RetentionPolicyWarning from './RetentionPolicyWarning'; +import RoomForeword from './RoomForeword/RoomForeword'; +import UnreadMessagesIndicator from './UnreadMessagesIndicator'; +import { useRestoreScrollPosition } from './hooks/useRestoreScrollPosition'; import MessageListErrorBoundary from '../MessageList/MessageListErrorBoundary'; import RoomAnnouncement from '../RoomAnnouncement'; import ComposerContainer from '../composer/ComposerContainer'; @@ -23,15 +26,11 @@ import { useRoom, useRoomSubscription, useRoomMessages } from '../contexts/RoomC import { useDateScroll } from '../hooks/useDateScroll'; import { useMessageListNavigation } from '../hooks/useMessageListNavigation'; import { useRetentionPolicy } from '../hooks/useRetentionPolicy'; -import RoomForeword from './RoomForeword/RoomForeword'; -import UnreadMessagesIndicator from './UnreadMessagesIndicator'; -import { UploadProgressContainer, UploadProgressIndicator } from './UploadProgress'; -import { useFileUpload } from './hooks/useFileUpload'; +import { useFileUploadDropTarget } from './hooks/useFileUploadDropTarget'; import { useGetMore } from './hooks/useGetMore'; import { useGoToHomeOnRemoved } from './hooks/useGoToHomeOnRemoved'; import { useHasNewMessages } from './hooks/useHasNewMessages'; import { useListIsAtBottom } from './hooks/useListIsAtBottom'; -import { useRestoreScrollPosition } from './hooks/useRestoreScrollPosition'; import { useSelectAllAndScrollToTop } from './hooks/useSelectAllAndScrollToTop'; import { useHandleUnread } from './hooks/useUnreadMessages'; import { useJumpToMessageImperative } from '../MessageList/hooks/useJumpToMessage'; @@ -116,12 +115,7 @@ const RoomBody = (): ReactElement => { surroundingMessagesJumpTpRef, ); - const { - uploads, - handleUploadFiles, - handleUploadProgressClose, - targeDrop: [fileUploadTriggerProps, fileUploadOverlayProps], - } = useFileUpload(); + const [fileUploadTriggerProps, fileUploadOverlayProps] = useFileUploadDropTarget(chat.uploads); const { messageListRef } = useMessageListNavigation(); const { innerRef: selectAndScrollRef, selectAllAndScrollToTop } = useSelectAllAndScrollToTop(); @@ -203,20 +197,6 @@ const RoomBody = (): ReactElement => {
- {uploads.length > 0 && ( - - {uploads.map((upload) => ( - - ))} - - )} {Boolean(unread) && ( { onMarkAsReadButtonClick={handleMarkAsReadButtonClick} /> )} - -
{ onResize={handleComposerResize} onNavigateToPreviousMessage={handleNavigateToPreviousMessage} onNavigateToNextMessage={handleNavigateToNextMessage} - onUploadFiles={handleUploadFiles} onClickSelectAll={selectAllAndScrollToTop} // TODO: send previewUrls param // previewUrls={} diff --git a/apps/meteor/client/views/room/body/UploadProgress/UploadProgressContainer.tsx b/apps/meteor/client/views/room/body/UploadProgress/UploadProgressContainer.tsx deleted file mode 100644 index 03d36898b8839..0000000000000 --- a/apps/meteor/client/views/room/body/UploadProgress/UploadProgressContainer.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { Box } from '@rocket.chat/fuselage'; -import type { ComponentProps } from 'react'; - -const UploadProgressContainer = (props: ComponentProps) => { - return ; -}; - -export default UploadProgressContainer; diff --git a/apps/meteor/client/views/room/body/UploadProgress/UploadProgressIndicator.tsx b/apps/meteor/client/views/room/body/UploadProgress/UploadProgressIndicator.tsx deleted file mode 100644 index 850f1af094958..0000000000000 --- a/apps/meteor/client/views/room/body/UploadProgress/UploadProgressIndicator.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { css } from '@rocket.chat/css-in-js'; -import { Box, Button, Palette } from '@rocket.chat/fuselage'; -import type { ReactElement } from 'react'; -import { useCallback, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; - -import type { Upload } from '../../../../lib/chats/Upload'; - -type UploadProgressIndicatorProps = { - id: Upload['id']; - name: string; - percentage: number; - error?: string; - onClose?: (id: Upload['id']) => void; -}; - -const UploadProgressIndicator = ({ id, name, percentage, error, onClose }: UploadProgressIndicatorProps): ReactElement | null => { - const { t } = useTranslation(); - - const customClass = css` - &::after { - content: ''; - position: absolute; - z-index: 1; - left: 0; - width: ${percentage}%; - height: 100%; - transition: width, 1s, ease-out; - background-color: ${Palette.surface['surface-neutral']}; - } - `; - - const handleCloseClick = useCallback(() => { - onClose?.(id); - }, [id, onClose]); - - const uploadProgressTitle = useMemo(() => { - if (error) { - return `${error} ${name}`; - } - - return `[${percentage}%] ${t('Uploading_file__fileName__', { fileName: name })}`; - }, [error, name, percentage, t]); - - return ( - - - {uploadProgressTitle} - - - - ); -}; - -export default UploadProgressIndicator; diff --git a/apps/meteor/client/views/room/body/UploadProgress/index.ts b/apps/meteor/client/views/room/body/UploadProgress/index.ts deleted file mode 100644 index b4bfb8f16b0f1..0000000000000 --- a/apps/meteor/client/views/room/body/UploadProgress/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as UploadProgressIndicator } from './UploadProgressIndicator'; -export { default as UploadProgressContainer } from './UploadProgressContainer'; diff --git a/apps/meteor/client/views/room/body/hooks/useFileUpload.ts b/apps/meteor/client/views/room/body/hooks/useFileUpload.ts index e9803eb98a7dd..6c6a01832f0b2 100644 --- a/apps/meteor/client/views/room/body/hooks/useFileUpload.ts +++ b/apps/meteor/client/views/room/body/hooks/useFileUpload.ts @@ -1,39 +1,46 @@ -import { useCallback, useEffect, useSyncExternalStore } from 'react'; +import { useCallback, useEffect, useMemo, useSyncExternalStore } from 'react'; -import { useFileUploadDropTarget } from './useFileUploadDropTarget'; +import type { UploadsAPI } from '../../../../lib/chats/ChatAPI'; import type { Upload } from '../../../../lib/chats/Upload'; import { useChat } from '../../contexts/ChatContext'; -export const useFileUpload = () => { +export const useFileUpload = (store: UploadsAPI) => { const chat = useChat(); - if (!chat) { + + if (!chat || !store) { throw new Error('No ChatContext provided'); } useEffect(() => { - chat.uploads.wipeFailedOnes(); - }, [chat]); + store.wipeFailedOnes(); + }, [store]); - const uploads = useSyncExternalStore(chat.uploads.subscribe, chat.uploads.get); + const uploads = useSyncExternalStore(store.subscribe, store.get); const handleUploadProgressClose = useCallback( (id: Upload['id']) => { - chat.uploads.cancel(id); + store.cancel(id); }, - [chat], + [store], ); const handleUploadFiles = useCallback( (files: readonly File[]): void => { - chat.flows.uploadFiles(files); + chat?.flows.uploadFiles({ files, uploadsStore: store }); }, - [chat], + [chat, store], ); - return { - uploads, - handleUploadProgressClose, - handleUploadFiles, - targeDrop: useFileUploadDropTarget(), - }; + const isUploading = uploads.some((upload) => upload.percentage < 100); + + return useMemo( + () => ({ + uploads, + hasUploads: uploads.length > 0, + isUploading, + handleUploadProgressClose, + handleUploadFiles, + }), + [uploads, isUploading, handleUploadProgressClose, handleUploadFiles], + ); }; diff --git a/apps/meteor/client/views/room/body/hooks/useFileUploadDropTarget.ts b/apps/meteor/client/views/room/body/hooks/useFileUploadDropTarget.ts index 888e7e055080d..710bb80901e4b 100644 --- a/apps/meteor/client/views/room/body/hooks/useFileUploadDropTarget.ts +++ b/apps/meteor/client/views/room/body/hooks/useFileUploadDropTarget.ts @@ -5,12 +5,15 @@ import { useCallback, useMemo } from 'react'; import { useDropTarget } from './useDropTarget'; import { useReactiveValue } from '../../../../hooks/useReactiveValue'; +import type { UploadsAPI } from '../../../../lib/chats/ChatAPI'; import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator'; import { useIsRoomOverMacLimit } from '../../../omnichannel/hooks/useIsRoomOverMacLimit'; import { useChat } from '../../contexts/ChatContext'; import { useRoom, useRoomSubscription } from '../../contexts/RoomContext'; -export const useFileUploadDropTarget = (): readonly [ +export const useFileUploadDropTarget = ( + uploadsStore: UploadsAPI, +): readonly [ fileUploadTriggerProps: { onDragEnter: (event: DragEvent) => void; }, @@ -58,7 +61,7 @@ export const useFileUploadDropTarget = (): readonly [ return file; }); - chat?.flows.uploadFiles(uploads); + chat?.flows.uploadFiles({ files: uploads, uploadsStore }); }); const allOverlayProps = useMemo(() => { diff --git a/apps/meteor/client/views/room/composer/ComposerMessage.tsx b/apps/meteor/client/views/room/composer/ComposerMessage.tsx index cefe16936beeb..f59f8d086ba5f 100644 --- a/apps/meteor/client/views/room/composer/ComposerMessage.tsx +++ b/apps/meteor/client/views/room/composer/ComposerMessage.tsx @@ -58,6 +58,7 @@ const ComposerMessage = ({ tmid, onSend, ...props }: ComposerMessageProps): Reac tshow, previewUrls, isSlashCommandAllowed, + tmid, }); if (newMessageSent) onSend?.(); } catch (error) { @@ -73,11 +74,8 @@ const ComposerMessage = ({ tmid, onSend, ...props }: ComposerMessageProps): Reac }, onNavigateToPreviousMessage: () => chat?.messageEditing.toPreviousMessage(), onNavigateToNextMessage: () => chat?.messageEditing.toNextMessage(), - onUploadFiles: (files: readonly File[]) => { - return chat?.flows.uploadFiles(files); - }, }), - [chat?.data, chat?.flows, chat?.action, chat?.composer?.text, chat?.messageEditing, dispatchToastMessage, onSend], + [chat?.data, chat?.action, chat?.flows, chat?.composer?.text, chat?.messageEditing, dispatchToastMessage, tmid, onSend], ); const { subscribe, getSnapshotValue } = useMemo(() => { diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx index dd804ab3f6dd8..c70234328b33f 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx @@ -20,6 +20,7 @@ import MessageBoxActionsToolbar from './MessageBoxActionsToolbar'; import MessageBoxFormattingToolbar from './MessageBoxFormattingToolbar'; import MessageBoxHint from './MessageBoxHint'; import MessageBoxReplies from './MessageBoxReplies'; +import MessageComposerFileArea from './MessageComposerFileArea'; import { createComposerAPI } from '../../../../../app/ui-message/client/messageBox/createComposerAPI'; import type { FormattingButton } from '../../../../../app/ui-message/client/messageBox/messageBoxFormatting'; import { formattingButtons } from '../../../../../app/ui-message/client/messageBox/messageBoxFormatting'; @@ -31,6 +32,7 @@ import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator'; import { keyCodes } from '../../../../lib/utils/keyCodes'; import AudioMessageRecorder from '../../../composer/AudioMessageRecorder'; import VideoMessageRecorder from '../../../composer/VideoMessageRecorder'; +import { useFileUpload } from '../../body/hooks/useFileUpload'; import { useChat } from '../../contexts/ChatContext'; import { useComposerPopupOptions } from '../../contexts/ComposerPopupContext'; import { useRoom } from '../../contexts/RoomContext'; @@ -85,7 +87,6 @@ type MessageBoxProps = { onEscape?: () => void; onNavigateToPreviousMessage?: () => void; onNavigateToNextMessage?: () => void; - onUploadFiles?: (files: readonly File[]) => void; tshow?: IMessage['tshow']; previewUrls?: string[]; subscription?: ISubscription; @@ -99,7 +100,6 @@ const MessageBox = ({ onJoin, onNavigateToNextMessage, onNavigateToPreviousMessage, - onUploadFiles, onEscape, onTyping, tshow, @@ -158,6 +158,9 @@ const MessageBox = ({ chat.emojiPicker.open(ref, (emoji: string) => chat.composer?.insertText(` :${emoji}: `)); }); + const uploadsStore = tmid ? chat.threadUploads : chat.uploads; + const { uploads, hasUploads, handleUploadFiles, isUploading } = useFileUpload(uploadsStore); + const handleSendMessage = useEffectEvent(() => { const text = chat.composer?.text ?? ''; chat.composer?.clear(); @@ -347,7 +350,7 @@ const MessageBox = ({ if (files.length) { event.preventDefault(); - onUploadFiles?.(files); + handleUploadFiles?.(files); } }); @@ -378,6 +381,7 @@ const MessageBox = ({ ); const shouldPopupPreview = useEnablePopupPreview(popup.filter, popup.option); + const shouldDisableDueUploads = !hasUploads || isUploading; return ( <> @@ -416,9 +420,9 @@ const MessageBox = ({ unencryptedMessagesAllowed={unencryptedMessagesAllowed} isMobile={isMobile} /> - {isRecordingVideo && } + {isRecordingVideo && } - {isRecordingAudio && } + {isRecordingAudio && } + {hasUploads && ( + + )} @@ -470,10 +483,10 @@ const MessageBox = ({ )} diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/MessageBoxActionsToolbar.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/MessageBoxActionsToolbar.tsx index 9a1ed5db7c80a..62a9a3740434a 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/MessageBoxActionsToolbar.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/MessageBoxActionsToolbar.tsx @@ -28,6 +28,7 @@ type MessageBoxActionsToolbarProps = { isRecording: boolean; rid: IRoom['_id']; tmid?: IMessage['_id']; + isEditing: boolean; }; const isHidden = (hiddenActions: Array, action: GenericMenuItemProps) => { @@ -45,6 +46,7 @@ const MessageBoxActionsToolbar = ({ tmid, variant = 'large', isMicrophoneDenied, + isEditing = false, }: MessageBoxActionsToolbarProps) => { const t = useTranslation(); const chatContext = useChat(); @@ -54,11 +56,12 @@ const MessageBoxActionsToolbar = ({ } const room = useRoom(); + const uploadsStore = tmid ? chatContext.threadUploads : chatContext.uploads; const audioMessageAction = useAudioMessageAction(!canSend || typing || isRecording || isMicrophoneDenied, isMicrophoneDenied); const videoMessageAction = useVideoMessageAction(!canSend || typing || isRecording); - const fileUploadAction = useFileUploadAction(!canSend || typing || isRecording); - const webdavActions = useWebdavActions(); + const fileUploadAction = useFileUploadAction(!canSend || isRecording || isEditing, uploadsStore); + const webdavActions = useWebdavActions(uploadsStore); const createDiscussionAction = useCreateDiscussionAction(room); const shareLocationAction = useShareLocationAction(room, tmid); const timestampAction = useTimestampAction(chatContext.composer); diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useFileUploadAction.ts b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useFileUploadAction.ts index 0fc1e5e7fcf31..25202a7cdbd8f 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useFileUploadAction.ts +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useFileUploadAction.ts @@ -4,11 +4,12 @@ import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useFileInput } from '../../../../../../hooks/useFileInput'; +import type { UploadsAPI } from '../../../../../../lib/chats/ChatAPI'; import { useChat } from '../../../../contexts/ChatContext'; const fileInputProps = { type: 'file', multiple: true }; -export const useFileUploadAction = (disabled: boolean): GenericMenuItemProps => { +export const useFileUploadAction = (disabled: boolean, uploadsStore: UploadsAPI): GenericMenuItemProps => { const { t } = useTranslation(); const fileUploadEnabled = useSetting('FileUpload_Enabled', true); const fileInputRef = useFileInput(fileInputProps); @@ -31,12 +32,12 @@ export const useFileUploadAction = (disabled: boolean): GenericMenuItemProps => }); return file; }); - chat?.flows.uploadFiles(filesToUpload, resetFileInput); + chat?.flows.uploadFiles({ files: filesToUpload, uploadsStore, resetFileInput }); }; fileInputRef.current?.addEventListener('change', handleUploadChange); return () => fileInputRef?.current?.removeEventListener('change', handleUploadChange); - }, [chat, fileInputRef]); + }, [chat, fileInputRef, uploadsStore]); const handleUpload = () => { fileInputRef?.current?.click(); diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useWebdavActions.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useWebdavActions.tsx index 0df740e38afc6..419895be69965 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useWebdavActions.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useWebdavActions.tsx @@ -4,11 +4,12 @@ import { useSetModal, useSetting } from '@rocket.chat/ui-contexts'; import { useTranslation } from 'react-i18next'; import { useWebDAVAccountIntegrationsQuery } from '../../../../../../hooks/webdav/useWebDAVAccountIntegrationsQuery'; +import type { UploadsAPI } from '../../../../../../lib/chats/ChatAPI'; import { useChat } from '../../../../contexts/ChatContext'; import AddWebdavAccountModal from '../../../../webdav/AddWebdavAccountModal'; import WebdavFilePickerModal from '../../../../webdav/WebdavFilePickerModal'; -export const useWebdavActions = (): GenericMenuItemProps[] => { +export const useWebdavActions = (uploadsStore: UploadsAPI): GenericMenuItemProps[] => { const enabled = useSetting('Webdav_Integration_Enabled', false); const { isSuccess, data } = useWebDAVAccountIntegrationsQuery({ enabled }); @@ -19,10 +20,7 @@ export const useWebdavActions = (): GenericMenuItemProps[] => { const setModal = useSetModal(); const handleAddWebDav = () => setModal( setModal(null)} onConfirm={() => setModal(null)} />); - const handleUpload = async (file: File, description?: string) => - chat?.uploads.send(file, { - description, - }); + const handleUpload = async (file: File) => chat?.flows.uploadFiles({ files: [file], uploadsStore }); const handleOpenWebdav = (account: IWebdavAccountIntegration) => setModal( setModal(null)} />); diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageComposerFile.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageComposerFile.tsx new file mode 100644 index 0000000000000..8a3b20d01fc3a --- /dev/null +++ b/apps/meteor/client/views/room/composer/messageBox/MessageComposerFile.tsx @@ -0,0 +1,76 @@ +import { IconButton } from '@rocket.chat/fuselage'; +import { useSetModal } from '@rocket.chat/ui-contexts'; +import { useState } from 'react'; +import type { AllHTMLAttributes, ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; + +import MessageComposerFileComponent from './MessageComposerFileComponent'; +import MessageComposerFileError from './MessageComposerFileError'; +import MessageComposerFileLoader from './MessageComposerFileLoader'; +import { getMimeType } from '../../../../../app/utils/lib/mimeTypes'; +import { usePreventPropagation } from '../../../../hooks/usePreventPropagation'; +import type { Upload } from '../../../../lib/chats/Upload'; +import { formatBytes } from '../../../../lib/utils/formatBytes'; +import FileUploadModal from '../../modals/FileUploadModal'; + +type MessageComposerFileProps = { + upload: Upload; + onRemove: (id: string) => void; + onEdit: (id: Upload['id'], fileName: string) => void; + onCancel: (id: Upload['id']) => void; +} & Omit, 'is'>; + +const MessageComposerFile = ({ upload, onRemove, onEdit, onCancel, ...props }: MessageComposerFileProps): ReactElement => { + const { t } = useTranslation(); + const [isHover, setIsHover] = useState(false); + const setModal = useSetModal(); + + const fileSize = formatBytes(upload.file.size, 2); + const fileExtension = getMimeType(upload.file.type, upload.file.name); + const isLoading = upload.percentage !== 100 && !upload.error; + + const handleOpenFilePreview = () => { + setModal( + { + onEdit(upload.id, name); + setModal(null); + }} + fileName={upload.file.name} + file={upload.file} + onClose={() => setModal(null)} + />, + ); + }; + + const dismissAction = isLoading ? () => onCancel(upload.id) : () => onRemove(upload.id); + const handleDismiss = usePreventPropagation(dismissAction); + + const actionIcon = + isLoading && !isHover ? ( + + ) : ( + + ); + + if (upload.error) { + return ; + } + + return ( + ['Enter', 'Space'].includes(e.code) && handleOpenFilePreview()} + onMouseLeave={() => setIsHover(false)} + onMouseEnter={() => setIsHover(true)} + fileTitle={upload.file.name} + fileSubtitle={`${fileSize} - ${fileExtension}`} + actionIcon={actionIcon} + aria-busy={isLoading} + {...props} + /> + ); +}; + +export default MessageComposerFile; diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageComposerFileArea.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageComposerFileArea.tsx new file mode 100644 index 0000000000000..b93f296463611 --- /dev/null +++ b/apps/meteor/client/views/room/composer/messageBox/MessageComposerFileArea.tsx @@ -0,0 +1,38 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useTranslation } from 'react-i18next'; + +import MessageComposerFile from './MessageComposerFile'; +import type { Upload } from '../../../../lib/chats/Upload'; + +type MessageComposerFileAreaProps = { + uploads?: readonly Upload[]; + onRemove: (id: Upload['id']) => void; + onEdit: (id: Upload['id'], fileName: string) => void; + onCancel: (id: Upload['id']) => void; +}; + +const MessageComposerFileArea = ({ uploads, onRemove, onEdit, onCancel }: MessageComposerFileAreaProps) => { + const { t } = useTranslation(); + return ( + + {uploads?.map((upload) => ( +
+ +
+ ))} +
+ ); +}; + +export default MessageComposerFileArea; diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageComposerFileComponent.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageComposerFileComponent.tsx new file mode 100644 index 0000000000000..1a54955d06943 --- /dev/null +++ b/apps/meteor/client/views/room/composer/messageBox/MessageComposerFileComponent.tsx @@ -0,0 +1,61 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box, Palette } from '@rocket.chat/fuselage'; +import type { AllHTMLAttributes, ReactElement } from 'react'; + +type MessageComposerFileComponentProps = { + fileTitle: string; + fileSubtitle: string; + actionIcon: ReactElement; + error?: boolean; +} & Omit, 'is'>; + +// TODO: This component will live in `ui-composer` +const MessageComposerFileComponent = ({ fileTitle, fileSubtitle, actionIcon, error, ...props }: MessageComposerFileComponentProps) => { + const closeWrapperStyle = css` + position: absolute; + right: 0.5rem; + top: 0.5rem; + `; + + const previewWrapperStyle = css` + background-color: 'surface-tint'; + + &:hover { + cursor: ${error ? 'unset' : 'pointer'}; + background-color: ${Palette.surface['surface-hover']}; + } + `; + + return ( + + + + {fileTitle} + + + {fileSubtitle} + + + {actionIcon} + + ); +}; + +export default MessageComposerFileComponent; diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageComposerFileError.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageComposerFileError.tsx new file mode 100644 index 0000000000000..2f76fe8c16c72 --- /dev/null +++ b/apps/meteor/client/views/room/composer/messageBox/MessageComposerFileError.tsx @@ -0,0 +1,27 @@ +import type { AllHTMLAttributes, ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; + +import MessageComposerFileComponent from './MessageComposerFileComponent'; + +type MessageComposerFileComponentProps = { + fileTitle: string; + error: Error; + actionIcon: ReactElement; +} & AllHTMLAttributes; + +const MessageComposerFileError = ({ fileTitle, error, actionIcon, ...props }: MessageComposerFileComponentProps) => { + const { t } = useTranslation(); + + return ( + + ); +}; + +export default MessageComposerFileError; diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageComposerFileLoader.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageComposerFileLoader.tsx new file mode 100644 index 0000000000000..670802e472d59 --- /dev/null +++ b/apps/meteor/client/views/room/composer/messageBox/MessageComposerFileLoader.tsx @@ -0,0 +1,34 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box, Palette } from '@rocket.chat/fuselage'; +import type { ComponentProps } from 'react'; + +// TODO: This component should be moved to fuselage +const MessageComposerFileLoader = (props: ComponentProps) => { + const customCSS = css` + animation: spin-animation 0.8s linear infinite; + + @keyframes spin-animation { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } + } + `; + + return ( + + + + + ); +}; + +export default MessageComposerFileLoader; diff --git a/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useDeleteFile.tsx b/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useDeleteFile.tsx index 8f556969bc28d..f660a88f3d6e5 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useDeleteFile.tsx +++ b/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useDeleteFile.tsx @@ -1,19 +1,19 @@ import type { IUpload } from '@rocket.chat/core-typings'; import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; import { GenericModal } from '@rocket.chat/ui-client'; -import { useSetModal, useToastMessageDispatch, useMethod } from '@rocket.chat/ui-contexts'; +import { useSetModal, useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts'; import { useTranslation } from 'react-i18next'; export const useDeleteFile = (reload: () => void) => { const { t } = useTranslation(); const setModal = useSetModal(); const dispatchToastMessage = useToastMessageDispatch(); - const deleteFile = useMethod('deleteFileMessage'); + const deleteFile = useEndpoint('POST', '/v1/uploads.delete'); const handleDelete = useEffectEvent((_id: IUpload['_id']) => { const onConfirm = async () => { try { - await deleteFile(_id); + await deleteFile({ fileId: _id }); dispatchToastMessage({ type: 'success', message: t('Deleted') }); reload(); } catch (error) { diff --git a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx index e3403f4860a22..003d88a50f610 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx +++ b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx @@ -20,7 +20,11 @@ type ThreadChatProps = { }; const ThreadChat = ({ mainMessage }: ThreadChatProps) => { - const [fileUploadTriggerProps, fileUploadOverlayProps] = useFileUploadDropTarget(); + const chat = useChat(); + + if (!chat) { + throw new Error('No ChatContext provided'); + } const sendToChannelPreference = useUserPreference<'always' | 'never' | 'default'>('alsoSendThreadToChannel'); @@ -47,7 +51,7 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps) => { closeTab(); }, [closeTab]); - const chat = useChat(); + const [fileUploadTriggerProps, fileUploadOverlayProps] = useFileUploadDropTarget(chat.threadUploads); const handleNavigateToPreviousMessage = useCallback((): void => { chat?.messageEditing.toPreviousMessage(); @@ -57,13 +61,6 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps) => { chat?.messageEditing.toNextMessage(); }, [chat?.messageEditing]); - const handleUploadFiles = useCallback( - (files: readonly File[]): void => { - chat?.flows.uploadFiles(files); - }, - [chat?.flows], - ); - const room = useRoom(); const readThreads = useMethod('readThreads'); useEffect(() => { @@ -115,7 +112,6 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps) => { onEscape={handleComposerEscape} onNavigateToPreviousMessage={handleNavigateToPreviousMessage} onNavigateToNextMessage={handleNavigateToNextMessage} - onUploadFiles={handleUploadFiles} tshow={sendToChannel} > diff --git a/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.spec.tsx b/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.spec.tsx index a7f01e9d5c6de..c6478206286b7 100644 --- a/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.spec.tsx +++ b/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.spec.tsx @@ -39,19 +39,6 @@ test.each(testCases)('%s should have no a11y violations', async (_storyname, Sto expect(results).toHaveNoViolations(); }); -it('should display error message when description exceeds character limit', async () => { - render(, { - wrapper: defaultWrapper.withSetting('Message_MaxAllowedSize', 10).build(), - }); - - const input = await screen.findByRole('textbox', { name: 'File description' }); - expect(input).toBeInTheDocument(); - await userEvent.type(input, '12345678910'); - await userEvent.tab(); - - expect(screen.getByText('Cannot upload file, description is over the 10 character limit')).toBeInTheDocument(); -}); - it('should not send a renamed file with not allowed mime-type', async () => { render(, { wrapper: defaultWrapper.withSetting('FileUpload_MediaTypeBlackList', 'image/svg+xml').build(), @@ -60,7 +47,7 @@ it('should not send a renamed file with not allowed mime-type', async () => { const input = await screen.findByRole('textbox', { name: 'File name' }); await userEvent.type(input, 'testing.svg'); - const button = await screen.findByRole('button', { name: 'Send' }); + const button = await screen.findByRole('button', { name: 'Update' }); await userEvent.click(button); expect(screen.getByText('Media type not accepted: image/svg+xml')).toBeInTheDocument(); diff --git a/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.stories.tsx b/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.stories.tsx index 97626623e902b..7d731a4d87edb 100644 --- a/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.stories.tsx +++ b/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.stories.tsx @@ -11,8 +11,6 @@ export default { args: { file: new File(['The lazy brown fox jumped over the lazy brown fox.'], 'test.txt', { type: 'text/plain' }), fileName: 'test.txt', - fileDescription: '', - invalidContentType: false, }, } satisfies Meta; diff --git a/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx b/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx index 26487ff0571cb..1de7bac04dc47 100644 --- a/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx +++ b/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx @@ -15,11 +15,9 @@ import { ModalFooter, ModalFooterControllers, } from '@rocket.chat/fuselage'; -import { useAutoFocus, useMergedRefs } from '@rocket.chat/fuselage-hooks'; -import { useToastMessageDispatch, useTranslation, useSetting } from '@rocket.chat/ui-contexts'; -import fileSize from 'filesize'; +import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement, ComponentProps } from 'react'; -import { memo, useCallback, useEffect, useId } from 'react'; +import { memo, useCallback, useId } from 'react'; import { useForm } from 'react-hook-form'; import FilePreview from './FilePreview'; @@ -28,36 +26,21 @@ import { getMimeTypeFromFileName } from '../../../../../app/utils/lib/mimeTypes' type FileUploadModalProps = { onClose: () => void; - onSubmit: (name: string, description?: string) => void; + onSubmit: (name: string) => void; file: File; fileName: string; - fileDescription?: string; - invalidContentType: boolean; - showDescription?: boolean; }; -const FileUploadModal = ({ - onClose, - file, - fileName, - fileDescription, - onSubmit, - invalidContentType, - showDescription = true, -}: FileUploadModalProps): ReactElement => { +const FileUploadModal = ({ onClose, file, fileName, onSubmit }: FileUploadModalProps): ReactElement => { + const t = useTranslation(); + const fileUploadFormId = useId(); + const fileNameField = useId(); + const { register, handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ mode: 'onBlur', defaultValues: { name: fileName, description: fileDescription } }); - - const t = useTranslation(); - const dispatchToastMessage = useToastMessageDispatch(); - const maxMsgSize = useSetting('Message_MaxAllowedSize', 5000); - const maxFileSize = useSetting('FileUpload_MaxFileSize', 104857600); - - const isDescriptionValid = (description: string) => - description.length >= maxMsgSize ? t('Cannot_upload_file_character_limit', { count: maxMsgSize }) : true; + formState: { errors, isDirty, isSubmitting }, + } = useForm({ mode: 'onBlur', defaultValues: { name: fileName } }); const validateFileName = useCallback( (fieldValue: string) => { @@ -71,54 +54,11 @@ const FileUploadModal = ({ [t], ); - const submit = ({ name, description }: { name: string; description?: string }): void => { - // -1 maxFileSize means there is no limit - if (maxFileSize > -1 && (file.size || 0) > maxFileSize) { - onClose(); - return dispatchToastMessage({ - type: 'error', - message: t('File_exceeds_allowed_size_of_bytes', { size: fileSize(maxFileSize) }), - }); - } - - onSubmit(name, description); - }; - - useEffect(() => { - if (invalidContentType) { - dispatchToastMessage({ - type: 'error', - message: t('FileUpload_MediaType_NotAccepted__type__', { type: file.type }), - }); - onClose(); - return; - } - - if (file.size === 0) { - dispatchToastMessage({ - type: 'error', - message: t('FileUpload_File_Empty'), - }); - onClose(); - } - }, [file, dispatchToastMessage, invalidContentType, t, onClose]); - - const fileUploadFormId = useId(); - const fileNameField = useId(); - const fileDescriptionField = useId(); - const autoFocusRef = useAutoFocus(); - - const { ref, ...descriptionField } = register('description', { - validate: (value) => isDescriptionValid(value || ''), - }); - - const descriptionRef = useMergedRefs(ref, autoFocusRef); - return ( ) => ( - + (!isDirty ? onClose() : onSubmit(name)))} {...props} /> )} > @@ -152,26 +92,6 @@ const FileUploadModal = ({ )} - {showDescription && ( - - {t('Upload_file_description')} - - - - {errors.description && ( - - {errors.description.message} - - )} - - )} @@ -180,7 +100,7 @@ const FileUploadModal = ({ {t('Cancel')} diff --git a/apps/meteor/client/views/room/modals/FileUploadModal/__snapshots__/FileUploadModal.spec.tsx.snap b/apps/meteor/client/views/room/modals/FileUploadModal/__snapshots__/FileUploadModal.spec.tsx.snap index 920eec6e41461..fe275926b5438 100644 --- a/apps/meteor/client/views/room/modals/FileUploadModal/__snapshots__/FileUploadModal.spec.tsx.snap +++ b/apps/meteor/client/views/room/modals/FileUploadModal/__snapshots__/FileUploadModal.spec.tsx.snap @@ -91,29 +91,6 @@ exports[`renders Default without crashing 1`] = ` />
-
- - - - -
@@ -141,7 +118,7 @@ exports[`renders Default without crashing 1`] = ` - Send + Update diff --git a/apps/meteor/client/views/room/webdav/WebdavFilePickerModal/WebdavFilePickerModal.tsx b/apps/meteor/client/views/room/webdav/WebdavFilePickerModal/WebdavFilePickerModal.tsx index 51ca961b6f41c..ae0d862a6e3d5 100644 --- a/apps/meteor/client/views/room/webdav/WebdavFilePickerModal/WebdavFilePickerModal.tsx +++ b/apps/meteor/client/views/room/webdav/WebdavFilePickerModal/WebdavFilePickerModal.tsx @@ -3,7 +3,7 @@ import type { SelectOption } from '@rocket.chat/fuselage'; import { Modal, Box, IconButton, Select, ModalHeader, ModalTitle, ModalClose, ModalContent, ModalFooter } from '@rocket.chat/fuselage'; import { useEffectEvent, useDebouncedValue } from '@rocket.chat/fuselage-hooks'; import { useSort } from '@rocket.chat/ui-client'; -import { useMethod, useToastMessageDispatch, useTranslation, useSetModal } from '@rocket.chat/ui-contexts'; +import { useMethod, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement, MouseEvent } from 'react'; import { useState, useEffect, useCallback } from 'react'; @@ -11,9 +11,7 @@ import FilePickerBreadcrumbs from './FilePickerBreadcrumbs'; import WebdavFilePickerGrid from './WebdavFilePickerGrid'; import WebdavFilePickerTable from './WebdavFilePickerTable'; import { sortWebdavNodes } from './lib/sortWebdavNodes'; -import { fileUploadIsValidContentType } from '../../../../../app/utils/client'; import FilterByText from '../../../../components/FilterByText'; -import FileUploadModal from '../../modals/FileUploadModal'; export type WebdavSortOptions = 'name' | 'size' | 'dataModified'; @@ -25,7 +23,6 @@ type WebdavFilePickerModalProps = { const WebdavFilePickerModal = ({ onUpload, onClose, account }: WebdavFilePickerModalProps): ReactElement => { const t = useTranslation(); - const setModal = useSetModal(); const getWebdavFilePreview = useMethod('getWebdavFilePreview'); const getWebdavFileList = useMethod('getWebdavFileList'); const getFileFromWebdav = useMethod('getFileFromWebdav'); @@ -147,15 +144,7 @@ const WebdavFilePickerModal = ({ onUpload, onClose, account }: WebdavFilePickerM const blob = new Blob([data]); const file = new File([blob], webdavNode.basename, { type: webdavNode.mime }); - setModal( - => uploadFile(file, description)} - file={file} - onClose={(): void => setModal(null)} - invalidContentType={Boolean(file.type && !fileUploadIsValidContentType(file.type))} - />, - ); + uploadFile(file); } catch (error) { return dispatchToastMessage({ type: 'error', message: error }); } diff --git a/apps/meteor/lib/constants.ts b/apps/meteor/lib/constants.ts index 61b66421ce446..4c4b572ca210f 100644 --- a/apps/meteor/lib/constants.ts +++ b/apps/meteor/lib/constants.ts @@ -1 +1,2 @@ export const NOTIFICATION_ATTACHMENT_COLOR = '#FD745E'; +export const MAX_MULTIPLE_UPLOADED_FILES = 10; diff --git a/apps/meteor/server/lib/moderation/deleteReportedMessages.ts b/apps/meteor/server/lib/moderation/deleteReportedMessages.ts index 204ae90d8c774..d322dd2571652 100644 --- a/apps/meteor/server/lib/moderation/deleteReportedMessages.ts +++ b/apps/meteor/server/lib/moderation/deleteReportedMessages.ts @@ -13,11 +13,10 @@ export async function deleteReportedMessages(messages: IMessage[], user: IUser): const files: string[] = []; const messageIds: string[] = []; for (const message of messages) { - if (message.file) { - files.push(message.file._id); - } if (message.files && message.files.length > 0) { - files.concat(message.files.map((file) => file._id)); + files.push(...message.files.map((file) => file._id)); + } else if (message.file) { + files.push(message.file._id); } messageIds.push(message._id); } diff --git a/apps/meteor/server/settings/file-upload.ts b/apps/meteor/server/settings/file-upload.ts index 90032266651c8..7a397cab15095 100644 --- a/apps/meteor/server/settings/file-upload.ts +++ b/apps/meteor/server/settings/file-upload.ts @@ -13,6 +13,13 @@ export const createFileUploadSettings = () => i18nDescription: 'FileUpload_MaxFileSizeDescription', }); + await this.add('FileUpload_EnableMultipleFilesPerMessage', false, { + type: 'boolean', + public: true, + i18nDescription: 'FileUpload_EnableMultipleFilesPerMessage_Description', + alert: 'FileUpload_EnableMultipleFilesPerMessage_alert', + }); + await this.add('FileUpload_MediaTypeWhiteList', '', { type: 'string', public: true, diff --git a/apps/meteor/tests/e2e/e2e-encryption/e2ee-encryption-decryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption/e2ee-encryption-decryption.spec.ts index e60a2777ff596..e6e7b978daa33 100644 --- a/apps/meteor/tests/e2e/e2e-encryption/e2ee-encryption-decryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption/e2ee-encryption-decryption.spec.ts @@ -96,9 +96,15 @@ test.describe('E2EE Encryption and Decryption - Basic Features', () => { await test.step('upload the file with encryption', async () => { // Upload a file await encryptedRoomPage.dragAndDropTxtFile(); - await fileUploadModal.setName(fileName); - await fileUploadModal.setDescription(fileDescription); - await fileUploadModal.send(); + + // Update file name and send + await expect(async () => { + await encryptedRoomPage.composer.getFileByName('any_file.txt').click(); + await fileUploadModal.setName(fileName); + await fileUploadModal.update(); + await expect(encryptedRoomPage.composer.getFileByName(fileName)).toBeVisible(); + await encryptedRoomPage.sendMessage(fileDescription); + }).toPass(); // Check the file upload await expect(encryptedRoomPage.lastMessage.encryptedIcon).toBeVisible(); @@ -113,9 +119,15 @@ test.describe('E2EE Encryption and Decryption - Basic Features', () => { await test.step('upload the file without encryption', async () => { await encryptedRoomPage.dragAndDropTxtFile(); - await fileUploadModal.setName(fileName); - await fileUploadModal.setDescription(fileDescription); - await fileUploadModal.send(); + + // Update file name and send + await expect(async () => { + await encryptedRoomPage.composer.getFileByName('any_file.txt').click(); + await fileUploadModal.setName(fileName); + await fileUploadModal.update(); + await expect(encryptedRoomPage.composer.getFileByName(fileName)).toBeVisible(); + await encryptedRoomPage.sendMessage(fileDescription); + }).toPass(); await expect(encryptedRoomPage.lastMessage.encryptedIcon).not.toBeVisible(); await expect(encryptedRoomPage.lastMessage.fileUploadName).toContainText(fileName); diff --git a/apps/meteor/tests/e2e/e2e-encryption/e2ee-file-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption/e2ee-file-encryption.spec.ts index f32a7bd028e18..f36d6c15106ab 100644 --- a/apps/meteor/tests/e2e/e2e-encryption/e2ee-file-encryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption/e2ee-file-encryption.spec.ts @@ -2,6 +2,7 @@ import { faker } from '@faker-js/faker'; import { Users } from '../fixtures/userStates'; import { HomeChannel } from '../page-objects'; +import { setSettingValueById } from '../utils'; import { preserveSettings } from '../utils/preserveSettings'; import { test, expect } from '../utils/test'; @@ -21,6 +22,7 @@ test.describe('E2EE File Encryption', () => { test.use({ storageState: Users.userE2EE.state }); test.beforeAll(async ({ api }) => { + await setSettingValueById(api, 'FileUpload_MaxFilesPerMessage', 10); await api.post('/settings/E2E_Enable', { value: true }); await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true }); await api.post('/settings/E2E_Enabled_Default_DirectRooms', { value: false }); @@ -31,6 +33,7 @@ test.describe('E2EE File Encryption', () => { test.afterAll(async ({ api }) => { await api.post('/settings/FileUpload_MediaTypeWhiteList', { value: '' }); await api.post('/settings/FileUpload_MediaTypeBlackList', { value: 'image/svg+xml' }); + await setSettingValueById(api, 'FileUpload_MaxFilesPerMessage', 1); }); test.beforeEach(async ({ page }) => { @@ -50,14 +53,16 @@ test.describe('E2EE File Encryption', () => { }); await test.step('send a file in channel', async () => { + const updatedFileName = 'any_file1.txt'; await poHomeChannel.content.dragAndDropTxtFile(); - await poHomeChannel.content.descriptionInput.fill('any_description'); - await poHomeChannel.content.fileNameInput.fill('any_file1.txt'); - await poHomeChannel.content.btnModalConfirm.click(); + await poHomeChannel.composer.getFileByName('any_file.txt').click(); + await poHomeChannel.content.inputFileUploadName.fill(updatedFileName); + await poHomeChannel.content.btnUpdateFileUpload.click(); + await poHomeChannel.content.sendMessage('any_description'); await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); await expect(poHomeChannel.content.getFileDescription).toHaveText('any_description'); - await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file1.txt'); + await expect(poHomeChannel.content.lastMessageFileName).toContainText(updatedFileName); }); await test.step('edit the description', async () => { @@ -85,29 +90,27 @@ test.describe('E2EE File Encryption', () => { }); await test.step('send a text file in channel', async () => { + const updatedFileName = 'any_file1.txt'; await poHomeChannel.content.dragAndDropTxtFile(); - await poHomeChannel.content.descriptionInput.fill('message 1'); - await poHomeChannel.content.fileNameInput.fill('any_file1.txt'); - await poHomeChannel.content.btnModalConfirm.click(); + await poHomeChannel.composer.getFileByName('any_file.txt').click(); + await poHomeChannel.content.inputFileUploadName.fill(updatedFileName); + await poHomeChannel.content.btnUpdateFileUpload.click(); + await poHomeChannel.composer.btnSend.click(); await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); - await expect(poHomeChannel.content.getFileDescription).toHaveText('message 1'); - await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file1.txt'); + await expect(poHomeChannel.content.getFileDescription).not.toBeVisible(); + await expect(poHomeChannel.content.lastMessageFileName).toContainText(updatedFileName); }); await test.step('set whitelisted media type setting', async () => { await api.post('/settings/FileUpload_MediaTypeWhiteList', { value: 'text/plain' }); }); - await test.step('send text file again with whitelist setting set', async () => { + await test.step('send text file again with blacklisted setting set, file upload should fail', async () => { await poHomeChannel.content.dragAndDropTxtFile(); - await poHomeChannel.content.descriptionInput.fill('message 2'); - await poHomeChannel.content.fileNameInput.fill('any_file2.txt'); - await poHomeChannel.content.btnModalConfirm.click(); - + await expect(poHomeChannel.composer.getFileByName('any_file.txt')).toHaveAttribute('readonly'); await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); - await expect(poHomeChannel.content.getFileDescription).toHaveText('message 2'); - await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file2.txt'); + await poHomeChannel.composer.removeFileByName('any_file.txt'); }); await test.step('set blacklisted media type setting to not accept application/octet-stream media type', async () => { @@ -115,14 +118,12 @@ test.describe('E2EE File Encryption', () => { }); await test.step('send text file again with blacklisted setting set, file upload should fail', async () => { + const composerFilesLocator = poHomeChannel.composer.getFileByName('any_file.txt'); + const composerFiles = await composerFilesLocator.all(); await poHomeChannel.content.dragAndDropTxtFile(); - await poHomeChannel.content.descriptionInput.fill('message 3'); - await poHomeChannel.content.fileNameInput.fill('any_file3.txt'); - await poHomeChannel.content.btnModalConfirm.click(); + await Promise.all(composerFiles.map((file) => expect(file).toHaveAttribute('readonly'))); await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); - await expect(poHomeChannel.content.getFileDescription).toHaveText('message 2'); - await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file2.txt'); }); }); @@ -155,15 +156,11 @@ test.describe('E2EE File Encryption', () => { await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); }); - await test.step('send a text file in channel, file should not be encrypted', async () => { + await test.step('should not attach files to the composer', async () => { await poHomeChannel.content.dragAndDropTxtFile(); - await poHomeChannel.content.descriptionInput.fill('any_description'); - await poHomeChannel.content.fileNameInput.fill('any_file1.txt'); - await poHomeChannel.content.btnModalConfirm.click(); - await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).not.toBeVisible(); - await expect(poHomeChannel.content.getFileDescription).toHaveText('any_description'); - await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file1.txt'); + await expect(poHomeChannel.composer.getFileByName('any_file.txt')).not.toBeVisible(); + await expect(poHomeChannel.composer.btnSend).toBeDisabled(); }); }); }); diff --git a/apps/meteor/tests/e2e/file-upload.spec.ts b/apps/meteor/tests/e2e/file-upload.spec.ts index 7603332fb0d7a..a1fd376bae04d 100644 --- a/apps/meteor/tests/e2e/file-upload.spec.ts +++ b/apps/meteor/tests/e2e/file-upload.spec.ts @@ -12,6 +12,7 @@ test.describe.serial('file-upload', () => { test.beforeAll(async ({ api }) => { await setSettingValueById(api, 'FileUpload_MediaTypeBlackList', 'image/svg+xml'); + await setSettingValueById(api, 'FileUpload_MaxFilesPerMessage', 10); targetChannel = await createTargetChannel(api, { members: ['user1'] }); }); @@ -24,58 +25,228 @@ test.describe.serial('file-upload', () => { test.afterAll(async ({ api }) => { await setSettingValueById(api, 'FileUpload_MediaTypeBlackList', 'image/svg+xml'); + await setSettingValueById(api, 'FileUpload_MaxFilesPerMessage', 1); expect((await api.post('/channels.delete', { roomName: targetChannel })).status()).toBe(200); }); test('should successfully cancel upload', async () => { + const fileName = 'any_file.txt'; await poHomeChannel.content.dragAndDropTxtFile(); - await poHomeChannel.content.btnModalCancel.click(); - await expect(poHomeChannel.content.modalFilePreview).not.toBeVisible(); + await poHomeChannel.composer.removeFileByName(fileName); + + await expect(poHomeChannel.composer.getFileByName(fileName)).not.toBeVisible(); }); test('should not display modal when clicking in send file', async () => { await poHomeChannel.content.dragAndDropTxtFile(); - await poHomeChannel.content.btnModalConfirm.click(); - await expect(poHomeChannel.content.modalFilePreview).not.toBeVisible(); + await poHomeChannel.composer.getFileByName('any_file.txt').click(); + await poHomeChannel.content.btnCancelUpdateFileUpload.click(); + await expect(poHomeChannel.content.fileUploadModal).not.toBeVisible(); }); - test('should send file with name/description updated', async () => { - await poHomeChannel.content.dragAndDropTxtFile(); - await expect(poHomeChannel.content.descriptionInput).toBeFocused(); + test('should send file with name updated', async () => { + const updatedFileName = 'any_file1.txt'; + await poHomeChannel.content.sendFileMessage('any_file.txt'); + + await test.step('update file name and send', async () => { + await poHomeChannel.composer.getFileByName('any_file.txt').click(); + await poHomeChannel.content.inputFileUploadName.fill(updatedFileName); + await poHomeChannel.content.btnUpdateFileUpload.click(); - await poHomeChannel.content.descriptionInput.fill('any_description'); - await poHomeChannel.content.fileNameInput.fill('any_file1.txt'); - await poHomeChannel.content.btnModalConfirm.click(); + expect(poHomeChannel.composer.getFileByName(updatedFileName)); + await poHomeChannel.composer.btnSend.click(); + }); - await expect(poHomeChannel.content.getFileDescription).toHaveText('any_description'); - await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file1.txt'); + await expect(poHomeChannel.content.getFileDescription).not.toBeVisible(); + await expect(poHomeChannel.content.lastMessageFileName).toContainText(updatedFileName); }); test('should send lst file successfully', async () => { await poHomeChannel.content.dragAndDropLstFile(); - await poHomeChannel.content.descriptionInput.fill('lst_description'); - await poHomeChannel.content.btnModalConfirm.click(); + await poHomeChannel.composer.btnSend.click(); - await expect(poHomeChannel.content.getFileDescription).toHaveText('lst_description'); + await expect(poHomeChannel.content.getFileDescription).not.toBeVisible(); await expect(poHomeChannel.content.lastMessageFileName).toContainText('lst-test.lst'); }); test('should send drawio (unknown media type) file successfully', async ({ page }) => { + const fileName = 'diagram.drawio'; await page.reload(); - await poHomeChannel.content.sendFileMessage('diagram.drawio'); - await poHomeChannel.content.descriptionInput.fill('drawio_description'); - await poHomeChannel.content.btnModalConfirm.click(); + await poHomeChannel.content.sendFileMessage(fileName); + await poHomeChannel.composer.btnSend.click(); - await expect(poHomeChannel.content.getFileDescription).toHaveText('drawio_description'); - await expect(poHomeChannel.content.lastMessageFileName).toContainText('diagram.drawio'); + await expect(poHomeChannel.content.getFileDescription).not.toBeVisible(); + await expect(poHomeChannel.content.lastMessageFileName).toContainText(fileName); }); test('should not to send drawio file (unknown media type) when the default media type is blocked', async ({ api, page }) => { + const fileName = 'diagram.drawio'; await setSettingValueById(api, 'FileUpload_MediaTypeBlackList', 'application/octet-stream'); await page.reload(); - await poHomeChannel.content.sendFileMessage('diagram.drawio'); - await expect(poHomeChannel.content.btnModalConfirm).not.toBeVisible(); + await poHomeChannel.content.sendFileMessage(fileName, { waitForResponse: false }); + + await expect(poHomeChannel.composer.getFileByName(fileName)).toHaveAttribute('readonly'); + }); + + test.describe.serial('multiple file upload', () => { + test('should send multiple files successfully', async () => { + const file1 = 'any_file.txt'; + const file2 = 'lst-test.lst'; + + await poHomeChannel.content.sendFileMessage(file1); + await poHomeChannel.content.sendFileMessage(file2); + + await poHomeChannel.composer.btnSend.click(); + + await expect(poHomeChannel.content.getFileDescription).not.toBeVisible(); + await expect(poHomeChannel.content.lastUserMessage).toContainText(file1); + await expect(poHomeChannel.content.lastUserMessage).toContainText(file2); + }); + + test('should be able to remove file from composer before sending', async () => { + const file1 = 'any_file.txt'; + const file2 = 'lst-test.lst'; + + await poHomeChannel.content.sendFileMessage(file1); + await poHomeChannel.content.sendFileMessage(file2); + + await poHomeChannel.composer.removeFileByName(file1); + + await expect(poHomeChannel.composer.getFileByName(file1)).not.toBeVisible(); + await expect(poHomeChannel.composer.getFileByName(file2)).toBeVisible(); + + await poHomeChannel.composer.btnSend.click(); + + await expect(poHomeChannel.content.lastUserMessage).not.toContainText(file1); + await expect(poHomeChannel.content.lastUserMessage).toContainText(file2); + }); + + test('should send multiple files with text message successfully', async () => { + const file1 = 'any_file.txt'; + const file2 = 'lst-test.lst'; + const message = 'Here are two files'; + + await poHomeChannel.content.sendFileMessage(file1); + await poHomeChannel.content.sendFileMessage(file2); + await poHomeChannel.composer.inputMessage.fill(message); + + await poHomeChannel.composer.btnSend.click(); + + await expect(poHomeChannel.content.lastUserMessage).toContainText(message); + await expect(poHomeChannel.content.lastUserMessage).toContainText(file1); + await expect(poHomeChannel.content.lastUserMessage).toContainText(file2); + }); + + test('should respect the maximum number of files allowed per message: 10', async () => { + const file11 = 'number6.png'; + const files = new Array(10).fill('number1.png'); + + await Promise.all(files.map((file) => poHomeChannel.content.sendFileMessage(file))); + await poHomeChannel.content.dragAndDropTxtFile(); + + await expect(poHomeChannel.composer.getFilesInComposer()).toHaveCount(10); + await expect(poHomeChannel.composer.getFileByName(file11)).not.toBeVisible(); + }); + }); + + test.describe.serial('thread multifile upload', () => { + test('should be able to remove file from thread composer before sending', async () => { + await poHomeChannel.content.sendMessage('this is a message for thread reply'); + await poHomeChannel.content.openReplyInThread(); + await poHomeChannel.content.sendFileMessageToThread('any_file.txt'); + await poHomeChannel.content.sendFileMessageToThread('another_file.txt'); + + await poHomeChannel.threadComposer.removeFileByName('another_file.txt'); + + await expect(poHomeChannel.threadComposer.getFileByName('any_file.txt')).toBeVisible(); + await expect(poHomeChannel.threadComposer.getFileByName('another_file.txt')).not.toBeVisible(); + }); + + test('should send multiple files in a thread successfully', async () => { + const message = 'Here are two files in thread'; + await poHomeChannel.content.openReplyInThread(); + await poHomeChannel.content.sendFileMessageToThread('any_file.txt'); + await poHomeChannel.content.sendFileMessageToThread('another_file.txt'); + + await poHomeChannel.threadComposer.inputMessage.fill(message); + await poHomeChannel.threadComposer.btnSend.click(); + + await expect(poHomeChannel.content.lastThreadMessageText).toContainText(message); + await expect(poHomeChannel.content.lastThreadMessageText.getByRole('link').getByText('another_file.txt')).toBeVisible(); + await expect(poHomeChannel.content.lastThreadMessageText.getByRole('link').getByText('any_file.txt')).toBeVisible(); + }); + }); + + test.describe.serial('file upload fails', () => { + test.beforeAll(async ({ api }) => { + await setSettingValueById(api, 'FileUpload_MediaTypeBlackList', 'application/octet-stream'); + }); + + test.afterAll(async ({ api }) => { + await setSettingValueById(api, 'FileUpload_MediaTypeBlackList', 'image/svg+xml'); + }); + + test('should open warning modal when all file uploads fail', async () => { + const invalidFile1 = 'empty_file.txt'; + const invalidFile2 = 'diagram.drawio'; + + await poHomeChannel.content.sendFileMessage(invalidFile1, { waitForResponse: false }); + await poHomeChannel.content.sendFileMessage(invalidFile2, { waitForResponse: false }); + + await expect(poHomeChannel.composer.getFileByName(invalidFile1)).toHaveAttribute('readonly'); + await expect(poHomeChannel.composer.getFileByName(invalidFile2)).toHaveAttribute('readonly'); + + await poHomeChannel.composer.btnSend.click(); + const warningModal = poHomeChannel.page.getByRole('dialog', { name: 'Warning' }); + await expect(warningModal).toBeVisible(); + await expect(warningModal).toContainText('2 files failed to upload'); + await expect(warningModal.getByRole('button', { name: 'Ok' })).toBeVisible(); + await expect(warningModal.getByRole('button', { name: 'Send anyway' })).not.toBeVisible(); + }); + + test('should handle multiple files with one failing upload', async () => { + const validFile = 'any_file.txt'; + const invalidFile = 'empty_file.txt'; + + await test.step('should only mark as "Upload failed" the specific file that failed to upload', async () => { + await poHomeChannel.content.sendFileMessage(validFile, { waitForResponse: false }); + await poHomeChannel.content.sendFileMessage(invalidFile, { waitForResponse: false }); + + await expect(poHomeChannel.composer.getFileByName(validFile)).not.toHaveAttribute('readonly'); + await expect(poHomeChannel.composer.getFileByName(invalidFile)).toHaveAttribute('readonly'); + }); + + await test.step('should open warning modal', async () => { + await poHomeChannel.composer.btnSend.click(); + + const warningModal = poHomeChannel.page.getByRole('dialog', { name: 'Are you sure' }); + await expect(warningModal).toBeVisible(); + await expect(warningModal).toContainText('One file failed to upload'); + }); + + await test.step('should close modal when clicking "Cancel" button', async () => { + const warningModal = poHomeChannel.page.getByRole('dialog', { name: 'Are you sure' }); + await warningModal.getByRole('button', { name: 'Cancel' }).click(); + + await expect(warningModal).not.toBeVisible(); + await expect(poHomeChannel.composer.getFileByName(invalidFile)).toBeVisible(); + await expect(poHomeChannel.composer.getFileByName(validFile)).toBeVisible(); + }); + + await test.step('should send message with the valid file when confirming "Send anyway"', async () => { + await poHomeChannel.composer.btnSend.click(); + + const warningModal = poHomeChannel.page.getByRole('dialog', { name: 'Are you sure' }); + + await warningModal.getByRole('button', { name: 'Send anyway' }).click(); + + await expect(warningModal).not.toBeVisible(); + await expect(poHomeChannel.composer.getFileByName(validFile)).not.toBeVisible(); + await expect(poHomeChannel.content.lastMessageFileName).toContainText(validFile); + await expect(poHomeChannel.composer.getFileByName(invalidFile)).not.toBeVisible(); + }); + }); }); }); @@ -100,6 +271,7 @@ test.describe('file-upload-not-member', () => { test('should not be able to upload if not a member', async () => { await poHomeChannel.content.dragAndDropTxtFile(); - await expect(poHomeChannel.content.modalFilePreview).not.toBeVisible(); + + await expect(poHomeChannel.composer.getFileByName('any_file.txt')).not.toBeVisible(); }); }); diff --git a/apps/meteor/tests/e2e/fixtures/files/another_file.txt b/apps/meteor/tests/e2e/fixtures/files/another_file.txt new file mode 100644 index 0000000000000..ad7bbe091558e --- /dev/null +++ b/apps/meteor/tests/e2e/fixtures/files/another_file.txt @@ -0,0 +1 @@ +another_file diff --git a/apps/meteor/tests/e2e/fixtures/files/empty_file.txt b/apps/meteor/tests/e2e/fixtures/files/empty_file.txt new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/apps/meteor/tests/e2e/fixtures/responses/mediaResponse.ts b/apps/meteor/tests/e2e/fixtures/responses/mediaResponse.ts new file mode 100644 index 0000000000000..908ba441d65b1 --- /dev/null +++ b/apps/meteor/tests/e2e/fixtures/responses/mediaResponse.ts @@ -0,0 +1,14 @@ +import type { Page, Response } from '@playwright/test'; + +const isMediaResponse = (response: Response) => /api\/v1\/rooms.media/.test(response.url()) && response.request().method() === 'POST'; + +export default async function waitForMediaResponse(page: Page) { + let responsePromise; + try { + responsePromise = page.waitForResponse((response) => isMediaResponse(response)); + } catch (error) { + console.error(error); + } + + return responsePromise; +} diff --git a/apps/meteor/tests/e2e/image-gallery.spec.ts b/apps/meteor/tests/e2e/image-gallery.spec.ts index 34a84cab458e1..3f888359336ae 100644 --- a/apps/meteor/tests/e2e/image-gallery.spec.ts +++ b/apps/meteor/tests/e2e/image-gallery.spec.ts @@ -38,17 +38,19 @@ test.describe.serial('Image Gallery', async () => { test.describe('When sending an image as a file', () => { test.beforeAll(async () => { + const largeFileName = 'test-large-image.jpeg'; + await poHomeChannel.navbar.openChat(targetChannel); for await (const imageName of imageNames) { await poHomeChannel.content.sendFileMessage(imageName); - await poHomeChannel.content.btnModalConfirm.click(); + await poHomeChannel.composer.btnSend.click(); await expect(poHomeChannel.content.lastUserMessage).toContainText(imageName); } await poHomeChannel.navbar.openChat(targetChannelLargeImage); - await poHomeChannel.content.sendFileMessage('test-large-image.jpeg'); - await poHomeChannel.content.btnModalConfirm.click(); - await expect(poHomeChannel.content.lastUserMessage).toContainText('test-large-image.jpeg'); + await poHomeChannel.content.sendFileMessage(largeFileName); + await poHomeChannel.composer.btnSend.click(); + await expect(poHomeChannel.content.lastUserMessage).toContainText(largeFileName); await poHomeChannel.content.lastUserMessage.locator('img.gallery-item').click(); }); diff --git a/apps/meteor/tests/e2e/image-upload.spec.ts b/apps/meteor/tests/e2e/image-upload.spec.ts index 509f9630225ab..f6d7d4f6e9ee1 100644 --- a/apps/meteor/tests/e2e/image-upload.spec.ts +++ b/apps/meteor/tests/e2e/image-upload.spec.ts @@ -36,11 +36,8 @@ test.describe('image-upload', () => { test('should show error indicator when upload fails', async () => { await poHomeChannel.content.sendFileMessage('bad-orientation.jpeg'); - await poHomeChannel.content.fileNameInput.fill('bad-orientation.jpeg'); - await poHomeChannel.content.descriptionInput.fill('bad-orientation_description'); - await poHomeChannel.content.btnModalConfirm.click(); - await expect(poHomeChannel.statusUploadIndicator).toContainText('Error:'); + await expect(poHomeChannel.composer.getFileByName('bad-orientation')).toHaveAttribute('readonly'); }); }); @@ -52,12 +49,10 @@ test.describe('image-upload', () => { }); test('should succeed upload of bad-orientation.jpeg', async () => { - await poHomeChannel.content.sendFileMessage('bad-orientation.jpeg'); - await poHomeChannel.content.fileNameInput.fill('bad-orientation.jpeg'); - await poHomeChannel.content.descriptionInput.fill('bad-orientation_description'); - await poHomeChannel.content.btnModalConfirm.click(); - - await expect(poHomeChannel.content.getFileDescription).toHaveText('bad-orientation_description'); + const imgName = 'bad-orientation.jpeg'; + await poHomeChannel.content.sendFileMessage(imgName); + await poHomeChannel.composer.btnSend.click(); + await expect(poHomeChannel.content.lastUserMessage).toContainText(imgName); }); }); }); diff --git a/apps/meteor/tests/e2e/message-actions.spec.ts b/apps/meteor/tests/e2e/message-actions.spec.ts index 5e043d198d37e..16a5769f3a0bc 100644 --- a/apps/meteor/tests/e2e/message-actions.spec.ts +++ b/apps/meteor/tests/e2e/message-actions.spec.ts @@ -240,7 +240,7 @@ test.describe.serial('message-actions', () => { test('expect forward text file to channel', async () => { const filename = 'any_file.txt'; await poHomeChannel.content.sendFileMessage(filename); - await poHomeChannel.content.btnModalConfirm.click(); + await poHomeChannel.composer.btnSend.click(); await expect(poHomeChannel.content.lastUserMessage).toContainText(filename); await poHomeChannel.content.forwardMessage(forwardChannel); @@ -252,7 +252,7 @@ test.describe.serial('message-actions', () => { test('expect forward image file to channel', async () => { const filename = 'test-image.jpeg'; await poHomeChannel.content.sendFileMessage(filename); - await poHomeChannel.content.btnModalConfirm.click(); + await poHomeChannel.composer.btnSend.click(); await expect(poHomeChannel.content.lastUserMessage).toContainText(filename); await poHomeChannel.content.forwardMessage(forwardChannel); @@ -264,7 +264,7 @@ test.describe.serial('message-actions', () => { test('expect forward pdf file to channel', async () => { const filename = 'test_pdf_file.pdf'; await poHomeChannel.content.sendFileMessage(filename); - await poHomeChannel.content.btnModalConfirm.click(); + await poHomeChannel.composer.btnSend.click(); await expect(poHomeChannel.content.lastUserMessage).toContainText(filename); await poHomeChannel.content.forwardMessage(forwardChannel); @@ -276,7 +276,7 @@ test.describe.serial('message-actions', () => { test('expect forward audio message to channel', async () => { const filename = 'sample-audio.mp3'; await poHomeChannel.content.sendFileMessage(filename); - await poHomeChannel.content.btnModalConfirm.click(); + await poHomeChannel.composer.btnSend.click(); await expect(poHomeChannel.content.lastUserMessage).toContainText(filename); await poHomeChannel.content.forwardMessage(forwardChannel); @@ -288,7 +288,7 @@ test.describe.serial('message-actions', () => { test('expect forward video message to channel', async () => { const filename = 'test_video.mp4'; await poHomeChannel.content.sendFileMessage(filename); - await poHomeChannel.content.btnModalConfirm.click(); + await poHomeChannel.composer.btnSend.click(); await expect(poHomeChannel.content.lastUserMessage).toContainText(filename); await poHomeChannel.content.forwardMessage(forwardChannel); diff --git a/apps/meteor/tests/e2e/message-composer.spec.ts b/apps/meteor/tests/e2e/message-composer.spec.ts index 14d700e5f21d0..72f06ab6af9d0 100644 --- a/apps/meteor/tests/e2e/message-composer.spec.ts +++ b/apps/meteor/tests/e2e/message-composer.spec.ts @@ -133,14 +133,14 @@ test.describe.serial('message-composer', () => { await expect(poHomeChannel.audioRecorder).not.toBeVisible(); }); - test('should open file modal when clicking on "Finish recording"', async ({ page }) => { + test('should attach file to the composer when clicking on "Finish recording"', async ({ page }) => { await poHomeChannel.navbar.openChat(targetChannel); await poHomeChannel.composer.btnAudioMessage.click(); await expect(poHomeChannel.audioRecorder).toBeVisible(); await page.waitForTimeout(1000); await poHomeChannel.audioRecorder.getByRole('button', { name: 'Finish Recording', exact: true }).click(); - await expect(poHomeChannel.content.fileUploadModal).toBeVisible(); + await expect(poHomeChannel.composer.getFileByName('Audio record.mp3')).toBeVisible(); }); }); }); diff --git a/apps/meteor/tests/e2e/page-objects/fragments/composer.ts b/apps/meteor/tests/e2e/page-objects/fragments/composer.ts index 457ffd72fabdf..c63b8b220562e 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/composer.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/composer.ts @@ -29,6 +29,18 @@ export abstract class Composer { return this.toolbarPrimaryActions.getByRole('button', { name: 'Audio message' }); } + getFileByName(fileName: string): Locator { + return this.root.getByRole('button', { name: fileName }); + } + + getFilesInComposer(): Locator { + return this.root.getByRole('group', { name: 'Uploads' }).getByRole('button', { name: /^(?!Close$)/ }); + } + + async removeFileByName(fileName: string): Promise { + return this.getFileByName(fileName).getByRole('button', { name: 'Close' }).click(); + } + get btnSend(): Locator { return this.root.getByRole('button', { name: 'Send' }); } diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts index eba1a2f986a26..11ac178c24023 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts @@ -4,6 +4,7 @@ import { resolve, join, relative } from 'node:path'; import type { Locator, Page } from '@playwright/test'; import { RoomComposer, ThreadComposer } from './composer'; +import waitForMediaResponse from '../../fixtures/responses/mediaResponse'; import { expect } from '../../utils/test'; const FIXTURES_PATH = relative(process.cwd(), resolve(__dirname, '../../fixtures/files')); @@ -15,9 +16,9 @@ export function getFilePath(fileName: string): string { export class HomeContent { protected readonly page: Page; - protected readonly composer: RoomComposer; + readonly composer: RoomComposer; - protected readonly threadComposer: ThreadComposer; + readonly threadComposer: ThreadComposer; constructor(page: Page) { this.page = page; @@ -150,12 +151,6 @@ export class HomeContent { return this.createDiscussionModal.getByRole('button', { name: 'Create' }); } - get modalFilePreview(): Locator { - return this.page.locator( - '//div[@id="modal-root"]//header//following-sibling::div[1]//div//div//img | //div[@id="modal-root"]//header//following-sibling::div[1]//div//div//div//i', - ); - } - get btnModalConfirm(): Locator { return this.page.locator('#modal-root .rcx-button-group--align-end .rcx-button--primary'); } @@ -168,16 +163,20 @@ export class HomeContent { return this.page.getByRole('button', { name: 'Dismiss quoted message' }); } - get descriptionInput(): Locator { - return this.page.locator('//div[@id="modal-root"]//fieldset//div[2]//span//input'); - } - get getFileDescription(): Locator { return this.page.locator('[data-qa-type="message"]:last-child [data-qa-type="message-body"]'); } - get fileNameInput(): Locator { - return this.page.locator('//div[@id="modal-root"]//fieldset//div[1]//span//input'); + get inputFileUploadName(): Locator { + return this.fileUploadModal.getByRole('textbox', { name: 'File name' }); + } + + get btnUpdateFileUpload(): Locator { + return this.fileUploadModal.getByRole('button', { name: 'Update' }); + } + + get btnCancelUpdateFileUpload(): Locator { + return this.fileUploadModal.getByRole('button', { name: 'Cancel' }); } // ----------------------------------------- @@ -351,7 +350,7 @@ export class HomeContent { await this.page.locator('[role=dialog][data-qa="DropTargetOverlay"]').dispatchEvent('drop', { dataTransfer }); } - async dragAndDropLstFile(): Promise { + async dragAndDropLstFile({ waitForLoad = true }: { waitForLoad?: boolean } = {}): Promise { const contract = await fs.readFile(getFilePath('lst-test.lst'), 'utf-8'); const dataTransfer = await this.page.evaluateHandle((contract) => { const data = new DataTransfer(); @@ -365,9 +364,12 @@ export class HomeContent { await this.composer.inputMessage.dispatchEvent('dragenter', { dataTransfer }); await this.page.locator('[role=dialog][data-qa="DropTargetOverlay"]').dispatchEvent('drop', { dataTransfer }); + if (waitForLoad) { + await waitForMediaResponse(this.page); + } } - async dragAndDropTxtFileToThread(): Promise { + async dragAndDropTxtFileToThread({ waitForResponse = true } = {}): Promise { const contract = await fs.readFile(getFilePath('any_file.txt'), 'utf-8'); const dataTransfer = await this.page.evaluateHandle((contract) => { const data = new DataTransfer(); @@ -381,10 +383,25 @@ export class HomeContent { await this.threadComposer.inputMessage.dispatchEvent('dragenter', { dataTransfer }); await this.page.locator('[role=dialog][data-qa="DropTargetOverlay"]').dispatchEvent('drop', { dataTransfer }); + + if (waitForResponse) { + await waitForMediaResponse(this.page); + } } - async sendFileMessage(fileName: string): Promise { - await this.page.locator('input[type=file]').setInputFiles(getFilePath(fileName)); + async sendFileMessage(fileName: string, { waitForResponse = true } = {}): Promise { + await this.page.getByLabel('Room composer').locator('input[type=file]').setInputFiles(getFilePath(fileName)); + if (waitForResponse) { + await waitForMediaResponse(this.page); + } + } + + async sendFileMessageToThread(fileName: string, { waitForResponse = true } = {}): Promise { + await this.threadComposer.inputMessage.click(); + await this.page.getByLabel('Thread composer').locator('input[type=file]').setInputFiles(getFilePath(fileName)); + if (waitForResponse) { + await waitForMediaResponse(this.page); + } } async openLastMessageMenu(): Promise { diff --git a/apps/meteor/tests/e2e/page-objects/fragments/modals/file-upload-modal.ts b/apps/meteor/tests/e2e/page-objects/fragments/modals/file-upload-modal.ts index 92f2915827efa..4ee33ebc8307f 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/modals/file-upload-modal.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/modals/file-upload-modal.ts @@ -15,8 +15,8 @@ export class FileUploadModal extends Modal { return this.root.getByRole('textbox', { name: 'File description' }); } - private get sendButton() { - return this.root.getByRole('button', { name: 'Send' }); + private get updateButton() { + return this.root.getByRole('button', { name: 'Update' }); } setName(fileName: string) { @@ -27,8 +27,8 @@ export class FileUploadModal extends Modal { return this.fileDescriptionInput.fill(description); } - async send() { - await this.sendButton.click(); + async update() { + await this.updateButton.click(); await this.waitForDismissal(); } } diff --git a/apps/meteor/tests/e2e/prune-messages.spec.ts b/apps/meteor/tests/e2e/prune-messages.spec.ts index d56856bfff51c..59082a321349b 100644 --- a/apps/meteor/tests/e2e/prune-messages.spec.ts +++ b/apps/meteor/tests/e2e/prune-messages.spec.ts @@ -42,8 +42,7 @@ test.describe('prune-messages', () => { } = poHomeChannel; await content.sendFileMessage('any_file.txt'); - await content.descriptionInput.fill('a message with a file'); - await content.btnModalConfirm.click(); + await poHomeChannel.composer.btnSend.click(); await expect(content.lastMessageFileName).toHaveText('any_file.txt'); await sendTargetChannelMessage(api, targetChannel.fname as string, { @@ -110,8 +109,7 @@ test.describe('prune-messages', () => { } = poHomeChannel; await content.sendFileMessage('any_file.txt'); - await content.descriptionInput.fill('a message with a file'); - await content.btnModalConfirm.click(); + await poHomeChannel.composer.btnSend.click(); await expect(content.lastMessageFileName).toHaveText('any_file.txt'); await test.step('prune files only', async () => { @@ -145,8 +143,7 @@ test.describe('prune-messages', () => { const { content } = poHomeChannel; await content.sendFileMessage('any_file.txt'); - await content.descriptionInput.fill('a message with a file'); - await content.btnModalConfirm.click(); + await poHomeChannel.composer.btnSend.click(); await expect(content.lastMessageFileName).toHaveText('any_file.txt'); await content.lastUserMessage.hover(); diff --git a/apps/meteor/tests/e2e/quote-attachment.spec.ts b/apps/meteor/tests/e2e/quote-attachment.spec.ts index ddf505d5600c9..a3cccd3851ead 100644 --- a/apps/meteor/tests/e2e/quote-attachment.spec.ts +++ b/apps/meteor/tests/e2e/quote-attachment.spec.ts @@ -30,9 +30,7 @@ test.describe.parallel('Quote Attachment', () => { const imageFileName = 'test-image.jpeg'; await test.step('Send message with attachment in the channel', async () => { await poHomeChannel.content.sendFileMessage(imageFileName); - await poHomeChannel.content.fileNameInput.fill(imageFileName); - await poHomeChannel.content.descriptionInput.fill(fileDescription); - await poHomeChannel.content.btnModalConfirm.click(); + await poHomeChannel.content.sendMessage(fileDescription); // Wait for the file to be uploaded and message to be sent await expect(poHomeChannel.content.lastUserMessage).toBeVisible(); @@ -58,7 +56,7 @@ test.describe.parallel('Quote Attachment', () => { }); test('should show file preview and description when quoting attachment file within a thread', async ({ page }) => { - const textFileName = 'any_file1.txt'; + const textFileName = 'any_file.txt'; await test.step('Send initial message in channel', async () => { await poHomeChannel.content.sendMessage('Initial message for thread test'); @@ -71,9 +69,7 @@ test.describe.parallel('Quote Attachment', () => { await expect(page).toHaveURL(/.*thread/); await poHomeChannel.content.dragAndDropTxtFileToThread(); - await poHomeChannel.content.descriptionInput.fill(fileDescription); - await poHomeChannel.content.fileNameInput.fill(textFileName); - await poHomeChannel.content.btnModalConfirm.click(); + await poHomeChannel.content.sendMessageInThread(fileDescription); await expect(poHomeChannel.content.lastThreadMessageFileDescription).toHaveText(fileDescription); await expect(poHomeChannel.content.lastThreadMessageFileName).toContainText(textFileName); diff --git a/apps/meteor/tests/e2e/threads.spec.ts b/apps/meteor/tests/e2e/threads.spec.ts index f7ddbdd51a906..de86d3dbd5a26 100644 --- a/apps/meteor/tests/e2e/threads.spec.ts +++ b/apps/meteor/tests/e2e/threads.spec.ts @@ -79,18 +79,20 @@ test.describe.serial('Threads', () => { await expect(poHomeChannel.content.lastThreadMessageText).toContainText('This is a thread message also sent in channel'); }); }); - test('expect upload a file attachment in thread with description', async ({ page }) => { + test('should send a file with name updated in thread', async ({ page }) => { + const updatedFileName = 'any_file1.txt'; await poHomeChannel.content.lastThreadMessagePreviewText.click(); await expect(page).toHaveURL(/.*thread/); await poHomeChannel.content.dragAndDropTxtFileToThread(); - await poHomeChannel.content.descriptionInput.fill('any_description'); - await poHomeChannel.content.fileNameInput.fill('any_file1.txt'); - await poHomeChannel.content.btnModalConfirm.click(); + await poHomeChannel.threadComposer.getFileByName('any_file.txt').click(); + await poHomeChannel.content.inputFileUploadName.fill(updatedFileName); + await poHomeChannel.content.btnUpdateFileUpload.click(); + await poHomeChannel.threadComposer.btnSend.click(); - await expect(poHomeChannel.content.lastThreadMessageFileDescription).toHaveText('any_description'); - await expect(poHomeChannel.content.lastThreadMessageFileName).toContainText('any_file1.txt'); + await expect(poHomeChannel.content.lastThreadMessageFileDescription).not.toBeVisible(); + await expect(poHomeChannel.content.lastThreadMessageFileName).toContainText(updatedFileName); }); test.describe('thread message actions', () => { diff --git a/apps/meteor/tests/end-to-end/api/livechat/20-messages.ts b/apps/meteor/tests/end-to-end/api/livechat/20-messages.ts index a247280422ccb..5bf5426df4b9d 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/20-messages.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/20-messages.ts @@ -1,10 +1,12 @@ import { faker } from '@faker-js/faker'; -import type { ILivechatAgent, ILivechatVisitor, IOmnichannelRoom, IRoom } from '@rocket.chat/core-typings'; +import type { ILivechatAgent, ILivechatVisitor, IOmnichannelRoom, IRoom, SettingValue } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; import { expect } from 'chai'; import { before, describe, it, after } from 'mocha'; -import { api, getCredentials, request } from '../../../data/api-data'; +import { api, credentials, getCredentials, methodCall, request } from '../../../data/api-data'; import { sendSimpleMessage } from '../../../data/chat.helper'; +import { imgURL } from '../../../data/interactions'; import { sendMessage, startANewLivechatRoomAndTakeIt, @@ -15,7 +17,7 @@ import { closeOmnichannelRoom, } from '../../../data/livechat/rooms'; import { removeAgent } from '../../../data/livechat/users'; -import { updateEESetting, updateSetting } from '../../../data/permissions.helper'; +import { getSettingValueById, updateEESetting, updateSetting } from '../../../data/permissions.helper'; import { createRoom, deleteRoom } from '../../../data/rooms.helper'; describe('LIVECHAT - messages', () => { @@ -120,5 +122,74 @@ describe('LIVECHAT - messages', () => { expect(res.body.message._id).to.be.equal(message._id); }); }); + + describe('Multiple files per message', () => { + let originalMaxFilesPerMessageValue: SettingValue; + before(async () => { + originalMaxFilesPerMessageValue = await getSettingValueById('FileUpload_MaxFilesPerMessage'); + await updateSetting('FileUpload_MaxFilesPerMessage', 2); + }); + + after(async () => { + await updateSetting('FileUpload_MaxFilesPerMessage', originalMaxFilesPerMessageValue); + }); + + it('should return filesUpload array when message has files property', async () => { + const { + room: { _id: roomId }, + visitor: { token }, + } = await startANewLivechatRoomAndTakeIt(); + + const file1Response = await request + .post(api(`rooms.media/${roomId}`)) + .set(credentials) + .attach('file', imgURL) + .expect(200); + const file2Response = await request + .post(api(`rooms.media/${roomId}`)) + .set(credentials) + .attach('file', imgURL) + .expect(200); + + const uploadedFileIds = [file1Response.body.file._id, file2Response.body.file._id]; + const filesToConfirm = uploadedFileIds.map((id) => ({ _id: id, name: 'test.png' })); + + // send message with multiple files as agent + const sendMessageResponse = await request + .post(methodCall('sendMessage')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'sendMessage', + params: [{ _id: Random.id(), rid: roomId, msg: 'message with multiple files' }, [], filesToConfirm], + id: 'id', + msg: 'method', + }), + }) + .expect(200); + + const data = JSON.parse(sendMessageResponse.body.message); + const fileMessage = data.result; + + // fetch message as visitor and verify filesUpload + // note: image uploads also create thumbnails, so files.length may be > 2 + await request + .get(api(`livechat/message/${fileMessage._id}`)) + .query({ token, rid: roomId }) + .send() + .expect(200) + .expect((res) => { + const { message } = res.body; + expect(message._id).to.be.equal(fileMessage._id); + expect(message.file).to.be.an('object'); + expect(message.files).to.be.an('array').that.has.lengthOf(4); + expect(message.fileUpload).to.be.an('object'); + expect(message.fileUpload.publicFilePath).to.be.a('string').and.not.empty; + expect(message.fileUpload.type).to.be.a('string'); + expect(message.fileUpload.size).to.be.a('number'); + expect(message.filesUpload).to.be.an('array').with.lengthOf(message.files.length); + }); + }); + }); }); }); diff --git a/apps/meteor/tests/end-to-end/api/methods.ts b/apps/meteor/tests/end-to-end/api/methods.ts index 020840d05f736..df32fb453d178 100644 --- a/apps/meteor/tests/end-to-end/api/methods.ts +++ b/apps/meteor/tests/end-to-end/api/methods.ts @@ -2089,6 +2089,30 @@ describe('Meteor.methods', () => { }) .end(done); }); + + it('should fail when sending more than 10 files', async () => { + const filesToConfirm = Array.from({ length: 11 }, (_, i) => ({ _id: `file${i + 1}`, name: `test${i + 1}.txt` })); + + await request + .post(methodCall('sendMessage')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'sendMessage', + params: [{ _id: Random.id(), rid, msg: 'test message with files' }, [], filesToConfirm], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('error').that.is.an('object'); + expect(data.error).to.have.a.property('error', 'error-too-many-files'); + }); + }); }); describe('[@updateMessage]', () => { diff --git a/apps/meteor/tests/end-to-end/api/moderation.ts b/apps/meteor/tests/end-to-end/api/moderation.ts index 162b4c65c4bc5..c6a94ff8db0a1 100644 --- a/apps/meteor/tests/end-to-end/api/moderation.ts +++ b/apps/meteor/tests/end-to-end/api/moderation.ts @@ -1,9 +1,11 @@ import type { IMessage, IModerationAudit, IModerationReport, IUser } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; import { expect } from 'chai'; import { after, before, describe, it } from 'mocha'; import type { Response } from 'supertest'; -import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { getCredentials, api, request, credentials, methodCall } from '../../data/api-data'; +import { imgURL } from '../../data/interactions'; import { createUser, deleteUser } from '../../data/users.helper'; const makeModerationApiRequest = async ( @@ -767,6 +769,80 @@ describe('[Moderation]', () => { expect(res.body).to.have.property('error').and.to.be.a('string'); }); }); + + describe('with multiple files', () => { + let generalRoomId: string; + let messageWithFiles: IMessage; + let fileUrls: string[]; + + before(async () => { + const channelInfoResponse = await request.get(api('channels.info')).set(credentials).query({ roomName: 'general' }).expect(200); + generalRoomId = channelInfoResponse.body.channel._id; + + const file1Response = await request + .post(api(`rooms.media/${generalRoomId}`)) + .set(credentials) + .attach('file', imgURL) + .expect(200); + + const file2Response = await request + .post(api(`rooms.media/${generalRoomId}`)) + .set(credentials) + .attach('file', imgURL) + .expect(200); + + const uploadedFileIds = [file1Response.body.file._id, file2Response.body.file._id]; + + const filesToConfirm = uploadedFileIds.map((id) => ({ _id: id, name: 'test.png' })); + const sendMessageResponse = await request + .post(methodCall('sendMessage')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'sendMessage', + params: [{ _id: Random.id(), rid: generalRoomId, msg: 'message with multiple files' }, [], filesToConfirm], + id: 'id', + msg: 'method', + }), + }) + .expect(200); + + const data = JSON.parse(sendMessageResponse.body.message); + messageWithFiles = data.result; + + fileUrls = + messageWithFiles.files?.map((f: { _id: string; name?: string }) => `/file-upload/${f._id}/${encodeURIComponent(f.name || '')}`) ?? + []; + + await request + .post(api('chat.reportMessage')) + .set(credentials) + .send({ + messageId: messageWithFiles._id, + description: 'test report for multiple files', + }) + .expect(200); + }); + + it('should delete reported messages and all associated files', async () => { + expect(fileUrls.length).to.be.greaterThan(1, 'Test requires multiple files'); + + await request + .post(api('moderation.user.deleteReportedMessages')) + .set(credentials) + .send({ + userId: messageWithFiles.u._id, + }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + for await (const fileUrl of fileUrls) { + await request.get(fileUrl).set(credentials).expect(404); + } + }); + }); }); describe('[/moderation.reportUser]', () => { diff --git a/packages/core-typings/src/IUpload.ts b/packages/core-typings/src/IUpload.ts index 660a498badb74..ee8f11c511b42 100644 --- a/packages/core-typings/src/IUpload.ts +++ b/packages/core-typings/src/IUpload.ts @@ -74,4 +74,6 @@ export interface IE2EEUpload extends IUpload { content: EncryptedContent; } +export type IUploadToConfirm = Pick; + export const isE2EEUpload = (upload: IUpload): upload is IE2EEUpload => Boolean(upload?.content?.ciphertext && upload?.content?.algorithm); diff --git a/packages/i18n/src/locales/af.i18n.json b/packages/i18n/src/locales/af.i18n.json index 7d5475f6b113e..6c06dc1392eea 100644 --- a/packages/i18n/src/locales/af.i18n.json +++ b/packages/i18n/src/locales/af.i18n.json @@ -2176,7 +2176,6 @@ "Updated_at": "Opgedateer op", "UpgradeToGetMore_engagement-dashboard_Title": "Analytics", "Upload_Folder_Path": "Laai mappad op", - "Upload_file_description": "Lêer beskrywing", "Upload_file_name": "Lêernaam", "Upload_file_question": "Laai leêr op?", "Upload_user_avatar": "Laai avatar op", diff --git a/packages/i18n/src/locales/ar.i18n.json b/packages/i18n/src/locales/ar.i18n.json index ccec8ea37a0f8..cddcdece7c566 100644 --- a/packages/i18n/src/locales/ar.i18n.json +++ b/packages/i18n/src/locales/ar.i18n.json @@ -3809,7 +3809,6 @@ "Upload_Folder_Path": "تحميل مسار المجلد", "Upload_From": "تحميل من {{name}}", "Upload_app": "تحميل التطبيق", - "Upload_file_description": "وصف الملف", "Upload_file_name": "اسم الملف", "Upload_file_question": "تحميل الملف؟", "Upload_user_avatar": "تحميل الصورة الرمزية", diff --git a/packages/i18n/src/locales/az.i18n.json b/packages/i18n/src/locales/az.i18n.json index 31d596b498810..a3689b80e93e5 100644 --- a/packages/i18n/src/locales/az.i18n.json +++ b/packages/i18n/src/locales/az.i18n.json @@ -2178,7 +2178,6 @@ "Updated_at": "Yenilənib", "UpgradeToGetMore_engagement-dashboard_Title": "Analytics", "Upload_Folder_Path": "Qovluq yolunu yükləyin", - "Upload_file_description": "Fayl təsviri", "Upload_file_name": "Fayl adı", "Upload_file_question": "Fayl yükləməyiniz?", "Upload_user_avatar": "Avatar yüklə", diff --git a/packages/i18n/src/locales/be-BY.i18n.json b/packages/i18n/src/locales/be-BY.i18n.json index 856b44c4eb435..96cff55e6043b 100644 --- a/packages/i18n/src/locales/be-BY.i18n.json +++ b/packages/i18n/src/locales/be-BY.i18n.json @@ -2197,7 +2197,6 @@ "Updated_at": "абноўлена", "UpgradeToGetMore_engagement-dashboard_Title": "аналітыка", "Upload_Folder_Path": "Загрузіць шлях да тэчцы", - "Upload_file_description": "апісанне файла", "Upload_file_name": "Імя файла", "Upload_file_question": "Загрузіць файл?", "Upload_user_avatar": "загрузіць аватар", diff --git a/packages/i18n/src/locales/bg.i18n.json b/packages/i18n/src/locales/bg.i18n.json index 2a8e0fb5fb6d4..7b2a85e0d6084 100644 --- a/packages/i18n/src/locales/bg.i18n.json +++ b/packages/i18n/src/locales/bg.i18n.json @@ -2173,7 +2173,6 @@ "Updated_at": "Актуализиран на", "UpgradeToGetMore_engagement-dashboard_Title": "анализ", "Upload_Folder_Path": "Качване на пътя на папките", - "Upload_file_description": "Описание на файла", "Upload_file_name": "Име на файл", "Upload_file_question": "Качи фаил?", "Upload_user_avatar": "Качване на аватар", diff --git a/packages/i18n/src/locales/bs.i18n.json b/packages/i18n/src/locales/bs.i18n.json index 3ceac1accd223..5fc329fb2d600 100644 --- a/packages/i18n/src/locales/bs.i18n.json +++ b/packages/i18n/src/locales/bs.i18n.json @@ -2170,7 +2170,6 @@ "Updated_at": "Ažurirano u", "UpgradeToGetMore_engagement-dashboard_Title": "Analitika", "Upload_Folder_Path": "Prijenos puta mape", - "Upload_file_description": "Opis fajla", "Upload_file_name": "Ime fajla", "Upload_file_question": "Prenesi datoteku?", "Upload_user_avatar": "Učitaj avatar", diff --git a/packages/i18n/src/locales/ca.i18n.json b/packages/i18n/src/locales/ca.i18n.json index 9b7c5a9bb08ab..a8742019fe128 100644 --- a/packages/i18n/src/locales/ca.i18n.json +++ b/packages/i18n/src/locales/ca.i18n.json @@ -3728,7 +3728,6 @@ "Upload_Folder_Path": "Carregar ruta de la carpeta", "Upload_From": "Pujar des de {{name}}", "Upload_app": "Pujar l'Aplicació", - "Upload_file_description": "Descripció de l'arxiu", "Upload_file_name": "Nom de l'arxiu", "Upload_file_question": "Pujar l'arxiu?", "Upload_user_avatar": "Carregar l'avatar", diff --git a/packages/i18n/src/locales/cs.i18n.json b/packages/i18n/src/locales/cs.i18n.json index 6ee0346b7fc2c..af9235e5a4890 100644 --- a/packages/i18n/src/locales/cs.i18n.json +++ b/packages/i18n/src/locales/cs.i18n.json @@ -3158,7 +3158,6 @@ "Upload_Folder_Path": "Cesta složky pro nahrávání souborů", "Upload_From": "Nahrát z {{name}}", "Upload_app": "Nahrát aplikaci", - "Upload_file_description": "Popis souboru", "Upload_file_name": "Název souboru", "Upload_file_question": "Nahrát soubor?", "Upload_user_avatar": "Nahrát avatara", diff --git a/packages/i18n/src/locales/cy.i18n.json b/packages/i18n/src/locales/cy.i18n.json index 7f473b852e6fc..029b69ce39f73 100644 --- a/packages/i18n/src/locales/cy.i18n.json +++ b/packages/i18n/src/locales/cy.i18n.json @@ -2171,7 +2171,6 @@ "Updated_at": "Wedi'i ddiweddaru yn", "UpgradeToGetMore_engagement-dashboard_Title": "Dadansoddiadau", "Upload_Folder_Path": "Llwytho Llwybr Ffolder", - "Upload_file_description": "Disgrifiad o'r ffeil", "Upload_file_name": "Ffeil enw", "Upload_file_question": "Llwytho ffeil?", "Upload_user_avatar": "Upload avatar", diff --git a/packages/i18n/src/locales/da.i18n.json b/packages/i18n/src/locales/da.i18n.json index ccf220038837a..740f8323a8fe5 100644 --- a/packages/i18n/src/locales/da.i18n.json +++ b/packages/i18n/src/locales/da.i18n.json @@ -3253,7 +3253,6 @@ "Upload_Folder_Path": "Upload mappepath", "Upload_From": "Upload fra {{name}}", "Upload_app": "Upload-app", - "Upload_file_description": "Filbeskrivelse", "Upload_file_name": "Filnavn", "Upload_file_question": "Upload fil?", "Upload_user_avatar": "Upload avatar", diff --git a/packages/i18n/src/locales/de-AT.i18n.json b/packages/i18n/src/locales/de-AT.i18n.json index e305f8fbc6963..d4c3e10718c4a 100644 --- a/packages/i18n/src/locales/de-AT.i18n.json +++ b/packages/i18n/src/locales/de-AT.i18n.json @@ -2177,7 +2177,6 @@ "Updated_at": "Aktualisiert am", "UpgradeToGetMore_engagement-dashboard_Title": "Analytics", "Upload_Folder_Path": "Ordnerpfad hochladen", - "Upload_file_description": "Dateibeschreibung", "Upload_file_name": "Dateiname", "Upload_file_question": "Möchten Sie eine Datei hochladen?", "Upload_user_avatar": "Hochladen von Avataren", diff --git a/packages/i18n/src/locales/de-IN.i18n.json b/packages/i18n/src/locales/de-IN.i18n.json index 74f2b3c22cc75..df571a160a0dc 100644 --- a/packages/i18n/src/locales/de-IN.i18n.json +++ b/packages/i18n/src/locales/de-IN.i18n.json @@ -2450,7 +2450,6 @@ "Upload_Folder_Path": "Pfad des Uploads", "Upload_From": "Upload von {{name}}", "Upload_app": "App hochladen", - "Upload_file_description": "Dateibeschreibung", "Upload_file_name": "Dateiname", "Upload_file_question": "Datei hochladen?", "Upload_user_avatar": "Avatar hochladen", diff --git a/packages/i18n/src/locales/de.i18n.json b/packages/i18n/src/locales/de.i18n.json index 78ff4591233f9..3332d9d1199f4 100644 --- a/packages/i18n/src/locales/de.i18n.json +++ b/packages/i18n/src/locales/de.i18n.json @@ -4223,7 +4223,6 @@ "Upload_Folder_Path": "Pfad des Uploads", "Upload_From": "Upload von {{name}}", "Upload_app": "App hochladen", - "Upload_file_description": "Dateibeschreibung", "Upload_file_name": "Dateiname", "Upload_file_question": "Datei hochladen?", "Upload_user_avatar": "Avatar hochladen", diff --git a/packages/i18n/src/locales/el.i18n.json b/packages/i18n/src/locales/el.i18n.json index c410c9e4ef583..c0d47a40e8acf 100644 --- a/packages/i18n/src/locales/el.i18n.json +++ b/packages/i18n/src/locales/el.i18n.json @@ -2179,7 +2179,6 @@ "Updated_at": "Ενημερώθηκε στο", "UpgradeToGetMore_engagement-dashboard_Title": "Αναλυτικά στοιχεία", "Upload_Folder_Path": "Μεταφόρτωση διαδρομής φακέλου", - "Upload_file_description": "Περιγραφή Αρχείου", "Upload_file_name": "Ονομα αρχείου", "Upload_file_question": "Να ανέβει το αρχείο;", "Upload_user_avatar": "Ανεβάστε το avatar", diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 7fbc45232ad75..47af61deac35e 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -1035,7 +1035,6 @@ "Cannot_invite_users_to_direct_rooms": "Cannot invite users to direct rooms", "Cannot_open_conversation_with_yourself": "Cannot Direct Message with yourself", "Cannot_share_your_location": "Cannot share your location...", - "Cannot_upload_file_character_limit": "Cannot upload file, description is over the {{count}} character limit", "Cant_join": "Can't join", "Categories": "Categories", "Categories*": "Categories*", @@ -2270,6 +2269,8 @@ "FileUpload_Error": "File Upload Error", "FileUpload_FileSystemPath": "System Path", "FileUpload_File_Empty": "File empty", + "FileUpload_Cancelled": "Upload cancelled", + "FileUpload_Update_Failed": "Could not update file name", "FileUpload_GoogleStorage_AccessId": "Google Storage Access Id", "FileUpload_GoogleStorage_AccessId_Description": "The Access Id is generally in an email format, for example: \"`example-test@example.iam.gserviceaccount.com`\"", "FileUpload_GoogleStorage_Bucket": "Google Storage Bucket Name", @@ -2286,6 +2287,9 @@ "FileUpload_GoogleStorage_Secret_Description": "Please follow [these instructions](https://github.com/CulturalMe/meteor-slingshot#google-cloud) and paste the result here.", "FileUpload_MaxFileSize": "Maximum File Upload Size (in bytes)", "FileUpload_MaxFileSizeDescription": "Set it to -1 to remove the file size limitation.", + "FileUpload_EnableMultipleFilesPerMessage": "Enable Multiple Files Per Message", + "FileUpload_EnableMultipleFilesPerMessage_alert": "Most Apps, Bridges and Integrations that read files are not compatible with more than one file per message.", + "FileUpload_EnableMultipleFilesPerMessage_Description": "Enable the ability to upload multiple files in a single message.", "FileUpload_MediaTypeBlackList": "Blocked Media Types", "FileUpload_MediaTypeBlackListDescription": "Comma-separated list of media types. This setting has priority over the Accepted Media Types.", "FileUpload_MediaTypeBlackList_Alert": "The default media type for unknown file extensions is \"application/octet-stream\", to work only with known file extensions you can add it to the \"Blocked Media Types\" list.", @@ -4787,6 +4791,7 @@ "Selecting_users": "Selecting users", "Self_managed_hosting": "Self-managed hosting", "Send": "Send", + "Send_anyway": "Send anyway", "Send_Email_SMTP_Warning": "Set up the SMTP server in email settings to enable.", "Send_Test": "Send Test", "Send_Test_Email": "Send test email", @@ -5518,18 +5523,17 @@ "Upgrade_tab_upgrade_your_plan": "Upgrade your plan", "Upgrade_to_Pro": "Upgrade to Pro", "Upload": "Upload", + "Upload_failed": "Upload failed", "Upload_Folder_Path": "Upload Folder Path", "Upload_From": "Upload from {{name}}", "Upload_anyway": "Upload anyway", "Upload_app": "Upload App", "Upload_file": "Upload file", - "Upload_file_description": "File description", "Upload_file_name": "File name", "Upload_file_question": "Upload file?", "Upload_private_app": "Upload private app", "Upload_user_avatar": "Upload avatar", "Uploading_file": "Uploading file...", - "Uploading_file__fileName__": "Uploading file {{fileName}}", "Uploads": "Uploads", "Uptime": "Uptime", "Usage": "Usage", @@ -5895,6 +5899,7 @@ "You_can_do_from_account_preferences": "You can do this later from your account preferences", "You_can_search_using_RegExp_eg": "You can search using Regular Expression. e.g. /^text$/i", "You_can_try_to": "You can try to", + "You_cant_upload_more_than__count__files": "You can't upload more than {{count}} files at once.", "You_can_use_an_emoji_as_avatar": "You can also use an emoji as an avatar.", "You_can_use_webhooks_to_easily_integrate_livechat_with_your_CRM": "You can use webhooks to easily integrate Omnichannel with your CRM.", "You_cant_leave_a_livechat_room_Please_use_the_close_button": "You can't leave a omnichannel room. Please, use the close button.", @@ -6312,6 +6317,7 @@ "error-token-already-exists": "A token with this name already exists", "error-token-does-not-exists": "Token does not exists", "error-too-many-requests": "Error, too many requests. Please slow down. You must wait {{seconds}} seconds before trying again.", + "error-too-many-files": "Error: number of files attached to the message is over the limit.", "error-transcript-already-requested": "Transcript already requested", "error-unable-to-update-priority": "Unable to update priority", "error-unknown-contact": "Contact is unknown.", @@ -7044,6 +7050,10 @@ "one": "{{count}} file pruned", "other": "{{count}} files pruned" }, + "__count__files_failed_to_upload": { + "one": "One file failed to upload and will not be sent: {{name}}", + "other": "{{count}} files failed to upload and will not be sent." + }, "__count__follower": { "one": "+{{count}} follower", "other": "+{{count}} followers" diff --git a/packages/i18n/src/locales/eo.i18n.json b/packages/i18n/src/locales/eo.i18n.json index 95335390f46b3..19d96be307d15 100644 --- a/packages/i18n/src/locales/eo.i18n.json +++ b/packages/i18n/src/locales/eo.i18n.json @@ -2174,7 +2174,6 @@ "Updated_at": "Ĝisdatigita je", "UpgradeToGetMore_engagement-dashboard_Title": "Analitiko", "Upload_Folder_Path": "Alŝuti dosierujon", - "Upload_file_description": "Dosiero priskribo", "Upload_file_name": "Dosiernomo", "Upload_file_question": "Alŝutu dosieron?", "Upload_user_avatar": "Alŝuti avataron", diff --git a/packages/i18n/src/locales/es.i18n.json b/packages/i18n/src/locales/es.i18n.json index 0fc0198805094..7555076dec221 100644 --- a/packages/i18n/src/locales/es.i18n.json +++ b/packages/i18n/src/locales/es.i18n.json @@ -3930,7 +3930,6 @@ "Upload_Folder_Path": "Ruta de carpeta de subida", "Upload_From": "Subir desde {{name}}", "Upload_app": "Subir aplicación", - "Upload_file_description": "Descripción de archivo", "Upload_file_name": "Nombre de archivo", "Upload_file_question": "¿Subir archivo?", "Upload_user_avatar": "Subir avatar", diff --git a/packages/i18n/src/locales/fa.i18n.json b/packages/i18n/src/locales/fa.i18n.json index 59e01b25d7a88..f1ea0f379c544 100644 --- a/packages/i18n/src/locales/fa.i18n.json +++ b/packages/i18n/src/locales/fa.i18n.json @@ -2461,7 +2461,6 @@ "Updated_at": "به روز شده در", "UpgradeToGetMore_engagement-dashboard_Title": "تجزیه و تحلیل ترافیک", "Upload_Folder_Path": "مسیر پوشه آپلود", - "Upload_file_description": "توضیحات فایل", "Upload_file_name": "نام فایل", "Upload_file_question": "آپلود فایل؟", "Upload_user_avatar": "بارگذاری تصویر", diff --git a/packages/i18n/src/locales/fi.i18n.json b/packages/i18n/src/locales/fi.i18n.json index fe8f3ddd9d282..5763c8c860e90 100644 --- a/packages/i18n/src/locales/fi.i18n.json +++ b/packages/i18n/src/locales/fi.i18n.json @@ -4380,7 +4380,6 @@ "Upload_From": "Lataukset käyttäjältä {{name}}", "Upload_anyway": "Lataa silti", "Upload_app": "Lataa sovellus", - "Upload_file_description": "Tiedoston kuvaus", "Upload_file_name": "Tiedoston nimi", "Upload_file_question": "Ladataanko tiedosto?", "Upload_private_app": "Lataa yksityinen sovellus", diff --git a/packages/i18n/src/locales/fr.i18n.json b/packages/i18n/src/locales/fr.i18n.json index 86be8c053e94a..dc4700d65a833 100644 --- a/packages/i18n/src/locales/fr.i18n.json +++ b/packages/i18n/src/locales/fr.i18n.json @@ -3812,7 +3812,6 @@ "Upload_Folder_Path": "Chemin du dossier de chargement", "Upload_From": "Charger depuis {{name}}", "Upload_app": "Charger l'application", - "Upload_file_description": "Description du fichier", "Upload_file_name": "Nom du fichier", "Upload_file_question": "Charger le fichier ?", "Upload_user_avatar": "Charger un avatar", diff --git a/packages/i18n/src/locales/he.i18n.json b/packages/i18n/src/locales/he.i18n.json index 31ab008d3147d..19e419fbbc53c 100644 --- a/packages/i18n/src/locales/he.i18n.json +++ b/packages/i18n/src/locales/he.i18n.json @@ -1217,7 +1217,6 @@ "Updated_at": "עודכן ב", "UpgradeToGetMore_engagement-dashboard_Title": "סטטיסטיקה", "Upload": "העלאה", - "Upload_file_description": "תיאור קובץ", "Upload_file_name": "שם קובץ", "Upload_file_question": "להעלות קובץ?", "Uploading_file": "מעלה קובץ...", diff --git a/packages/i18n/src/locales/hi-IN.i18n.json b/packages/i18n/src/locales/hi-IN.i18n.json index 77e4241717e8a..096def00ce49c 100644 --- a/packages/i18n/src/locales/hi-IN.i18n.json +++ b/packages/i18n/src/locales/hi-IN.i18n.json @@ -4683,7 +4683,6 @@ "Upload_From": "{{name}} से अपलोड करें", "Upload_anyway": "फिर भी अपलोड करें", "Upload_app": "ऐप अपलोड करें", - "Upload_file_description": "फाइल विवरण", "Upload_file_name": "फ़ाइल का नाम", "Upload_file_question": "दस्तावेज अपलोड करें?", "Upload_private_app": "निजी ऐप अपलोड करें", diff --git a/packages/i18n/src/locales/hr.i18n.json b/packages/i18n/src/locales/hr.i18n.json index 942a0e841f75a..ac28566bf75ed 100644 --- a/packages/i18n/src/locales/hr.i18n.json +++ b/packages/i18n/src/locales/hr.i18n.json @@ -2294,7 +2294,6 @@ "Updated_at": "Ažurirano u", "UpgradeToGetMore_engagement-dashboard_Title": "Analitika", "Upload_Folder_Path": "Prijenos puta mape", - "Upload_file_description": "Opis fajla", "Upload_file_name": "Ime fajla", "Upload_file_question": "Prenesi datoteku?", "Upload_user_avatar": "Učitaj avatar", diff --git a/packages/i18n/src/locales/hu.i18n.json b/packages/i18n/src/locales/hu.i18n.json index 19899faf6dec5..cc1f45805ec60 100644 --- a/packages/i18n/src/locales/hu.i18n.json +++ b/packages/i18n/src/locales/hu.i18n.json @@ -4117,7 +4117,6 @@ "Upload_Folder_Path": "Feltöltési mappa útvonala", "Upload_From": "Feltöltés innen: {{name}}", "Upload_app": "Alkalmazás feltöltése", - "Upload_file_description": "Fájl leírása", "Upload_file_name": "Fájlnév", "Upload_file_question": "Feltölti a fájlt?", "Upload_user_avatar": "Profilkép feltöltése", diff --git a/packages/i18n/src/locales/id.i18n.json b/packages/i18n/src/locales/id.i18n.json index 2adbbce8c8494..c0eada89c609e 100644 --- a/packages/i18n/src/locales/id.i18n.json +++ b/packages/i18n/src/locales/id.i18n.json @@ -2172,7 +2172,6 @@ "Updated_at": "Diperbarui pada", "UpgradeToGetMore_engagement-dashboard_Title": "Analytics", "Upload_Folder_Path": "Unggah Jalur Folder", - "Upload_file_description": "Deskripsi berkas", "Upload_file_name": "Nama file", "Upload_file_question": "Unggah file?", "Upload_user_avatar": "Upload avatar", diff --git a/packages/i18n/src/locales/it.i18n.json b/packages/i18n/src/locales/it.i18n.json index 69ca883a7bcf6..90fe38b407b9b 100644 --- a/packages/i18n/src/locales/it.i18n.json +++ b/packages/i18n/src/locales/it.i18n.json @@ -2690,7 +2690,6 @@ "UpgradeToGetMore_custom-roles_Title": "Ruoli personalizzati", "UpgradeToGetMore_engagement-dashboard_Title": "Analytics", "Upload_Folder_Path": "Carica percorso cartella", - "Upload_file_description": "Descrizione file", "Upload_file_name": "Nome file", "Upload_file_question": "Caricare il file?", "Upload_user_avatar": "Carica avatar", diff --git a/packages/i18n/src/locales/ja.i18n.json b/packages/i18n/src/locales/ja.i18n.json index c4111af955f98..8dd8f12edb930 100644 --- a/packages/i18n/src/locales/ja.i18n.json +++ b/packages/i18n/src/locales/ja.i18n.json @@ -3775,7 +3775,6 @@ "Upload_Folder_Path": "フォルダパスのアップロード", "Upload_From": "{{name}}からアップロード", "Upload_app": "アプリのアップロード", - "Upload_file_description": "ファイルの説明", "Upload_file_name": "ファイル名", "Upload_file_question": "ファイルをアップロードしますか?", "Upload_user_avatar": "アバターをアップロード", diff --git a/packages/i18n/src/locales/ka-GE.i18n.json b/packages/i18n/src/locales/ka-GE.i18n.json index e8ae17a32a8fa..7aea725c1d672 100644 --- a/packages/i18n/src/locales/ka-GE.i18n.json +++ b/packages/i18n/src/locales/ka-GE.i18n.json @@ -2941,7 +2941,6 @@ "Upload_Folder_Path": "საქაღალდის გზის ატვირთვა", "Upload_From": "ატვირთვა {{name}}", "Upload_app": "აპლიკაციის ატვირთვა", - "Upload_file_description": "ფაილის აღწერა", "Upload_file_name": "ფაილის სახელი", "Upload_file_question": "გსურთ ატვირთოთ ფაილი?", "Upload_user_avatar": "ავატარის ატვირთვა", diff --git a/packages/i18n/src/locales/km.i18n.json b/packages/i18n/src/locales/km.i18n.json index 05d6cfb68999c..8048839b6f11c 100644 --- a/packages/i18n/src/locales/km.i18n.json +++ b/packages/i18n/src/locales/km.i18n.json @@ -2475,7 +2475,6 @@ "UpgradeToGetMore_engagement-dashboard_Title": "វិភាគ", "Upload_Folder_Path": "ផ្ទុកផ្លូវថតឡើង", "Upload_From": "ផ្ទុកឡើងពី {{name}}", - "Upload_file_description": "ការពិពណ៌នាឯកសារ", "Upload_file_name": "ឈ្មោះ​ឯកសារ", "Upload_file_question": "ផ្ទុក​ឯកសារ​ឡើង​ឬ?", "Upload_user_avatar": "ផ្ទុករូបតំនាង", diff --git a/packages/i18n/src/locales/ko.i18n.json b/packages/i18n/src/locales/ko.i18n.json index 90ea1ffce6dd3..113c116758310 100644 --- a/packages/i18n/src/locales/ko.i18n.json +++ b/packages/i18n/src/locales/ko.i18n.json @@ -3224,7 +3224,6 @@ "Upload_Folder_Path": "폴더 경로 업로드", "Upload_From": " {{name}} 에서 업로드", "Upload_app": "앱 업로드", - "Upload_file_description": "파일 설명", "Upload_file_name": "파일 이름", "Upload_file_question": "파일을 업로드하시겠습니까?", "Upload_user_avatar": "아바타 업로드", diff --git a/packages/i18n/src/locales/ku.i18n.json b/packages/i18n/src/locales/ku.i18n.json index 98ec9ff45ba42..ca3664e37841b 100644 --- a/packages/i18n/src/locales/ku.i18n.json +++ b/packages/i18n/src/locales/ku.i18n.json @@ -2168,7 +2168,6 @@ "Updated_at": "Nûvekirî", "UpgradeToGetMore_engagement-dashboard_Title": "analytics", "Upload_Folder_Path": "Peldanka Peldanka Hilbijêre", - "Upload_file_description": "Pirtûka pelê", "Upload_file_name": "Navê pelê", "Upload_file_question": "Pelê bar bike?", "Upload_user_avatar": "Avatar hilbijêre", diff --git a/packages/i18n/src/locales/lo.i18n.json b/packages/i18n/src/locales/lo.i18n.json index e434b1efe860d..cc2ba9c2dd484 100644 --- a/packages/i18n/src/locales/lo.i18n.json +++ b/packages/i18n/src/locales/lo.i18n.json @@ -2201,7 +2201,6 @@ "Updated_at": "Updated at", "UpgradeToGetMore_engagement-dashboard_Title": "ການວິເຄາະ", "Upload_Folder_Path": "ອັບໂຫລດໂຟເດີໂຟເດີ", - "Upload_file_description": "ລາຍລະອຽດຂອງໄຟລ໌", "Upload_file_name": "ຊື່​ເອ​ກະ​ສານ", "Upload_file_question": "ອັບໂຫລດເອກະສານ?", "Upload_user_avatar": "ອັບໂຫລດ avatar", diff --git a/packages/i18n/src/locales/lt.i18n.json b/packages/i18n/src/locales/lt.i18n.json index 1883c762921e3..1758e48609645 100644 --- a/packages/i18n/src/locales/lt.i18n.json +++ b/packages/i18n/src/locales/lt.i18n.json @@ -2226,7 +2226,6 @@ "Updated_at": "Atnaujinta", "UpgradeToGetMore_engagement-dashboard_Title": "\"Analytics\"", "Upload_Folder_Path": "Įkelti aplanko kelią", - "Upload_file_description": "Failo aprašymas", "Upload_file_name": "Failo pavadinimas", "Upload_file_question": "Įkelti failą?", "Upload_user_avatar": "Įkelti avatarą", diff --git a/packages/i18n/src/locales/lv.i18n.json b/packages/i18n/src/locales/lv.i18n.json index 158010f517c57..9d3b999c44138 100644 --- a/packages/i18n/src/locales/lv.i18n.json +++ b/packages/i18n/src/locales/lv.i18n.json @@ -2186,7 +2186,6 @@ "Updated_at": "Atjaunināts uz", "UpgradeToGetMore_engagement-dashboard_Title": "Analītika", "Upload_Folder_Path": "Augšupielādēt mapes ceļu", - "Upload_file_description": "Faila apraksts", "Upload_file_name": "Faila nosaukums", "Upload_file_question": "Vai augšupielādēt failu?", "Upload_user_avatar": "Augšupielādēt avataru", diff --git a/packages/i18n/src/locales/mn.i18n.json b/packages/i18n/src/locales/mn.i18n.json index 40fac0d97c7e4..499e2a947b3b8 100644 --- a/packages/i18n/src/locales/mn.i18n.json +++ b/packages/i18n/src/locales/mn.i18n.json @@ -2172,7 +2172,6 @@ "Updated_at": "Дээр шинэчилсэн", "UpgradeToGetMore_engagement-dashboard_Title": "Аналитик", "Upload_Folder_Path": "Folder Path-г оруулна уу", - "Upload_file_description": "Файлын тайлбар", "Upload_file_name": "Файлын нэр", "Upload_file_question": "Файл оруулах уу?", "Upload_user_avatar": "Зургийг байршуулна уу", diff --git a/packages/i18n/src/locales/ms-MY.i18n.json b/packages/i18n/src/locales/ms-MY.i18n.json index 517fcae8acca4..f1bc64a4ae1b2 100644 --- a/packages/i18n/src/locales/ms-MY.i18n.json +++ b/packages/i18n/src/locales/ms-MY.i18n.json @@ -2178,7 +2178,6 @@ "Updated_at": "Dikemaskini di", "UpgradeToGetMore_engagement-dashboard_Title": "Analisis", "Upload_Folder_Path": "Muatkan Laluan Folder", - "Upload_file_description": "Penerangan fail", "Upload_file_name": "Nama fail", "Upload_file_question": "Muat naik fail?", "Upload_user_avatar": "Muat naik avatar", diff --git a/packages/i18n/src/locales/nb.i18n.json b/packages/i18n/src/locales/nb.i18n.json index e0afeb4b6b181..a5b39a11344c4 100644 --- a/packages/i18n/src/locales/nb.i18n.json +++ b/packages/i18n/src/locales/nb.i18n.json @@ -941,7 +941,6 @@ "Cannot_invite_users_to_direct_rooms": "Kan ikke invitere brukere til direkterom", "Cannot_open_conversation_with_yourself": "Kan ikke sende direkte-melding til deg selv", "Cannot_share_your_location": "Kan ikke dele din posisjonen...", - "Cannot_upload_file_character_limit": "Kan ikke laste opp filen, beskrivelsen overskrider {{count}} tegn", "Cant_join": "Kan ikke bli med", "Categories": "Kategorier", "Categories*": "Kategorier*", @@ -5384,13 +5383,11 @@ "Upload_anyway": "Last opp allikevel", "Upload_app": "Last opp app", "Upload_file": "Last opp fil", - "Upload_file_description": "Filbeskrivelse", "Upload_file_name": "Filnavn", "Upload_file_question": "Laste opp fil?", "Upload_private_app": "Last opp privat app", "Upload_user_avatar": "Last opp avatar", "Uploading_file": "Laster opp fil ...", - "Uploading_file__fileName__": "Laste opp fil {{fileName}}", "Uploads": "Opplastinger", "Uptime": "Oppetid", "Usage": "Bruk", diff --git a/packages/i18n/src/locales/nl.i18n.json b/packages/i18n/src/locales/nl.i18n.json index a1d2215602956..53409ecc9220b 100644 --- a/packages/i18n/src/locales/nl.i18n.json +++ b/packages/i18n/src/locales/nl.i18n.json @@ -3804,7 +3804,6 @@ "Upload_Folder_Path": "Upload mappad", "Upload_From": "Uploaden van {{name}}", "Upload_app": "App uploaden", - "Upload_file_description": "Bestandsomschrijving", "Upload_file_name": "Bestandsnaam", "Upload_file_question": "Upload bestand?", "Upload_user_avatar": "Upload avatar", diff --git a/packages/i18n/src/locales/nn.i18n.json b/packages/i18n/src/locales/nn.i18n.json index 4c64cf5a89068..b55c70d3602ed 100644 --- a/packages/i18n/src/locales/nn.i18n.json +++ b/packages/i18n/src/locales/nn.i18n.json @@ -915,7 +915,6 @@ "Cannot_invite_users_to_direct_rooms": "Kan ikke invitere brukere til å lede rom", "Cannot_open_conversation_with_yourself": "Kan ikke sende melding til deg selv", "Cannot_share_your_location": "Kan ikke dele din posisjonen...", - "Cannot_upload_file_character_limit": "Kan ikke laste opp filen, beskrivelsen overskrider {{count}} tegn", "Cant_join": "Kan ikke bli med", "Categories": "Kategorier", "Categories*": "Kategorier*", @@ -4925,7 +4924,6 @@ "Upload_anyway": "Last opp allikevel", "Upload_app": "Last opp app", "Upload_file": "Last opp fil", - "Upload_file_description": "Filbeskrivelse", "Upload_file_name": "Filnavn", "Upload_file_question": "Last opp fil?", "Upload_private_app": "Last opp privat app", diff --git a/packages/i18n/src/locales/pl.i18n.json b/packages/i18n/src/locales/pl.i18n.json index f9f0f9cad2e5c..b709edec46c6c 100644 --- a/packages/i18n/src/locales/pl.i18n.json +++ b/packages/i18n/src/locales/pl.i18n.json @@ -4126,7 +4126,6 @@ "Upload_Folder_Path": "Prześlij ścieżkę folderu", "Upload_From": "Prześlij z {{name}}", "Upload_app": "Prześlij aplikację", - "Upload_file_description": "Opis pliku", "Upload_file_name": "Nazwa pliku", "Upload_file_question": "Przesłać plik?", "Upload_user_avatar": "Załaduj awatar", diff --git a/packages/i18n/src/locales/pt-BR.i18n.json b/packages/i18n/src/locales/pt-BR.i18n.json index ce8c1af79e774..8bba34fcd09a3 100644 --- a/packages/i18n/src/locales/pt-BR.i18n.json +++ b/packages/i18n/src/locales/pt-BR.i18n.json @@ -934,7 +934,6 @@ "Cannot_invite_users_to_direct_rooms": "Não é possível convidar pessoas para salas diretas", "Cannot_open_conversation_with_yourself": "Não é possível dirigir a mensagem com você mesmo", "Cannot_share_your_location": "Não foi possível compartilhar sua localização...", - "Cannot_upload_file_character_limit": "Não é possível fazer upload de arquivo, a descrição está acima do limite de caracteres {{count}} ", "Cant_join": "Não é possível participar", "Categories": "Categorias", "Categories*": "Categorias*", @@ -5257,7 +5256,6 @@ "Upload_anyway": "Fazer upload de qualquer forma", "Upload_app": "Upload de aplicativo", "Upload_file": "Carregar arquivo", - "Upload_file_description": "Descrição do arquivo", "Upload_file_name": "Nome do arquivo", "Upload_file_question": "Fazer upload de arquivo?", "Upload_private_app": "Carregar aplicativo privado", diff --git a/packages/i18n/src/locales/pt.i18n.json b/packages/i18n/src/locales/pt.i18n.json index 083cb5f655bff..cd4b4240971cf 100644 --- a/packages/i18n/src/locales/pt.i18n.json +++ b/packages/i18n/src/locales/pt.i18n.json @@ -2513,7 +2513,6 @@ "UpgradeToGetMore_engagement-dashboard_Title": "Analytics", "Upload_Folder_Path": "Carregar caminho da pasta", "Upload_From": "Upload de {{name}}", - "Upload_file_description": "Descrição do ficheiro", "Upload_file_name": "Nome do ficheiro", "Upload_file_question": "Carregar ficheiro?", "Upload_user_avatar": "Carregar Avatar", diff --git a/packages/i18n/src/locales/ro.i18n.json b/packages/i18n/src/locales/ro.i18n.json index 60ab831cbd3fe..bbb8f6cdc6914 100644 --- a/packages/i18n/src/locales/ro.i18n.json +++ b/packages/i18n/src/locales/ro.i18n.json @@ -2175,7 +2175,6 @@ "Updated_at": "Actualizat la", "UpgradeToGetMore_engagement-dashboard_Title": "Analytics", "Upload_Folder_Path": "Încărcați calea folderelor", - "Upload_file_description": "Descrierea fisierului", "Upload_file_name": "Nume de fișier", "Upload_file_question": "Încarcă fișier?", "Upload_user_avatar": "Încărcați avatar", diff --git a/packages/i18n/src/locales/ru.i18n.json b/packages/i18n/src/locales/ru.i18n.json index 84848acc3fa25..8c146f883cb8b 100644 --- a/packages/i18n/src/locales/ru.i18n.json +++ b/packages/i18n/src/locales/ru.i18n.json @@ -3959,7 +3959,6 @@ "Upload_Folder_Path": "Путь к папке загрузки", "Upload_From": "Загрузить с {{name}}", "Upload_app": "Загрузить приложение", - "Upload_file_description": "Описание файла", "Upload_file_name": "Имя файла", "Upload_file_question": "Загрузить файл?", "Upload_user_avatar": "Загруженный аватар", diff --git a/packages/i18n/src/locales/sk-SK.i18n.json b/packages/i18n/src/locales/sk-SK.i18n.json index 84ff28bf7b9af..2c8418db4dc68 100644 --- a/packages/i18n/src/locales/sk-SK.i18n.json +++ b/packages/i18n/src/locales/sk-SK.i18n.json @@ -2180,7 +2180,6 @@ "Updated_at": "Aktualizované na", "UpgradeToGetMore_engagement-dashboard_Title": "Analytika", "Upload_Folder_Path": "Nahrať cestu priečinka", - "Upload_file_description": "Popis súboru", "Upload_file_name": "Názov súboru", "Upload_file_question": "Nahrajte súbor?", "Upload_user_avatar": "Nahrať avatar", diff --git a/packages/i18n/src/locales/sl-SI.i18n.json b/packages/i18n/src/locales/sl-SI.i18n.json index 5ab895f3a464a..dadc971611748 100644 --- a/packages/i18n/src/locales/sl-SI.i18n.json +++ b/packages/i18n/src/locales/sl-SI.i18n.json @@ -2172,7 +2172,6 @@ "Updated_at": "Posodobljeno ob", "UpgradeToGetMore_engagement-dashboard_Title": "Analiza", "Upload_Folder_Path": "Naloži pot do mape", - "Upload_file_description": "Opis datoteke", "Upload_file_name": "Ime datoteke", "Upload_file_question": "Želite naložiti datoteko?", "Upload_user_avatar": "Naloži avatar", diff --git a/packages/i18n/src/locales/sq.i18n.json b/packages/i18n/src/locales/sq.i18n.json index 3ae8a0a5dcb73..4483c5a3d3b56 100644 --- a/packages/i18n/src/locales/sq.i18n.json +++ b/packages/i18n/src/locales/sq.i18n.json @@ -2176,7 +2176,6 @@ "Updated_at": "Përditësuar në", "UpgradeToGetMore_engagement-dashboard_Title": "Analitikë", "Upload_Folder_Path": "Ngarko dosjen e dosjes", - "Upload_file_description": "Përshkrimi i skedarit", "Upload_file_name": "Emri i skedarit", "Upload_file_question": "Ngarko skedar?", "Upload_user_avatar": "Ngarko avatar", diff --git a/packages/i18n/src/locales/sr.i18n.json b/packages/i18n/src/locales/sr.i18n.json index ab54814b70937..bdcb3a3eaf0ec 100644 --- a/packages/i18n/src/locales/sr.i18n.json +++ b/packages/i18n/src/locales/sr.i18n.json @@ -2010,7 +2010,6 @@ "Updated_at": "Ажурирано у", "UpgradeToGetMore_engagement-dashboard_Title": "Аналитика", "Upload_Folder_Path": "Путања фолдера", - "Upload_file_description": "Опис фајла", "Upload_file_name": "Назив документа", "Upload_file_question": "Отпреми датотеку?", "Upload_user_avatar": "Уплоад аватар", diff --git a/packages/i18n/src/locales/sv.i18n.json b/packages/i18n/src/locales/sv.i18n.json index 0393b1f68b357..5749e24061393 100644 --- a/packages/i18n/src/locales/sv.i18n.json +++ b/packages/i18n/src/locales/sv.i18n.json @@ -933,7 +933,6 @@ "Cannot_invite_users_to_direct_rooms": "Det går inte att bjuda in användare att styra rum", "Cannot_open_conversation_with_yourself": "Kan inte skicka direktmeddelande till dig själv", "Cannot_share_your_location": "Kan inte dela din plats...", - "Cannot_upload_file_character_limit": "Det går inte att ladda upp filen, beskrivningen överskrider teckengränsen på {{count}} ", "Cant_join": "Kan inte gå med", "Categories": "Kategorier", "Categories*": "Kategorier*", @@ -5292,13 +5291,11 @@ "Upload_anyway": "Ladda upp ändå", "Upload_app": "Ladda upp app", "Upload_file": "Ladda upp fil", - "Upload_file_description": "Filbeskrivning", "Upload_file_name": "Filnamn", "Upload_file_question": "Ladda upp fil?", "Upload_private_app": "Ladda upp en privat app", "Upload_user_avatar": "Ladda upp avatar", "Uploading_file": "Laddar upp fil...", - "Uploading_file__fileName__": "Ladda upp fil {{fileName}}", "Uploads": "Uppladdningar", "Uptime": "Upptid", "Usage": "Användning", diff --git a/packages/i18n/src/locales/ta-IN.i18n.json b/packages/i18n/src/locales/ta-IN.i18n.json index 3b992211871cf..42d258da6af19 100644 --- a/packages/i18n/src/locales/ta-IN.i18n.json +++ b/packages/i18n/src/locales/ta-IN.i18n.json @@ -2175,7 +2175,6 @@ "Updated_at": "புதுப்பிக்கப்பட்டது", "UpgradeToGetMore_engagement-dashboard_Title": "அனலிட்டிக்ஸ்", "Upload_Folder_Path": "கோப்புறை பாதை பதிவேற்றவும்", - "Upload_file_description": "கோப்பு விளக்கம்", "Upload_file_name": "கோப்பு பெயர்", "Upload_file_question": "கோப்பை பதிவேற்ற?", "Upload_user_avatar": "பதிவைப் பதிவேற்று", diff --git a/packages/i18n/src/locales/th-TH.i18n.json b/packages/i18n/src/locales/th-TH.i18n.json index a97133c7474fc..3ccfc0af84c18 100644 --- a/packages/i18n/src/locales/th-TH.i18n.json +++ b/packages/i18n/src/locales/th-TH.i18n.json @@ -2168,7 +2168,6 @@ "Updated_at": "อัปเดตเมื่อวันที่", "UpgradeToGetMore_engagement-dashboard_Title": "Analytics", "Upload_Folder_Path": "อัปโหลดเส้นทางโฟลเดอร์", - "Upload_file_description": "คำอธิบายไฟล์", "Upload_file_name": "ชื่อไฟล์", "Upload_file_question": "อัปโหลดไฟล์หรือไม่?", "Upload_user_avatar": "อัปโหลดภาพอวตาร", diff --git a/packages/i18n/src/locales/tr.i18n.json b/packages/i18n/src/locales/tr.i18n.json index 293703c1eb71d..84437ce68fa94 100644 --- a/packages/i18n/src/locales/tr.i18n.json +++ b/packages/i18n/src/locales/tr.i18n.json @@ -2582,7 +2582,6 @@ "Upload_Folder_Path": "Dosya yükleme konumu", "Upload_From": "{{name}} den yükle", "Upload_app": "Uygulamayı Yükle", - "Upload_file_description": "Dosya açıklaması", "Upload_file_name": "Dosya adı", "Upload_file_question": "Dosya yükle?", "Upload_user_avatar": "Avatarı yükle", diff --git a/packages/i18n/src/locales/uk.i18n.json b/packages/i18n/src/locales/uk.i18n.json index 1a6a66fef1613..2feecd180ca31 100644 --- a/packages/i18n/src/locales/uk.i18n.json +++ b/packages/i18n/src/locales/uk.i18n.json @@ -2668,7 +2668,6 @@ "UpgradeToGetMore_auditing_Title": "Аудит повідомлень", "UpgradeToGetMore_engagement-dashboard_Title": "Аналітика", "Upload_Folder_Path": "Завантажте шлях до папки", - "Upload_file_description": "Опис файлу", "Upload_file_name": "Ім'я файлу", "Upload_file_question": "Завантажити файл?", "Upload_user_avatar": "Завантажити аватар", diff --git a/packages/i18n/src/locales/vi-VN.i18n.json b/packages/i18n/src/locales/vi-VN.i18n.json index d079aa74e4989..99f5b4201fec6 100644 --- a/packages/i18n/src/locales/vi-VN.i18n.json +++ b/packages/i18n/src/locales/vi-VN.i18n.json @@ -2269,7 +2269,6 @@ "Updated_at": "Cập nhật tại", "UpgradeToGetMore_engagement-dashboard_Title": "phân tích", "Upload_Folder_Path": "Tải lên đường dẫn thư mục", - "Upload_file_description": "Mô tả tập tin", "Upload_file_name": "Tên tệp", "Upload_file_question": "Cập nhật dử liệu?", "Upload_user_avatar": "Tải lên hình đại diện", diff --git a/packages/i18n/src/locales/zh-HK.i18n.json b/packages/i18n/src/locales/zh-HK.i18n.json index 49eb9b842a3ee..217580751d9c0 100644 --- a/packages/i18n/src/locales/zh-HK.i18n.json +++ b/packages/i18n/src/locales/zh-HK.i18n.json @@ -2202,7 +2202,6 @@ "Updated_at": "更新于", "UpgradeToGetMore_engagement-dashboard_Title": "分析", "Upload_Folder_Path": "上传文件夹路径", - "Upload_file_description": "文件描述", "Upload_file_name": "文件名", "Upload_file_question": "上传文件?", "Upload_user_avatar": "上传头像", diff --git a/packages/i18n/src/locales/zh-TW.i18n.json b/packages/i18n/src/locales/zh-TW.i18n.json index 6b1e537e827a0..d3bdffba359bd 100644 --- a/packages/i18n/src/locales/zh-TW.i18n.json +++ b/packages/i18n/src/locales/zh-TW.i18n.json @@ -3603,7 +3603,6 @@ "Upload_Folder_Path": "上傳資料夾路徑", "Upload_From": "從 {{name}} 上傳", "Upload_app": "上傳應用程式", - "Upload_file_description": "檔案敘述", "Upload_file_name": "檔案名稱", "Upload_file_question": "是否上傳檔案?", "Upload_user_avatar": "上傳頭像", diff --git a/packages/i18n/src/locales/zh.i18n.json b/packages/i18n/src/locales/zh.i18n.json index 3244a2b493e55..defceaa975010 100644 --- a/packages/i18n/src/locales/zh.i18n.json +++ b/packages/i18n/src/locales/zh.i18n.json @@ -1031,7 +1031,6 @@ "Cannot_invite_users_to_direct_rooms": "不能邀请用户加入私聊", "Cannot_open_conversation_with_yourself": "不能和你自己私聊", "Cannot_share_your_location": "不能分享您的位置…", - "Cannot_upload_file_character_limit": "无法上传文件,描述超过 {{count}} 个字符限制", "Cant_join": "无法加入", "Categories": "类别", "Categories*": "类别*", @@ -5515,13 +5514,11 @@ "Upload_anyway": "仍然上传", "Upload_app": "上传应用", "Upload_file": "上传文件", - "Upload_file_description": "文件描述", "Upload_file_name": "文件名", "Upload_file_question": "上传文件?", "Upload_private_app": "上传私有应用", "Upload_user_avatar": "上传头像", "Uploading_file": "文件上传中……", - "Uploading_file__fileName__": "正在上传文件 {{fileName}}", "Uploads": "上传", "Uptime": "运行时间", "Usage": "使用情况", diff --git a/packages/model-typings/src/models/IBaseUploadsModel.ts b/packages/model-typings/src/models/IBaseUploadsModel.ts index 1161ab0fe86d9..6a107a4bf6c29 100644 --- a/packages/model-typings/src/models/IBaseUploadsModel.ts +++ b/packages/model-typings/src/models/IBaseUploadsModel.ts @@ -10,6 +10,8 @@ export interface IBaseUploadsModel extends IBaseModel { confirmTemporaryFile(fileId: string, userId: string): Promise | undefined; + confirmTemporaryFiles(fileIds: string[], userId: string): Promise | undefined; + findByIds(_ids: string[], options?: FindOptions): FindCursor; findOneByName(name: string, options?: { session?: ClientSession }): Promise; @@ -20,5 +22,7 @@ export interface IBaseUploadsModel extends IBaseModel { updateFileNameById(fileId: string, name: string): Promise; + updateFileContentById(fileId: string, content: IUpload['content']): Promise; + deleteFile(fileId: string, options?: { session?: ClientSession }): Promise; } diff --git a/packages/models/src/models/BaseUploadModel.ts b/packages/models/src/models/BaseUploadModel.ts index ba4165534c00d..da31481e1d9ca 100644 --- a/packages/models/src/models/BaseUploadModel.ts +++ b/packages/models/src/models/BaseUploadModel.ts @@ -74,10 +74,6 @@ export abstract class BaseUploadModelRaw extends BaseRaw implements IBaseUplo } confirmTemporaryFile(fileId: string, userId: string): Promise | undefined { - if (!fileId) { - return; - } - const filter = { _id: fileId, userId, @@ -92,6 +88,23 @@ export abstract class BaseUploadModelRaw extends BaseRaw implements IBaseUplo return this.updateOne(filter, update); } + confirmTemporaryFiles(fileIds: string[], userId: string): Promise | undefined { + const filter = { + _id: { + $in: fileIds, + }, + userId, + }; + + const update: Filter = { + $unset: { + expiresAt: 1, + }, + }; + + return this.updateMany(filter, update); + } + findByIds(_ids: string[], options?: FindOptions): FindCursor { const query = { _id: { @@ -131,6 +144,16 @@ export abstract class BaseUploadModelRaw extends BaseRaw implements IBaseUplo return this.updateOne(filter, update); } + async updateFileContentById(fileId: string, content: IUpload['content']): Promise { + const filter = { _id: fileId }; + const update = { + $set: { + content, + }, + }; + return this.updateOne(filter, update); + } + async deleteFile(fileId: string, options?: { session?: ClientSession }): Promise { return this.deleteOne({ _id: fileId }, { session: options?.session }); }