Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/young-buses-grow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': minor
---

feat: add image cropping functionality for file uploads; users can now crop images before sending them in chat
17 changes: 10 additions & 7 deletions apps/meteor/client/lib/chats/flows/uploadFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,11 @@ export const uploadFiles = async (chat: ChatAPI, files: readonly File[], resetFi
imperativeModal.close();
uploadNextFile();
},
onSubmit: async (fileName, description): Promise<void> => {
Object.defineProperty(file, 'name', {

onSubmit: async (fileName: string, description?: string, croppedFile?: File): Promise<void> => {
const fileToUpload = croppedFile ?? file;

Object.defineProperty(fileToUpload, 'name', {
writable: true,
value: fileName,
});
Expand All @@ -80,30 +83,30 @@ export const uploadFiles = async (chat: ChatAPI, files: readonly File[], resetFi
const e2eRoom = await e2e.getInstanceByRoomId(room._id);

if (!e2eRoom) {
uploadFile(file, { description });
uploadFile(fileToUpload, { description });
return;
}

if (!settings.peek('E2E_Enable_Encrypt_Files')) {
uploadFile(file, { description });
uploadFile(fileToUpload, { description });
return;
}

const shouldConvertSentMessages = await e2eRoom.shouldConvertSentMessages({ msg });

if (!shouldConvertSentMessages) {
uploadFile(file, { description });
uploadFile(fileToUpload, { description });
return;
}

const encryptedFile = await e2eRoom.encryptFile(file);
const encryptedFile = await e2eRoom.encryptFile(fileToUpload);

if (encryptedFile) {
const getContent = async (_id: string, fileUrl: string): Promise<IE2EEMessage['content']> => {
const attachments = [];

const attachment: FileAttachmentProps = {
title: file.name,
title: fileToUpload.name,
type: 'file',
description,
title_link: fileUrl,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { Button, ButtonGroup, Box } from '@rocket.chat/fuselage';
import { useTranslation } from '@rocket.chat/ui-contexts';
import { useState, useCallback, useEffect, type ReactElement } from 'react';
import type { Area } from 'react-easy-crop';
import Cropper from 'react-easy-crop';

import GenericPreview from './GenericPreview';
import MediaPreview from './MediaPreview';
import { isIE11 } from '../../../../lib/utils/isIE11';

export enum FilePreviewType {
IMAGE = 'image',
AUDIO = 'audio',
VIDEO = 'video',
}

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;
};

async function getCroppedFile(imageSrc: string, crop: Area, original: File): Promise<File | null> {
const image = new Image();
image.src = imageSrc;
await new Promise((resolve, reject) => {
image.onload = resolve;
image.onerror = reject;
});

const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return null;

canvas.width = Math.round(crop.width);
canvas.height = Math.round(crop.height);

ctx.drawImage(
image,
Math.round(crop.x),
Math.round(crop.y),
Math.round(crop.width),
Math.round(crop.height),
0,
0,
Math.round(crop.width),
Math.round(crop.height),
);

const mime = original.type && original.type.startsWith('image/') ? original.type : 'image/jpeg';
const blob: Blob | null = await new Promise((resolve) => canvas.toBlob(resolve, mime, 0.95));
if (!blob) return null;

const dot = original.name.lastIndexOf('.');
const base = dot > 0 ? original.name.slice(0, dot) : original.name;
const ext = dot > 0 ? original.name.slice(dot) : '.jpg';
const name = `${base}-cropped${ext}`;

return new File([blob], name, { type: blob.type, lastModified: Date.now() });
}

type FilePreviewProps = {
file: File;
onFileChange?: (file: File) => void;
onCancel?: () => void;
};

const CropFilePreview = ({ file, onFileChange, onCancel }: FilePreviewProps): ReactElement => {
const t = useTranslation();
const fileType = getFileType(file.type);
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [croppedArea, setCroppedArea] = useState<Area | null>(null);
const [croppedImageURL, setCroppedImageURL] = useState<string | null>(null);
const [objectUrl, setObjectUrl] = useState<string | null>(null);

useEffect(() => {
const url = URL.createObjectURL(file);
setObjectUrl(url);

return () => {
URL.revokeObjectURL(url);
if (croppedImageURL) {
URL.revokeObjectURL(croppedImageURL);
}
};
}, [file, croppedImageURL]);

const onCropComplete = useCallback((_area: Area, croppedAreaPixels: Area) => {
setCroppedArea(croppedAreaPixels);
}, []);

const handleCropDone = useCallback(async () => {
if (!croppedArea || !objectUrl) return;
const croppedFile = await getCroppedFile(objectUrl, croppedArea, file);
if (!croppedFile) return;

const previewUrl = URL.createObjectURL(croppedFile);
setCroppedImageURL(previewUrl);
onFileChange?.(croppedFile);
}, [file, croppedArea, objectUrl, onFileChange]);

if (croppedImageURL) {
return <img src={croppedImageURL} alt={t('Cropped_preview')} style={{ maxWidth: '100%', maxHeight: 320 }} />;
}

if (!objectUrl) {
return <GenericPreview file={file} />;
}

if (fileType === FilePreviewType.IMAGE && onCancel) {
return (
<Box display='flex' flexDirection='column' alignItems='center'>
<Box position='relative' width='100%' height='300px' borderRadius='x8' overflow='hidden' marginBlockEnd={16}>
<Cropper
image={objectUrl}
crop={crop}
zoom={zoom}
aspect={undefined}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
style={{
containerStyle: {
width: '100%',
height: '100%',
position: 'relative',
},
}}
/>
</Box>
<ButtonGroup>
<Button secondary onClick={onCancel}>
{t('Cancel')}
</Button>
<Button primary onClick={handleCropDone}>
{t('Apply')}
</Button>
</ButtonGroup>
</Box>
);
}

if (shouldShowMediaPreview(file, fileType)) {
return <MediaPreview file={file} fileType={fileType as FilePreviewType} />;
}

return <GenericPreview file={file} />;
};

export default CropFilePreview;
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,21 @@ import {
ModalFooterControllers,
} from '@rocket.chat/fuselage';
import { useAutoFocus, useMergedRefs } from '@rocket.chat/fuselage-hooks';
import { useFeaturePreview } from '@rocket.chat/ui-client';
import { useToastMessageDispatch, useTranslation, useSetting } from '@rocket.chat/ui-contexts';
import fileSize from 'filesize';
import type { ReactElement, ComponentProps } from 'react';
import { memo, useCallback, useEffect, useId } from 'react';
import { memo, useCallback, useEffect, useId, useState } from 'react';
import { useForm } from 'react-hook-form';

import CropFilePreview from './CropFilePreview';
import FilePreview from './FilePreview';
import { fileUploadIsValidContentType } from '../../../../../app/utils/client/restrictions';
import { getMimeTypeFromFileName } from '../../../../../app/utils/lib/mimeTypes';

type FileUploadModalProps = {
onClose: () => void;
onSubmit: (name: string, description?: string) => void;
onSubmit: (name: string, description: string | undefined, file: File) => void;
file: File;
fileName: string;
fileDescription?: string;
Expand All @@ -45,6 +47,8 @@ const FileUploadModal = ({
invalidContentType,
showDescription = true,
}: FileUploadModalProps): ReactElement => {
const [currentFile, setCurrentFile] = useState<File>(file);
const [isCropping, setIsCropping] = useState(false);
const {
register,
handleSubmit,
Expand All @@ -55,6 +59,7 @@ const FileUploadModal = ({
const dispatchToastMessage = useToastMessageDispatch();
const maxMsgSize = useSetting('Message_MaxAllowedSize', 5000);
const maxFileSize = useSetting('FileUpload_MaxFileSize', 104857600);
const enablePreview = useFeaturePreview('imageCropPreview');

const isDescriptionValid = (description: string) =>
description.length >= maxMsgSize ? t('Cannot_upload_file_character_limit', { count: maxMsgSize }) : true;
Expand All @@ -81,7 +86,7 @@ const FileUploadModal = ({
});
}

onSubmit(name, description);
onSubmit(name, description, currentFile);
};

useEffect(() => {
Expand Down Expand Up @@ -114,6 +119,27 @@ const FileUploadModal = ({

const descriptionRef = useMergedRefs(ref, autoFocusRef);

if (isCropping) {
return (
<Modal>
<ModalHeader>
<ModalTitle>{t('Crop_Image')}</ModalTitle>
<ModalClose onClick={() => setIsCropping(false)} />
</ModalHeader>
<ModalContent>
<CropFilePreview
file={currentFile}
onFileChange={(newFile) => {
setCurrentFile(newFile);
setIsCropping(false);
}}
onCancel={() => setIsCropping(false)}
/>
</ModalContent>
</Modal>
);
}

return (
<Modal
aria-labelledby={`${fileUploadFormId}-title`}
Expand All @@ -128,8 +154,13 @@ const FileUploadModal = ({
</ModalHeader>
<ModalContent>
<Box display='flex' maxHeight='x360' w='full' justifyContent='center' alignContent='center' mbe={16}>
<FilePreview file={file} />
<FilePreview file={currentFile} />
</Box>
{enablePreview && currentFile.type.startsWith('image/') && (
<Button small onClick={() => setIsCropping(true)} mb={16}>
{t('Crop')}
</Button>
)}
<FieldGroup>
<Field>
<FieldLabel htmlFor={fileNameField}>{t('Upload_file_name')}</FieldLabel>
Expand Down
1 change: 1 addition & 0 deletions apps/meteor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@
"react": "~18.3.1",
"react-aria": "~3.37.0",
"react-dom": "~18.3.1",
"react-easy-crop": "^5.5.0",
"react-error-boundary": "^3.1.4",
"react-hook-form": "~7.45.4",
"react-i18next": "~13.2.2",
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@
},
"dependencies": {
"@types/stream-buffers": "^3.0.8",
"node-gyp": "^10.2.0"
"node-gyp": "^10.2.0",
"react-easy-crop": "^5.5.0"
},
"devDependencies": {
"@changesets/cli": "^2.27.11",
Expand Down
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/en.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -1567,6 +1567,9 @@
"Created_at_s_by_s": "Created at <strong>%s</strong> by <strong>%s</strong>",
"Created_at_s_by_s_triggered_by_s": "Created at <strong>%s</strong> by <strong>%s</strong> triggered by <strong>%s</strong>",
"Created_by": "Created by",
"Crop": "Crop",
"Crop_Image": "Crop Image",
"Cropped_preview": "Cropped preview",
"Crowd_Connection_successful": "Crowd Connection Successful",
"Crowd_Remove_Orphaned_Users": "Remove Orphaned Users",
"Crowd_sync_interval_Description": "The interval between synchronizations. Example `every 24 hours` or `on the first day of the week`, more examples at [Cron Text Parser](http://bunkat.github.io/later/parsers.html#text)",
Expand Down Expand Up @@ -2568,6 +2571,8 @@
"Ignore": "Ignore",
"Ignore_Two_Factor_Authentication": "Ignore Two Factor Authentication",
"Ignored": "Ignored",
"Image_crop": "Image Cropping",
"Image_crop_description": "Crop images before sending them in chat to remove unwanted parts and send only what matters.",
"Image_gallery": "Image gallery",
"Images": "Images",
"Impersonate_next_agent_from_queue": "Impersonate next agent from queue",
Expand Down
14 changes: 13 additions & 1 deletion packages/ui-client/src/hooks/useFeaturePreviewList.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { TranslationKey } from '@rocket.chat/ui-contexts';

export type FeaturesAvailable = 'secondarySidebar' | 'expandableMessageComposer';
export type FeaturesAvailable =
| 'secondarySidebar'
| 'expandableMessageComposer'
| 'imageCropPreview';

export type FeaturePreviewProps = {
name: FeaturesAvailable;
Expand All @@ -19,6 +22,15 @@ export type FeaturePreviewProps = {

// TODO: Move the features preview array to another directory to be accessed from both BE and FE.
export const defaultFeaturesPreview: FeaturePreviewProps[] = [
{
name: 'imageCropPreview',
i18n: 'Image_crop',
description: 'Image_crop_description',
group: 'Message',
imageUrl: 'images/featurePreview/image-crop.png',
value: false,
enabled: true,
},
{
name: 'secondarySidebar',
i18n: 'Filters_and_secondary_sidebar',
Expand Down
Loading