[IMPROVE] Create thumbnails from uploaded images (#20907)

Co-authored-by: Diego Sampaio <chinello@gmail.com>
Co-authored-by: Tasso Evangelista <tasso.evangelista@rocket.chat>
Co-authored-by: Guilherme Gazzo <guilhermegazzo@gmail.com>
pull/21779/head^2
Kevin Aleman 4 years ago committed by GitHub
parent 3c362a0d60
commit 3e68e780bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 39
      app/file-upload/server/lib/FileUpload.js
  2. 91
      app/file-upload/server/methods/sendFileMessage.js
  3. 130
      app/file-upload/server/methods/sendFileMessage.ts
  4. 29
      app/lib/server/functions/deleteMessage.ts
  5. 29
      app/lib/server/startup/settings.js
  6. 38
      app/ui/client/views/app/photoswipe.js
  7. 1
      client/components/Message/Attachments/Files/ImageAttachment.tsx
  8. 12
      client/components/Message/Attachments/components/Image.tsx
  9. 5
      definition/IMessage/IMessage.ts
  10. 4
      packages/rocketchat-i18n/i18n/en.i18n.json

@ -266,6 +266,45 @@ export const FileUpload = {
return result;
},
createImageThumbnail(file) {
if (!settings.get('Message_Attachments_Thumbnails_Enabled')) {
return;
}
const width = settings.get('Message_Attachments_Thumbnails_Width');
const height = settings.get('Message_Attachments_Thumbnails_Height');
if (file.identify.size && file.identify.size.height < height && file.identify.size.width < width) {
return;
}
file = Uploads.findOneById(file._id);
file = FileUpload.addExtensionTo(file);
const store = FileUpload.getStore('Uploads');
const image = store._store.getReadStream(file._id, file);
const transformer = sharp()
.resize({ width, height, fit: 'inside' });
const result = transformer.toBuffer({ resolveWithObject: true }).then(({ data, info: { width, height } }) => ({ data, width, height }));
image.pipe(transformer);
return result;
},
uploadImageThumbnail(file, buffer, rid, userId) {
const store = FileUpload.getStore('Uploads');
const details = {
name: `thumb-${ file.name }`,
size: buffer.length,
type: file.type,
rid,
userId,
};
return store.insertSync(details, buffer);
},
uploadsOnValidate(file) {
if (!/^image\/((x-windows-)?bmp|p?jpeg|png|gif)$/.test(file.type)) {
return;

@ -1,91 +0,0 @@
import { Meteor } from 'meteor/meteor';
import { Match, check } from 'meteor/check';
import { Random } from 'meteor/random';
import _ from 'underscore';
import { Uploads } from '../../../models';
import { Rooms } from '../../../models/server/raw';
import { callbacks } from '../../../callbacks';
import { FileUpload } from '../lib/FileUpload';
import { canAccessRoom } from '../../../authorization/server/functions/canAccessRoom';
Meteor.methods({
async sendFileMessage(roomId, store, file, msgData = {}) {
if (!Meteor.userId()) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'sendFileMessage' });
}
const room = await Rooms.findOneById(roomId);
const user = Meteor.user();
if (user?.type !== 'app' && canAccessRoom(room, user) !== true) {
return false;
}
check(msgData, {
avatar: Match.Optional(String),
emoji: Match.Optional(String),
alias: Match.Optional(String),
groupable: Match.Optional(Boolean),
msg: Match.Optional(String),
tmid: Match.Optional(String),
});
Uploads.updateFileComplete(file._id, Meteor.userId(), _.omit(file, '_id'));
const fileUrl = FileUpload.getPath(`${ file._id }/${ encodeURI(file.name) }`);
const attachment = {
title: file.name,
type: 'file',
description: file.description,
title_link: fileUrl,
title_link_download: true,
};
if (/^image\/.+/.test(file.type)) {
attachment.image_url = fileUrl;
attachment.image_type = file.type;
attachment.image_size = file.size;
if (file.identify && file.identify.size) {
attachment.image_dimensions = file.identify.size;
}
try {
attachment.image_preview = await FileUpload.resizeImagePreview(file);
} catch (e) {
delete attachment.image_url;
delete attachment.image_type;
delete attachment.image_size;
delete attachment.image_dimensions;
}
} else if (/^audio\/.+/.test(file.type)) {
attachment.audio_url = fileUrl;
attachment.audio_type = file.type;
attachment.audio_size = file.size;
} else if (/^video\/.+/.test(file.type)) {
attachment.video_url = fileUrl;
attachment.video_type = file.type;
attachment.video_size = file.size;
}
let msg = Object.assign({
_id: Random.id(),
rid: roomId,
ts: new Date(),
msg: '',
file: {
_id: file._id,
name: file.name,
type: file.type,
},
groupable: false,
attachments: [attachment],
}, msgData);
msg = Meteor.call('sendMessage', msg);
Meteor.defer(() => callbacks.run('afterFileUpload', { user, room, message: msg }));
return msg;
},
});

@ -0,0 +1,130 @@
/* eslint-disable @typescript-eslint/camelcase */
import { Meteor } from 'meteor/meteor';
import { Match, check } from 'meteor/check';
import _ from 'underscore';
import { Uploads } from '../../../models/server';
import { Rooms } from '../../../models/server/raw';
import { callbacks } from '../../../callbacks/server';
import { FileUpload } from '../lib/FileUpload';
import { canAccessRoom } from '../../../authorization/server/functions/canAccessRoom';
import { MessageAttachment } from '../../../../definition/IMessage/MessageAttachment/MessageAttachment';
import { FileAttachmentProps } from '../../../../definition/IMessage/MessageAttachment/Files/FileAttachmentProps';
import { IUser } from '../../../../definition/IUser';
Meteor.methods({
async sendFileMessage(roomId, _store, file, msgData = {}) {
const user = Meteor.user() as IUser | undefined;
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'sendFileMessage' } as any);
}
const room = await Rooms.findOneById(roomId);
if (user?.type !== 'app' && !canAccessRoom(room, user)) {
return false;
}
check(msgData, {
avatar: Match.Optional(String),
emoji: Match.Optional(String),
alias: Match.Optional(String),
groupable: Match.Optional(Boolean),
msg: Match.Optional(String),
tmid: Match.Optional(String),
});
Uploads.updateFileComplete(file._id, user._id, _.omit(file, '_id'));
const fileUrl = FileUpload.getPath(`${ file._id }/${ encodeURI(file.name) }`);
const attachments: MessageAttachment[] = [];
const files = [{
_id: file._id,
name: file.name,
type: 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,
};
if (file.identify && 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 } = thumbResult;
const thumbnail = FileUpload.uploadImageThumbnail(file, 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: file.name,
type: thumbnail.type,
});
}
} catch (e) {
console.error(e);
}
attachments.push(attachment);
} else if (/^audio\/.+/.test(file.type)) {
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,
audio_size: file.size,
};
attachments.push(attachment);
} else if (/^video\/.+/.test(file.type)) {
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,
video_size: file.size,
};
attachments.push(attachment);
}
const msg = Meteor.call('sendMessage', {
rid: roomId,
ts: new Date(),
msg: '',
file: files[0],
files,
groupable: false,
attachments,
...msgData,
});
callbacks.runAsync('afterFileUpload', { user, room, message: msg });
return msg;
},
});

@ -6,15 +6,18 @@ import { Messages, Uploads, Rooms } from '../../../models/server';
import { Notifications } from '../../../notifications/server';
import { callbacks } from '../../../callbacks/server';
import { Apps } from '../../../apps/server';
import { IMessage } from '../../../../definition/IMessage';
import { IUser } from '../../../../definition/IUser';
export const deleteMessage = function(message, user) {
export const deleteMessage = function(message: IMessage, user: IUser): void {
const deletedMsg = Messages.findOneById(message._id);
const isThread = deletedMsg.tcount > 0;
const keepHistory = settings.get('Message_KeepHistory') || isThread;
const showDeletedStatus = settings.get('Message_ShowDeletedStatus') || isThread;
const bridges = Apps && Apps.isLoaded() && Apps.getBridges();
if (deletedMsg && Apps && Apps.isLoaded()) {
const prevent = Promise.await(Apps.getBridges().getListenerBridge().messageEvent('IPreMessageDeletePrevent', deletedMsg));
if (deletedMsg && bridges) {
const prevent = Promise.await(bridges.getListenerBridge().messageEvent('IPreMessageDeletePrevent', deletedMsg));
if (prevent) {
throw new Meteor.Error('error-app-prevented-deleting', 'A Rocket.Chat App prevented the message deleting.');
}
@ -24,6 +27,8 @@ export const deleteMessage = function(message, user) {
Messages.decreaseReplyCountById(deletedMsg.tmid, -1);
}
const files = (message.files || [message.file]).filter(Boolean); // Keep compatibility with old messages
if (keepHistory) {
if (showDeletedStatus) {
Messages.cloneAndSaveAsHistoryById(message._id, user);
@ -31,21 +36,21 @@ export const deleteMessage = function(message, user) {
Messages.setHiddenById(message._id, true);
}
if (message.file && message.file._id) {
Uploads.update(message.file._id, { $set: { _hidden: true } });
}
files.forEach((file) => {
file?._id && Uploads.update(file._id, { $set: { _hidden: true } });
});
} else {
if (!showDeletedStatus) {
Messages.removeById(message._id);
}
if (message.file && message.file._id) {
FileUpload.getStore('Uploads').deleteById(message.file._id);
}
files.forEach((file) => {
file?._id && FileUpload.getStore('Uploads').deleteById(file._id);
});
}
const room = Rooms.findOneById(message.rid, { fields: { lastMessage: 1, prid: 1, mid: 1 } });
callbacks.run('afterDeleteMessage', deletedMsg, room, user);
callbacks.run('afterDeleteMessage', deletedMsg, room);
// update last message
if (settings.get('Store_Last_Message')) {
@ -63,7 +68,7 @@ export const deleteMessage = function(message, user) {
Notifications.notifyRoom(message.rid, 'deleteMessage', { _id: message._id });
}
if (Apps && Apps.isLoaded()) {
Apps.getBridges().getListenerBridge().messageEvent('IPostMessageDeleted', deletedMsg);
if (bridges) {
bridges.getListenerBridge().messageEvent('IPostMessageDeleted', deletedMsg);
}
};

@ -1107,6 +1107,35 @@ settings.addGroup('Message', function() {
public: true,
i18nDescription: 'Message_Attachments_GroupAttachDescription',
});
this.add('Message_Attachments_Thumbnails_Enabled', true, {
type: 'boolean',
public: true,
i18nDescription: 'Message_Attachments_Thumbnails_EnabledDesc',
});
this.add('Message_Attachments_Thumbnails_Width', 480, {
type: 'int',
public: true,
enableQuery: [
{
_id: 'Message_Attachments_Thumbnails_Enabled',
value: true,
},
],
});
this.add('Message_Attachments_Thumbnails_Height', 360, {
type: 'int',
public: true,
enableQuery: [
{
_id: 'Message_Attachments_Thumbnails_Enabled',
value: true,
},
],
});
this.add('Message_Attachments_Strip_Exif', false, {
type: 'boolean',
public: true,

@ -11,7 +11,7 @@ Meteor.startup(() => {
if (!currentGallery) {
const PhotoSwipe = PhotoSwipeImport.default;
const PhotoSwipeUI_Default = PhotoSwipeUI_DefaultImport.default;
currentGallery = await new PhotoSwipe(document.getElementById('pswp'), PhotoSwipeUI_Default, items, options);
currentGallery = new PhotoSwipe(document.getElementById('pswp'), PhotoSwipeUI_Default, items, options);
currentGallery.listen('destroy', () => {
currentGallery = null;
});
@ -46,7 +46,25 @@ Meteor.startup(() => {
galleryOptions.index = i;
}
const item = {
src: element.src,
w: element.naturalWidth,
h: element.naturalHeight,
title: element.dataset.title || element.title,
description: element.dataset.description,
};
if (element.dataset.src || element.href) {
// use stored sizes if available
if (element.dataset.width && element.dataset.height) {
return {
...item,
h: element.dataset.height,
w: element.dataset.width,
src: element.dataset.src || element.href,
};
}
const img = new Image();
img.addEventListener('load', () => {
@ -54,6 +72,10 @@ Meteor.startup(() => {
return;
}
// stores loaded sizes on original image element
element.dataset.width = img.naturalWidth;
element.dataset.height = img.naturalHeight;
delete currentGallery.items[i].html;
currentGallery.items[i].src = img.src;
currentGallery.items[i].w = img.naturalWidth;
@ -65,19 +87,13 @@ Meteor.startup(() => {
img.src = element.dataset.src || element.href;
return {
html: '',
title: element.dataset.title || element.title,
description: element.dataset.description,
...item,
msrc: element.src,
src: element.dataset.src || element.href,
};
}
return {
src: element.src,
w: element.naturalWidth,
h: element.naturalHeight,
title: element.dataset.title || element.title,
description: element.dataset.description,
};
return item;
});
initGallery(items, galleryOptions);

@ -40,6 +40,7 @@ export const ImageAttachment: FC<ImageAttachmentProps> = ({
{...imageDimensions}
loadImage={loadImage}
setLoadImage={setLoadImage}
dataSrc={getURL(link || url)}
src={getURL(url)}
previewUrl={`data:image/png;base64,${imagePreview}`}
/>

@ -8,6 +8,7 @@ import Retry from './Retry';
type ImageProps = {
previewUrl?: string;
dataSrc?: string;
src: string;
loadImage?: boolean;
setLoadImage: () => void;
@ -31,7 +32,14 @@ const getDimensions = (
return { width: (height / originalHeight) * originalWidth, height };
};
const Image: FC<ImageProps> = ({ previewUrl, loadImage = true, setLoadImage, src, ...size }) => {
const Image: FC<ImageProps> = ({
previewUrl,
dataSrc,
loadImage = true,
setLoadImage,
src,
...size
}) => {
const limits = useAttachmentDimensions();
const { width = limits.width, height = limits.height } = size;
const [error, setError] = useState(false);
@ -63,7 +71,7 @@ const Image: FC<ImageProps> = ({ previewUrl, loadImage = true, setLoadImage, src
{...dimensions}
is='picture'
>
<img className='gallery-item' src={src} {...dimensions} />
<img className='gallery-item' data-src={dataSrc || src} src={src} {...dimensions} />
</ImageBox>
);
};

@ -5,6 +5,7 @@ import { IRocketChatRecord } from '../IRocketChatRecord';
import { IUser } from '../IUser';
import { ChannelName, RoomID } from '../IRoom';
import { MessageAttachment } from './MessageAttachment/MessageAttachment';
import { FileProp } from './MessageAttachment/Files/FileProp';
type MentionType = 'user' | 'team';
@ -67,6 +68,8 @@ export interface IMessage extends IRocketChatRecord {
e2e?: 'pending';
urls: any;
file: any;
/** @deprecated Deprecated in favor of files */
file?: FileProp;
files?: FileProp[];
attachments: MessageAttachment[];
}

@ -2820,6 +2820,10 @@
"Message_Attachments": "Message Attachments",
"Message_Attachments_GroupAttach": "Group Attachment Buttons",
"Message_Attachments_GroupAttachDescription": "This groups the icons under an expandable menu. Takes up less screen space.",
"Message_Attachments_Thumbnails_Enabled": "Enable image thumbnails to save bandwith",
"Message_Attachments_Thumbnails_Width": "Thumbnail's max width (in pixels)",
"Message_Attachments_Thumbnails_Height": "Thumbnail's max height (in pixels)",
"Message_Attachments_Thumbnails_EnabledDesc": "Thumbnails will be served instead of the original image to reduce bandwith usage. Images at original resolution can be downloaded using the icon next to the attachment's name.",
"Message_Attachments_Strip_Exif": "Remove EXIF metadata from supported files",
"Message_Attachments_Strip_ExifDescription": "Strips out EXIF metadata from image files (jpeg, tiff, etc). This setting is not retroactive, so files uploaded while disabled will have EXIF data",
"Message_Audio": "Audio Message",

Loading…
Cancel
Save