diff --git a/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts b/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts index 8ed534c7015b..6863ab522030 100644 --- a/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts +++ b/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts @@ -6,6 +6,7 @@ import type { AtLeast, FilesAndAttachments, IMessage, + FileProp, } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Rooms, Uploads, Users } from '@rocket.chat/models'; @@ -29,6 +30,38 @@ function validateFileRequiredFields(file: Partial): asserts file is AtL }); } +export const parseMultipleFilesIntoMessageAttachments = async ( + filesToConfirm: Partial[], + roomId: string, + user: IUser, +): Promise<{ files: FileProp[]; attachments: MessageAttachment[] }> => { + const messageFiles: FileProp[] = []; + const messageAttachments: MessageAttachment[] = []; + + const filesAndAttachments = await Promise.all( + filesToConfirm + .filter((files: Partial) => !!files) + .map(async (file) => { + try { + const { files, attachments } = await parseFileIntoMessageAttachments(file, roomId, user); + return { files, attachments }; + } catch (error) { + console.error('Error processing file:', file, error); + return { files: [], attachments: [] }; + } + }), + ); + + filesAndAttachments + .filter(({ files, attachments }) => files.length || attachments.length) + .forEach(({ files, attachments }) => { + messageFiles.push(...files); + messageAttachments.push(...attachments); + }); + + return { files: messageFiles, attachments: messageAttachments }; +}; + export const parseFileIntoMessageAttachments = async ( file: Partial, roomId: string, diff --git a/apps/meteor/app/lib/server/functions/sendMessage.ts b/apps/meteor/app/lib/server/functions/sendMessage.ts index aba5ddb7264c..cfcf21eb29c0 100644 --- a/apps/meteor/app/lib/server/functions/sendMessage.ts +++ b/apps/meteor/app/lib/server/functions/sendMessage.ts @@ -1,13 +1,14 @@ import { Apps } from '@rocket.chat/apps'; import { api, Message } from '@rocket.chat/core-services'; -import type { IMessage, IRoom } from '@rocket.chat/core-typings'; -import { Messages } from '@rocket.chat/models'; +import type { IMessage, IRoom, IUpload } from '@rocket.chat/core-typings'; +import { Messages, Uploads } from '@rocket.chat/models'; import { Match, check } from 'meteor/check'; 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, notifyOnMessageChange } from '../lib/notifyListener'; @@ -215,11 +216,35 @@ export function prepareMessageObject( /** * Validates and sends the message object. */ -export const sendMessage = async function (user: any, message: any, room: any, upsert = false, previewUrls?: string[]) { +export const sendMessage = async function ( + user: any, + message: any, + room: any, + upsert = false, + previewUrls?: string[], + uploadIdsToConfirm?: string[], +) { if (!user || !message || !room._id) { return false; } + if (uploadIdsToConfirm !== undefined) { + const filesToConfirm: Partial[] = await Promise.all( + uploadIdsToConfirm.map(async (fileid) => { + const file = await Uploads.findOneById(fileid); + if (!file) { + throw new Meteor.Error('invalid-file'); + } + return file; + }), + ); + if (message?.t !== 'e2e') { + const { files, attachments } = await parseMultipleFilesIntoMessageAttachments(filesToConfirm, message.rid, user); + message.files = files; + message.attachments = attachments; + } + } + await validateMessage(message, room, user); prepareMessageObject(message, room._id, user); @@ -292,6 +317,10 @@ export const sendMessage = async function (user: any, message: any, room: any, u // TODO: is there an opportunity to send returned data to notifyOnMessageChange? await afterSaveMessage(message, room); + if (uploadIdsToConfirm !== undefined) { + await Promise.all(uploadIdsToConfirm.map((fileid) => Uploads.confirmTemporaryFile(fileid, user._id))); + } + void notifyOnMessageChange({ id: message._id }); void notifyOnRoomChangedById(message.rid); diff --git a/apps/meteor/app/lib/server/methods/sendMessage.ts b/apps/meteor/app/lib/server/methods/sendMessage.ts index 76134c81d0b3..6d4b173f8ea5 100644 --- a/apps/meteor/app/lib/server/methods/sendMessage.ts +++ b/apps/meteor/app/lib/server/methods/sendMessage.ts @@ -19,7 +19,12 @@ import { MessageTypes } from '../../../ui-utils/server'; import { sendMessage } from '../functions/sendMessage'; import { RateLimiter } from '../lib'; -export async function executeSendMessage(uid: IUser['_id'], message: AtLeast, previewUrls?: string[]) { +export async function executeSendMessage( + uid: IUser['_id'], + message: AtLeast, + previewUrls?: string[], + uploadIdsToConfirm?: string[], +) { if (message.tshow && !message.tmid) { throw new Meteor.Error('invalid-params', 'tshow provided but missing tmid', { method: 'sendMessage', @@ -96,7 +101,7 @@ export async function executeSendMessage(uid: IUser['_id'], message: AtLeast, previewUrls?: string[]): any; + sendMessage(message: AtLeast, previewUrls?: string[], uploadIdsToConfirm?: string[]): any; } } Meteor.methods({ - async sendMessage(message, previewUrls) { + async sendMessage(message, previewUrls, uploadIdsToConfirm) { check(message, Object); const uid = Meteor.userId(); @@ -137,7 +142,7 @@ Meteor.methods({ } try { - return await applyAirGappedRestrictionsValidation(() => executeSendMessage(uid, message, previewUrls)); + return await applyAirGappedRestrictionsValidation(() => executeSendMessage(uid, message, previewUrls, uploadIdsToConfirm)); } 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/client/lib/chats/ChatAPI.ts b/apps/meteor/client/lib/chats/ChatAPI.ts index 325073d43837..82664ffb9429 100644 --- a/apps/meteor/client/lib/chats/ChatAPI.ts +++ b/apps/meteor/client/lib/chats/ChatAPI.ts @@ -101,10 +101,10 @@ export type UploadsAPI = { wipeFailedOnes(): void; cancel(id: Upload['id']): void; send( - file: File, + file: 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'] }, + getContent?: (fileId: string[], fileUrl: string[]) => Promise, + fileContent?: { raw: Partial; encrypted?: { algorithm: string; ciphertext: string } | undefined }, ): Promise; }; diff --git a/apps/meteor/client/lib/chats/flows/uploadFiles.ts b/apps/meteor/client/lib/chats/flows/uploadFiles.ts index a16e368198bb..4b078c580bc3 100644 --- a/apps/meteor/client/lib/chats/flows/uploadFiles.ts +++ b/apps/meteor/client/lib/chats/flows/uploadFiles.ts @@ -35,7 +35,7 @@ export const uploadFiles = async (chat: ChatAPI, files: readonly File[], resetFi const uploadFile = ( file: File, extraData?: Pick & { description?: string }, - getContent?: (fileId: string, fileUrl: string) => Promise, + getContent?: (fileId: string[], fileUrl: string[]) => Promise, fileContent?: { raw: Partial; encrypted: IE2EEMessage['content'] }, ) => { chat.uploads.send( @@ -99,8 +99,10 @@ export const uploadFiles = async (chat: ChatAPI, files: readonly File[], resetFi const encryptedFile = await e2eRoom.encryptFile(file); if (encryptedFile) { - const getContent = async (_id: string, fileUrl: string): Promise => { + const getContent = async (filesId: string[], filesUrl: string[]): Promise => { const attachments = []; + const _id = filesId[0]; + const fileUrl = filesUrl[0]; const attachment: FileAttachmentProps = { title: file.name, diff --git a/apps/meteor/client/lib/chats/uploadFilesAndGetIdsandURL.ts b/apps/meteor/client/lib/chats/uploadFilesAndGetIdsandURL.ts new file mode 100644 index 000000000000..eadf9072b058 --- /dev/null +++ b/apps/meteor/client/lib/chats/uploadFilesAndGetIdsandURL.ts @@ -0,0 +1,143 @@ +import type { IUpload } from '@rocket.chat/core-typings'; +import { Emitter } from '@rocket.chat/emitter'; +import { Random } from '@rocket.chat/random'; + +import { UserAction, USER_ACTIVITIES } from '../../../app/ui/client/lib/UserAction'; +import { sdk } from '../../../app/utils/client/lib/SDKClient'; +import { getErrorMessage } from '../errorHandling'; +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'); +}; +export const uploadAndGetIds = async ( + file: File, + { + rid, + tmid, + fileContent, + }: { + rid: string; + tmid?: string; + fileContent?: { raw: Partial; encrypted?: { algorithm: string; ciphertext: string } | undefined }; + }, +): Promise<{ fileId: string; fileUrl: string }> => { + const id = Random.id(); + + updateUploads((uploads) => [ + ...uploads, + { + id, + name: fileContent?.raw.name || file.name, + percentage: 0, + }, + ]); + + try { + const result = await new Promise<{ fileId: string; fileUrl: string }>((resolve, reject) => { + const xhr = sdk.rest.upload( + `/v1/rooms.media/${rid}`, + { + file, + ...(fileContent && { + content: JSON.stringify(fileContent.encrypted), + }), + }, + { + load: (event) => { + console.log('from uploadfiles event', event); + }, + progress: (event) => { + if (!event.lengthComputable) { + return; + } + const progress = (event.loaded / event.total) * 100; + if (progress === 100) { + return; + } + + updateUploads((uploads) => + uploads.map((upload) => { + if (upload.id !== id) { + return upload; + } + + return { + ...upload, + percentage: Math.round(progress) || 0, + }; + }), + ); + }, + error: (event) => { + updateUploads((uploads) => + uploads.map((upload) => { + if (upload.id !== id) { + return upload; + } + + return { + ...upload, + percentage: 0, + error: new Error(xhr.responseText), + }; + }), + ); + reject(event); + }, + }, + ); + + xhr.onload = () => { + if (xhr.readyState === xhr.DONE && xhr.status === 200) { + const response = JSON.parse(xhr.responseText); + + resolve({ + fileId: response.file._id, + fileUrl: response.file.url, + }); + } else { + reject(new Error('File upload failed.')); + } + }; + + if (uploads.length) { + UserAction.performContinuously(rid, USER_ACTIVITIES.USER_UPLOADING, { tmid }); + } + + emitter.once(`cancelling-${id}`, () => { + xhr.abort(); + updateUploads((uploads) => uploads.filter((upload) => upload.id !== id)); + reject(new Error('Upload cancelled.')); + }); + }); + + updateUploads((uploads) => uploads.filter((upload) => upload.id !== id)); + + return result; + } catch (error: unknown) { + updateUploads((uploads) => + uploads.map((upload) => { + if (upload.id !== id) { + return upload; + } + + return { + ...upload, + percentage: 0, + error: new Error(getErrorMessage(error)), + }; + }), + ); + throw error; + } finally { + if (!uploads.length) { + UserAction.stop(rid, USER_ACTIVITIES.USER_UPLOADING, { tmid }); + } + } +}; diff --git a/apps/meteor/client/lib/chats/uploads.ts b/apps/meteor/client/lib/chats/uploads.ts index 8411a840c1d9..f95dd8f9640c 100644 --- a/apps/meteor/client/lib/chats/uploads.ts +++ b/apps/meteor/client/lib/chats/uploads.ts @@ -30,7 +30,7 @@ const wipeFailedOnes = (): void => { }; const send = async ( - file: File, + file: File[] | File, { description, msg, @@ -44,43 +44,36 @@ const send = async ( tmid?: string; t?: IMessage['t']; }, - getContent?: (fileId: string, fileUrl: string) => Promise, - fileContent?: { raw: Partial; encrypted: IE2EEMessage['content'] }, + getContent?: (fileId: string[], fileUrl: string[]) => Promise, + fileContent?: { raw: Partial; encrypted?: { algorithm: string; ciphertext: string } | undefined }, ): Promise => { + const files = Array.isArray(file) ? file : [file]; const id = Random.id(); - updateUploads((uploads) => [ ...uploads, { id, - name: fileContent?.raw.name || file.name, + name: files[0].name || fileContent?.raw.name || 'unknown', percentage: 0, }, ]); - try { - await new Promise((resolve, reject) => { + const uploadPromises = files.map((f) => { + return new Promise<{ fileId: string; fileUrl: string }>((resolve, reject) => { const xhr = sdk.rest.upload( `/v1/rooms.media/${rid}`, { - file, + file: f, ...(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; - } - updateUploads((uploads) => uploads.map((upload) => { if (upload.id !== id) { @@ -113,34 +106,46 @@ const send = async ( }, ); - xhr.onload = async () => { + xhr.onload = () => { if (xhr.readyState === xhr.DONE && xhr.status === 200) { const result = JSON.parse(xhr.responseText); - let content; - if (getContent) { - content = await getContent(result.file._id, result.file.url); - } - - await sdk.rest.post(`/v1/rooms.mediaConfirm/${rid}/${result.file._id}`, { - msg, - tmid, - description, - t, - content, - }); + resolve({ fileId: result.file._id, fileUrl: result.file.url }); } }; - if (uploads.length) { - UserAction.performContinuously(rid, USER_ACTIVITIES.USER_UPLOADING, { tmid }); - } - emitter.once(`cancelling-${id}`, () => { xhr.abort(); updateUploads((uploads) => uploads.filter((upload) => upload.id !== id)); + reject(new Error('Upload cancelled')); }); }); + }); + + try { + const results = await Promise.all(uploadPromises); + const fileIds = results.map((result) => result.fileId); + const fileUrls = results.map((result) => result.fileUrl); + if (msg === undefined) { + msg = ''; + } + + let content; + if (getContent) { + content = await getContent(fileIds, fileUrls); + } + const text: IMessage = { + rid, + _id: id, + msg: msg || description || '', + ts: new Date(), + u: { _id: id, username: id }, + _updatedAt: new Date(), + tmid, + t, + content, + }; + await sdk.call('sendMessage', text, fileUrls, fileIds); updateUploads((uploads) => uploads.filter((upload) => upload.id !== id)); } catch (error: unknown) { updateUploads((uploads) => @@ -169,9 +174,9 @@ export const createUploadsAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid?: IMes wipeFailedOnes, cancel, send: ( - file: File, + file: File[] | File, { description, msg, t }: { description?: string; msg?: string; t?: IMessage['t'] }, - getContent?: (fileId: string, fileUrl: string) => Promise, - fileContent?: { raw: Partial; encrypted: IE2EEMessage['content'] }, + getContent?: (fileId: string[], fileUrl: string[]) => Promise, + fileContent?: { raw: Partial; encrypted?: { algorithm: string; ciphertext: string } | undefined }, ): Promise => send(file, { description, msg, rid, tmid, t }, getContent, fileContent), }); diff --git a/apps/meteor/client/providers/ImageGalleryProvider.tsx b/apps/meteor/client/providers/ImageGalleryProvider.tsx index e2365e534ca3..02b04dffeeed 100644 --- a/apps/meteor/client/providers/ImageGalleryProvider.tsx +++ b/apps/meteor/client/providers/ImageGalleryProvider.tsx @@ -23,8 +23,10 @@ const ImageGalleryProvider = ({ children }: ImageGalleryProviderProps) => { return setSingleImageUrl(target.dataset.id); } if (target?.classList.contains('gallery-item')) { + const id1 = 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(id1 || target.dataset.id || id); } if (target?.classList.contains('gallery-item-container')) { return setImageId(target.dataset.id); diff --git a/apps/meteor/client/views/room/body/DropTargetOverlay.tsx b/apps/meteor/client/views/room/body/DropTargetOverlay.tsx index 2e9e16ef3201..bcb57c407c80 100644 --- a/apps/meteor/client/views/room/body/DropTargetOverlay.tsx +++ b/apps/meteor/client/views/room/body/DropTargetOverlay.tsx @@ -9,13 +9,20 @@ import { useFormatDateAndTime } from '../../../hooks/useFormatDateAndTime'; type DropTargetOverlayProps = { enabled: boolean; + setFilesToUplaod: any; reason?: ReactNode; - onFileDrop?: (files: File[]) => void; visible?: boolean; onDismiss?: () => void; }; -function DropTargetOverlay({ enabled, reason, onFileDrop, visible = true, onDismiss }: DropTargetOverlayProps): ReactElement | null { +function DropTargetOverlay({ + enabled, + setFilesToUplaod, + reason, + // onFileDrop, // not using onFileDrop anymore as we use setFilesToUplaod + visible = true, + onDismiss, +}: DropTargetOverlayProps): ReactElement | null { const { t } = useTranslation(); const handleDragLeave = useMutableCallback((event: DragEvent) => { @@ -55,8 +62,7 @@ function DropTargetOverlay({ enabled, reason, onFileDrop, visible = true, onDism } } } - - onFileDrop?.(files); + setFilesToUplaod(files); }); if (!visible) { diff --git a/apps/meteor/client/views/room/body/RoomBody.tsx b/apps/meteor/client/views/room/body/RoomBody.tsx index a592bb1fa2c0..0a418b6e0ffe 100644 --- a/apps/meteor/client/views/room/body/RoomBody.tsx +++ b/apps/meteor/client/views/room/body/RoomBody.tsx @@ -3,7 +3,7 @@ import { Box } from '@rocket.chat/fuselage'; import { useMergedRefs } from '@rocket.chat/fuselage-hooks'; import { usePermission, useRole, useSetting, useTranslation, useUser, useUserPreference } from '@rocket.chat/ui-contexts'; import type { MouseEventHandler, ReactElement, UIEvent } from 'react'; -import React, { memo, useCallback, useMemo, useRef } from 'react'; +import React, { memo, useCallback, useMemo, useRef, useState } from 'react'; import { RoomRoles } from '../../../../app/models/client'; import { isTruthy } from '../../../../lib/isTruthy'; @@ -89,6 +89,7 @@ const RoomBody = (): ReactElement => { const useRealName = useSetting('UI_Use_Real_Name') as boolean; const innerBoxRef = useRef(null); + const [filesToUpload, setFilesToUpload] = useState([]); const { wrapperRef: unreadBarWrapperRef, @@ -225,7 +226,7 @@ const RoomBody = (): ReactElement => { >
- + {roomLeader ? ( { onNavigateToPreviousMessage={handleNavigateToPreviousMessage} onNavigateToNextMessage={handleNavigateToNextMessage} onUploadFiles={handleUploadFiles} + setFilesToUpload={setFilesToUpload} + filesToUpload={filesToUpload} // TODO: send previewUrls param // previewUrls={} /> diff --git a/apps/meteor/client/views/room/body/RoomBodyV2.tsx b/apps/meteor/client/views/room/body/RoomBodyV2.tsx index cfd6cb94cb51..e6a274179d83 100644 --- a/apps/meteor/client/views/room/body/RoomBodyV2.tsx +++ b/apps/meteor/client/views/room/body/RoomBodyV2.tsx @@ -3,7 +3,7 @@ import { Box } from '@rocket.chat/fuselage'; import { useMergedRefs } from '@rocket.chat/fuselage-hooks'; import { usePermission, useRole, useSetting, useTranslation, useUser, useUserPreference } from '@rocket.chat/ui-contexts'; import type { MouseEventHandler, ReactElement } from 'react'; -import React, { memo, useCallback, useMemo, useRef } from 'react'; +import React, { memo, useCallback, useMemo, useRef, useState } from 'react'; import { isTruthy } from '../../../../lib/isTruthy'; import { CustomScrollbars } from '../../../components/CustomScrollbars'; @@ -84,6 +84,7 @@ const RoomBody = (): ReactElement => { }, [allowAnonymousRead, canPreviewChannelRoom, room, subscribed]); const innerBoxRef = useRef(null); + const [filesToUpload, setFilesToUpload] = useState([]); const { wrapperRef: unreadBarWrapperRef, @@ -207,7 +208,7 @@ const RoomBody = (): ReactElement => { >
- +
{uploads.map((upload) => ( @@ -285,6 +286,8 @@ const RoomBody = (): ReactElement => { onNavigateToPreviousMessage={handleNavigateToPreviousMessage} onNavigateToNextMessage={handleNavigateToNextMessage} onUploadFiles={handleUploadFiles} + setFilesToUpload={setFilesToUpload} + filesToUpload={filesToUpload} // TODO: send previewUrls param // previewUrls={} /> diff --git a/apps/meteor/client/views/room/composer/ComposerMessage.tsx b/apps/meteor/client/views/room/composer/ComposerMessage.tsx index 3f37f7a40443..c74997972922 100644 --- a/apps/meteor/client/views/room/composer/ComposerMessage.tsx +++ b/apps/meteor/client/views/room/composer/ComposerMessage.tsx @@ -11,6 +11,8 @@ import ComposerSkeleton from './ComposerSkeleton'; import MessageBox from './messageBox/MessageBox'; export type ComposerMessageProps = { + filesToUpload: File[]; + setFilesToUpload: any; tmid?: IMessage['_id']; children?: ReactNode; subscription?: ISubscription; @@ -83,7 +85,6 @@ const ComposerMessage = ({ tmid, onSend, ...props }: ComposerMessageProps): Reac const publicationReady = useReactiveValue( useCallback(() => LegacyRoomManager.getOpenedRoomByRid(room._id)?.streamActive ?? false, [room._id]), ); - if (!publicationReady) { return ; } diff --git a/apps/meteor/client/views/room/composer/messageBox/FilePreview/FilePreview.tsx b/apps/meteor/client/views/room/composer/messageBox/FilePreview/FilePreview.tsx new file mode 100644 index 000000000000..959f30aaccc4 --- /dev/null +++ b/apps/meteor/client/views/room/composer/messageBox/FilePreview/FilePreview.tsx @@ -0,0 +1,63 @@ +import type { ReactElement } from 'react'; +import React from 'react'; + +import { isIE11 } from '../../../../../lib/utils/isIE11'; +import GenericPreview from './GenericPreview'; +import MediaPreview from './MediaPreview'; + +export enum FilePreviewType { + IMAGE = 'image', + AUDIO = 'audio', + // VIDEO = 'video', // currently showing it in simple generic view +} + +const getFileType = (fileType: File['type']): FilePreviewType | undefined => { + if (!fileType) { + return; + } + for (const type of Object.values(FilePreviewType)) { + if (fileType.indexOf(type) > -1) { + return type; + } + } +}; + +const shouldShowMediaPreview = (file: File, fileType: FilePreviewType | undefined): boolean => { + if (!fileType) { + return false; + } + if (isIE11) { + return false; + } + // Avoid preview if file size bigger than 10mb + if (file.size > 10000000) { + return false; + } + if (!Object.values(FilePreviewType).includes(fileType)) { + return false; + } + return true; +}; + +type FilePreviewProps = { + file: File; + key: number; + index: number; + onRemove: (index: number) => void; +}; + +const FilePreview = ({ file, index, onRemove }: FilePreviewProps): ReactElement => { + const fileType = getFileType(file.type); + + const handleRemove = () => { + onRemove(index); + }; + + if (shouldShowMediaPreview(file, fileType)) { + return ; + } + + return ; +}; + +export default FilePreview; diff --git a/apps/meteor/client/views/room/composer/messageBox/FilePreview/GenericPreview.tsx b/apps/meteor/client/views/room/composer/messageBox/FilePreview/GenericPreview.tsx new file mode 100644 index 000000000000..e8a4112a8bf2 --- /dev/null +++ b/apps/meteor/client/views/room/composer/messageBox/FilePreview/GenericPreview.tsx @@ -0,0 +1,77 @@ +import { Box, Icon } from '@rocket.chat/fuselage'; +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; + +import { formatBytes } from '../../../../../lib/utils/formatBytes'; + +type GenericPreviewProps = { + file: File; + index: number; + onRemove: (index: number) => void; +}; + +const GenericPreview = ({ file, index, onRemove }: GenericPreviewProps): ReactElement => { + const [isHovered, setIsHovered] = useState(false); + + const handleMouseEnter = () => { + setIsHovered(true); + }; + + const handleMouseLeave = () => { + setIsHovered(false); + }; + + const buttonStyle: React.CSSProperties = { + position: 'absolute' as const, + right: '-10px', + top: '-8px', + backgroundColor: '#6d6c6c', + display: isHovered ? 'block' : 'none', + cursor: 'pointer', + zIndex: 1, + color: 'white', + borderRadius: '100%', + }; + return ( + + {/* currently using div */} +
+ {file.name.split('.')[1] === 'mp4' || file.name.split('.')[1] === 'webm' ? ( + + ) : ( + + )} +
+ + {`${file.name.split('.')[0]} - ${formatBytes(file.size, 2)}`} + {`${file.name.split('.')[1]}`} + + {/*
*/} + onRemove(index)} /> + + ); +}; +export default GenericPreview; diff --git a/apps/meteor/client/views/room/composer/messageBox/FilePreview/ImagePreview.tsx b/apps/meteor/client/views/room/composer/messageBox/FilePreview/ImagePreview.tsx new file mode 100644 index 000000000000..9c4e54c7df6f --- /dev/null +++ b/apps/meteor/client/views/room/composer/messageBox/FilePreview/ImagePreview.tsx @@ -0,0 +1,62 @@ +import { Box, Icon } from '@rocket.chat/fuselage'; +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; + +import GenericPreview from './GenericPreview'; +import PreviewSkeleton from './PreviewSkeleton'; + +type ImagePreviewProps = { + url: string; + file: File; + onRemove: (index: number) => void; + index: number; +}; + +const ImagePreview = ({ url, file, onRemove, index }: ImagePreviewProps): ReactElement => { + const [error, setError] = useState(false); + const [loading, setLoading] = useState(true); + const [isHovered, setIsHovered] = useState(false); + + const handleLoad = (): void => setLoading(false); + const handleError = (): void => { + setLoading(false); + setError(true); + }; + + const handleMouseEnter = (): void => setIsHovered(true); + const handleMouseLeave = (): void => setIsHovered(false); + + const buttonStyle: React.CSSProperties = { + position: 'absolute' as const, + right: '-10px', + top: '-8px', + backgroundColor: '#6d6c6c', + display: isHovered ? 'block' : 'none', + cursor: 'pointer', + zIndex: 1, + color: 'white', + borderRadius: '100%', + }; + + if (error) { + return ; + } + + return ( + + {loading && } + + onRemove(index)} /> + + ); +}; + +export default ImagePreview; diff --git a/apps/meteor/client/views/room/composer/messageBox/FilePreview/MediaPreview.tsx b/apps/meteor/client/views/room/composer/messageBox/FilePreview/MediaPreview.tsx new file mode 100644 index 000000000000..a5eed5a03db3 --- /dev/null +++ b/apps/meteor/client/views/room/composer/messageBox/FilePreview/MediaPreview.tsx @@ -0,0 +1,78 @@ +import { AudioPlayer, Box, Icon } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import React, { useEffect, useState, memo } from 'react'; + +// import { userAgentMIMETypeFallback } from '../../../../lib/utils/userAgentMIMETypeFallback'; // as currently showing video in generic view +import { FilePreviewType } from './FilePreview'; +import ImagePreview from './ImagePreview'; +import PreviewSkeleton from './PreviewSkeleton'; + +type ReaderOnloadCallback = (url: FileReader['result']) => void; + +const readFileAsDataURL = (file: File, callback: ReaderOnloadCallback): void => { + const reader = new FileReader(); + reader.onload = (e): void => callback(e?.target?.result || null); + + return reader.readAsDataURL(file); +}; + +const useFileAsDataURL = (file: File): [loaded: boolean, url: null | FileReader['result']] => { + const [loaded, setLoaded] = useState(false); + const [url, setUrl] = useState(null); + + useEffect(() => { + setLoaded(false); + readFileAsDataURL(file, (url) => { + setUrl(url); + setLoaded(true); + }); + }, [file]); + return [loaded, url]; +}; + +type MediaPreviewProps = { + file: File; + fileType: FilePreviewType; + onRemove: (index: number) => void; + index: number; +}; + +const MediaPreview = ({ file, fileType, onRemove, index }: MediaPreviewProps): ReactElement => { + const [loaded, url] = useFileAsDataURL(file); + const t = useTranslation(); + + if (!loaded) { + return ; + } + + if (typeof url !== 'string') { + return ( + + + {t('FileUpload_Cannot_preview_file')} + + ); + } + + if (fileType === FilePreviewType.IMAGE) { + return ; + } + + // if (fileType === FilePreviewType.VIDEO) { + // return ( + // + // + // {t('Browser_does_not_support_video_element')} + // + // ); + // } + + if (fileType === FilePreviewType.AUDIO) { + return ; + } + + throw new Error('Wrong props provided for MediaPreview'); +}; + +export default memo(MediaPreview); diff --git a/apps/meteor/client/views/room/composer/messageBox/FilePreview/PreviewSkeleton.tsx b/apps/meteor/client/views/room/composer/messageBox/FilePreview/PreviewSkeleton.tsx new file mode 100644 index 000000000000..72017e26e9f5 --- /dev/null +++ b/apps/meteor/client/views/room/composer/messageBox/FilePreview/PreviewSkeleton.tsx @@ -0,0 +1,7 @@ +import { Skeleton } from '@rocket.chat/fuselage'; +import type { ReactElement } from 'react'; +import React from 'react'; + +const PreviewSkeleton = (): ReactElement => ; + +export default PreviewSkeleton; diff --git a/apps/meteor/client/views/room/composer/messageBox/HandleFileUploads.ts b/apps/meteor/client/views/room/composer/messageBox/HandleFileUploads.ts new file mode 100644 index 000000000000..5cff06239d33 --- /dev/null +++ b/apps/meteor/client/views/room/composer/messageBox/HandleFileUploads.ts @@ -0,0 +1,192 @@ +import type { IMessage, IUpload, IE2EEMessage, FileAttachmentProps } from '@rocket.chat/core-typings'; + +import { e2e } from '../../../../../app/e2e/client'; +import { getFileExtension } from '../../../../../lib/utils/getFileExtension'; +import { prependReplies } from '../../../../lib/utils/prependReplies'; + +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 uploadFile = ( + file: File[] | File, + chat: any, + extraData?: Pick & { msg?: string }, + getContent?: (fileId: string[], fileUrl: string[]) => Promise, + fileContent?: { raw: Partial; encrypted?: { algorithm: string; ciphertext: string } | undefined }, + setFilesToUpload?: (files: File[]) => void, +) => { + if (!chat) { + console.error('Chat context not found'); + return; + } + chat.uploads.send( + file, + { + ...extraData, + }, + getContent, + fileContent, + ); + chat.composer?.clear(); + setFilesToUpload?.([]); +}; + +const handleEncryptedFilesShared = async ( + filesToUpload: File[], + chat: any, + msg: string, + e2eRoom: any, + setFilesToUpload?: (files: File[]) => void, +) => { + const encryptedFilesarray: any = await Promise.all(filesToUpload.map((file) => e2eRoom.encryptFile(file))); + + const filesarray = encryptedFilesarray.map((file: any) => file?.file); + + const imgDimensions = await Promise.all( + filesToUpload.map((file) => { + if (/^image\/.+/.test(file.type)) { + return getHeightAndWidthFromDataUrl(window.URL.createObjectURL(file)); + } + return null; + }), + ); + + if (encryptedFilesarray[0]) { + const getContent = async (_id: string[], fileUrl: string[]): Promise => { + const attachments = []; + const arrayoffiles = []; + for (let i = 0; i < _id.length; i++) { + const attachment: FileAttachmentProps = { + title: filesToUpload[i].name, + type: 'file', + title_link: fileUrl[i], + title_link_download: true, + encryption: { + key: encryptedFilesarray[i].key, + iv: encryptedFilesarray[i].iv, + }, + hashes: { + sha256: encryptedFilesarray[i].hash, + }, + }; + + if (/^image\/.+/.test(filesToUpload[i].type)) { + const dimensions = imgDimensions[i]; + attachments.push({ + ...attachment, + image_url: fileUrl[i], + image_type: filesToUpload[i].type, + image_size: filesToUpload[i].size, + ...(dimensions && { + image_dimensions: dimensions, + }), + }); + } else if (/^audio\/.+/.test(filesToUpload[i].type)) { + attachments.push({ + ...attachment, + audio_url: fileUrl[i], + audio_type: filesToUpload[i].type, + audio_size: filesToUpload[i].size, + }); + } else if (/^video\/.+/.test(filesToUpload[i].type)) { + attachments.push({ + ...attachment, + video_url: fileUrl[i], + video_type: filesToUpload[i].type, + video_size: filesToUpload[i].size, + }); + } else { + attachments.push({ + ...attachment, + size: filesToUpload[i].size, + format: getFileExtension(filesToUpload[i].name), + }); + } + + const files = { + _id: _id[i], + name: filesToUpload[i].name, + type: filesToUpload[i].type, + size: filesToUpload[i].size, + }; + arrayoffiles.push(files); + } + + return e2eRoom.encryptMessageContent({ + attachments, + files: arrayoffiles, + file: filesToUpload[0], + msg, + }); + }; + + const fileContentData = { + type: filesToUpload[0].type, + typeGroup: filesToUpload[0].type.split('/')[0], + name: filesToUpload[0].name, + encryption: { + key: encryptedFilesarray[0].key, + iv: encryptedFilesarray[0].iv, + }, + hashes: { + sha256: encryptedFilesarray[0].hash, + }, + }; + + const fileContent = { + raw: fileContentData, + encrypted: await e2eRoom.encryptMessageContent(fileContentData), + }; + uploadFile( + filesarray, + chat, + { + t: 'e2e', + }, + getContent, + fileContent, + setFilesToUpload, + ); + } +}; +export const handleSendFiles = async (filesToUpload: File[], chat: any, room: any, setFilesToUpload?: (files: File[]) => void) => { + if (!chat || !room) { + return; + } + const replies = chat.composer?.quotedMessages.get() ?? []; + const msg = await prependReplies(chat.composer?.text || '', replies); + + filesToUpload.forEach((file) => { + Object.defineProperty(file, 'name', { + writable: true, + value: file.name, + }); + }); + + const e2eRoom = await e2e.getInstanceByRoomId(room._id); + + if (!e2eRoom) { + uploadFile(filesToUpload, chat, { msg }); + setFilesToUpload?.([]); + return; + } + + const shouldConvertSentMessages = await e2eRoom.shouldConvertSentMessages({ msg }); + + if (!shouldConvertSentMessages) { + uploadFile(filesToUpload, chat, { msg }); + setFilesToUpload?.([]); + return; + } + handleEncryptedFilesShared(filesToUpload, chat, msg, e2eRoom, setFilesToUpload); + chat.composer?.clear(); +}; diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx index 596b1781697f..e88be84004b2 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx @@ -1,5 +1,6 @@ /* eslint-disable complexity */ import type { IMessage, ISubscription } from '@rocket.chat/core-typings'; +import { Box } from '@rocket.chat/fuselage'; import { useContentBoxSize, useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { MessageComposerAction, @@ -11,10 +12,11 @@ import { MessageComposerToolbarSubmit, MessageComposerButton, } from '@rocket.chat/ui-composer'; -import { useTranslation, useUserPreference, useLayout, useSetting } from '@rocket.chat/ui-contexts'; +import { useTranslation, useUserPreference, useLayout, useSetting, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import { useMutation } from '@tanstack/react-query'; +import fileSize from 'filesize'; import type { ReactElement, MouseEventHandler, FormEvent, ClipboardEventHandler, MouseEvent } from 'react'; -import React, { memo, useRef, useReducer, useCallback } from 'react'; +import React, { memo, useRef, useReducer, useCallback, useState, useEffect } from 'react'; import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { createComposerAPI } from '../../../../../app/ui-message/client/messageBox/createComposerAPI'; @@ -38,6 +40,8 @@ import { useAutoGrow } from '../RoomComposer/hooks/useAutoGrow'; import { useComposerBoxPopup } from '../hooks/useComposerBoxPopup'; import { useEnablePopupPreview } from '../hooks/useEnablePopupPreview'; import { useMessageComposerMergedRefs } from '../hooks/useMessageComposerMergedRefs'; +import FilePreview from './FilePreview/FilePreview'; +import { handleSendFiles } from './HandleFileUploads'; import MessageBoxActionsToolbar from './MessageBoxActionsToolbar'; import MessageBoxFormattingToolbar from './MessageBoxFormattingToolbar'; import MessageBoxHint from './MessageBoxHint'; @@ -77,6 +81,8 @@ const a: any[] = []; const getEmptyArray = () => a; type MessageBoxProps = { + filesToUpload: File[]; + setFilesToUpload: any; tmid?: IMessage['_id']; onSend?: (params: { value: string; tshow?: boolean; previewUrls?: string[]; isSlashCommandAllowed?: boolean }) => Promise; onJoin?: () => Promise; @@ -93,7 +99,11 @@ type MessageBoxProps = { isEmbedded?: boolean; }; +type HandleFilesToUpload = (filesList: File[], resetFileInput?: () => void) => void; + const MessageBox = ({ + filesToUpload, + setFilesToUpload, tmid, onSend, onJoin, @@ -114,6 +124,77 @@ const MessageBox = ({ const composerPlaceholder = useMessageBoxPlaceholder(t('Message'), room); const [typing, setTyping] = useReducer(reducer, false); + const [isUploading, setIsUploading] = useState(false); + + const dispatchToastMessage = useToastMessageDispatch(); + const maxFileSize = useSetting('FileUpload_MaxFileSize') as number; + + const handleFilesToUpload: HandleFilesToUpload = (filesList: File[], resetFileInput?: () => void) => { + setFilesToUpload((prevFiles: File[]) => { + let newFilesToUpload = [...prevFiles, ...filesList]; + if (newFilesToUpload.length > 6) { + newFilesToUpload = newFilesToUpload.slice(0, 6); + dispatchToastMessage({ + type: 'error', + message: "You can't upload more than 6 files at once. Only the first 6 files will be uploaded.", + }); + } + let nameError = 0; + let sizeError = 0; + + const validFiles = newFilesToUpload.filter((queuedFile) => { + const { name, size } = queuedFile; + + if (!name) { + nameError = 1; + return false; + } + + if (maxFileSize > -1 && (size || 0) > maxFileSize) { + sizeError = 1; + return false; + } + + return true; + }); + + if (nameError) { + dispatchToastMessage({ + type: 'error', + message: t('error-the-field-is-required', { field: t('Name') }), + }); + } + + if (sizeError) { + dispatchToastMessage({ + type: 'error', + message: `${t('File_exceeds_allowed_size_of_bytes', { size: fileSize(maxFileSize) })}`, + }); + } + + setIsUploading(validFiles.length > 0); + return validFiles; + }); + + resetFileInput?.(); + }; + const handleRemoveFile = (indexToRemove: number) => { + const updatedFiles = [...filesToUpload]; + + const element = document.getElementById(`file-preview-${indexToRemove}`); + if (element) { + element.style.transition = 'opacity 0.3s ease-in-out'; + element.style.opacity = '0'; + } + + setTimeout(() => { + updatedFiles.splice(indexToRemove, 1); + setFilesToUpload(updatedFiles); + if (element) { + element.style.opacity = '1'; + } + }, 300); + }; const { isMobile } = useLayout(); const sendOnEnterBehavior = useUserPreference<'normal' | 'alternative' | 'desktop'>('sendOnEnter') || isMobile; @@ -156,6 +237,10 @@ const MessageBox = ({ }); const handleSendMessage = useMutableCallback(() => { + if (isUploading) { + setIsUploading(!isUploading); + return handleSendFiles(filesToUpload, chat, room, setFilesToUpload); + } const text = chat.composer?.text ?? ''; chat.composer?.clear(); clearPopup(); @@ -250,6 +335,13 @@ const MessageBox = ({ const isEditing = useSyncExternalStore(chat.composer?.editing.subscribe ?? emptySubscribe, chat.composer?.editing.get ?? getEmptyFalse); + useEffect(() => { + setIsUploading(filesToUpload.length > 0); + if (isEditing) { + setFilesToUpload([]); + } + }, [filesToUpload, isEditing, setFilesToUpload]); + const isRecordingAudio = useSyncExternalStore( chat.composer?.recording.subscribe ?? emptySubscribe, chat.composer?.recording.get ?? getEmptyFalse, @@ -400,6 +492,29 @@ const MessageBox = ({ aria-activedescendant={ariaActiveDescendant} />
+ {isUploading && ( + <> + + {filesToUpload.map((file, index) => ( +
+ +
+ ))} +
+ + )} @@ -439,10 +556,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 38396844866b..253539930d5a 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,8 @@ type MessageBoxActionsToolbarProps = { isRecording: boolean; rid: IRoom['_id']; tmid?: IMessage['_id']; + isEditing: boolean; + handleFiles: (filesList: File[], resetFileInput?: () => void) => void; }; const isHidden = (hiddenActions: Array, action: GenericMenuItemProps) => { @@ -45,6 +47,8 @@ const MessageBoxActionsToolbar = ({ tmid, variant = 'large', isMicrophoneDenied, + isEditing = false, + handleFiles, }: MessageBoxActionsToolbarProps) => { const t = useTranslation(); const chatContext = useChat(); @@ -57,7 +61,7 @@ const MessageBoxActionsToolbar = ({ const audioMessageAction = useAudioMessageAction(!canSend || typing || isRecording || isMicrophoneDenied, isMicrophoneDenied); const videoMessageAction = useVideoMessageAction(!canSend || typing || isRecording); - const fileUploadAction = useFileUploadAction(!canSend || typing || isRecording); + const fileUploadAction = useFileUploadAction(!canSend || isRecording || isEditing, handleFiles); const webdavActions = useWebdavActions(); const createDiscussionAction = useCreateDiscussionAction(room); const shareLocationAction = useShareLocationAction(room, tmid); 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 f576aba9803c..83bd5189eb4a 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 @@ -3,15 +3,16 @@ import { useTranslation, useSetting } from '@rocket.chat/ui-contexts'; import { useEffect } from 'react'; import { useFileInput } from '../../../../../../hooks/useFileInput'; -import { useChat } from '../../../../contexts/ChatContext'; const fileInputProps = { type: 'file', multiple: true }; -export const useFileUploadAction = (disabled: boolean): GenericMenuItemProps => { +export const useFileUploadAction = ( + disabled: boolean, + handleFiles: (filesList: File[], resetFileInput?: () => void) => void, +): GenericMenuItemProps => { const t = useTranslation(); const fileUploadEnabled = useSetting('FileUpload_Enabled'); const fileInputRef = useFileInput(fileInputProps); - const chat = useChat(); useEffect(() => { const resetFileInput = () => { @@ -30,12 +31,12 @@ export const useFileUploadAction = (disabled: boolean): GenericMenuItemProps => }); return file; }); - chat?.flows.uploadFiles(filesToUpload, resetFileInput); + handleFiles(filesToUpload, resetFileInput); }; fileInputRef.current?.addEventListener('change', handleUploadChange); return () => fileInputRef?.current?.removeEventListener('change', handleUploadChange); - }, [chat, fileInputRef]); + }, [fileInputRef]); const handleUpload = () => { fileInputRef?.current?.click(); 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 7afe0b442bbc..14b024014afa 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx +++ b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx @@ -24,6 +24,7 @@ type ThreadChatProps = { const ThreadChat = ({ mainMessage }: ThreadChatProps) => { const [fileUploadTriggerProps, fileUploadOverlayProps] = useFileUploadDropTarget(); + const [filesToUpload, setFilesToUpload] = useState([]); const sendToChannelPreference = useUserPreference<'always' | 'never' | 'default'>('alsoSendThreadToChannel'); @@ -95,7 +96,7 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps) => { return ( - + { onNavigateToPreviousMessage={handleNavigateToPreviousMessage} onNavigateToNextMessage={handleNavigateToNextMessage} onUploadFiles={handleUploadFiles} + setFilesToUpload={setFilesToUpload} + filesToUpload={filesToUpload} tshow={sendToChannel} >