signal-desktop/js/views/file_input_view.js

539 lines
14 KiB
JavaScript

/* global textsecure: false */
/* global Whisper: false */
/* global i18n: false */
/* global loadImage: false */
/* global Backbone: false */
/* global _: false */
/* global Signal: false */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
const { MIME, VisualAttachment } = window.Signal.Types;
Whisper.FileSizeToast = Whisper.ToastView.extend({
templateName: 'file-size-modal',
render_attributes() {
return {
'file-size-warning': i18n('fileSizeWarning'),
limit: this.model.limit,
units: this.model.units,
};
},
});
Whisper.UnableToLoadToast = Whisper.ToastView.extend({
render_attributes() {
return { toastMessage: i18n('unableToLoadAttachment') };
},
});
Whisper.DangerousFileTypeToast = Whisper.ToastView.extend({
template: i18n('dangerousFileType'),
});
Whisper.OneNonImageAtATimeToast = Whisper.ToastView.extend({
template: i18n('oneNonImageAtATimeToast'),
});
Whisper.CannotMixImageAndNonImageAttachmentsToast = Whisper.ToastView.extend({
template: i18n('cannotMixImageAdnNonImageAttachments'),
});
Whisper.MaxAttachmentsToast = Whisper.ToastView.extend({
template: i18n('maximumAttachments'),
});
Whisper.FileInputView = Backbone.View.extend({
tagName: 'span',
className: 'file-input',
initialize() {
this.attachments = [];
this.attachmentListView = new Whisper.ReactWrapperView({
el: this.el,
Component: window.Signal.Components.AttachmentList,
props: this.getPropsForAttachmentList(),
});
},
remove() {
if (this.attachmentListView) {
this.attachmentListView.remove();
}
if (this.captionEditorView) {
this.captionEditorView.remove();
}
Backbone.View.prototype.remove.call(this);
},
render() {
this.attachmentListView.update(this.getPropsForAttachmentList());
this.trigger('staged-attachments-changed');
},
getPropsForAttachmentList() {
const { attachments } = this;
// We never want to display voice notes in our attachment list
if (_.any(attachments, attachment => Boolean(attachment.isVoiceNote))) {
return {
attachments: [],
};
}
return {
attachments,
onAddAttachment: this.onAddAttachment.bind(this),
onClickAttachment: this.onClickAttachment.bind(this),
onCloseAttachment: this.onCloseAttachment.bind(this),
onClose: this.onClose.bind(this),
};
},
onClickAttachment(attachment) {
const getProps = () => ({
url: attachment.videoUrl || attachment.url,
caption: attachment.caption,
attachment,
onSave,
});
const onSave = caption => {
// eslint-disable-next-line no-param-reassign
attachment.caption = caption;
this.captionEditorView.remove();
Signal.Backbone.Views.Lightbox.hide();
this.render();
};
this.captionEditorView = new Whisper.ReactWrapperView({
className: 'attachment-list-wrapper',
Component: window.Signal.Components.CaptionEditor,
props: getProps(),
onClose: () => Signal.Backbone.Views.Lightbox.hide(),
});
Signal.Backbone.Views.Lightbox.show(this.captionEditorView.el);
},
onCloseAttachment(attachment) {
this.attachments = _.without(this.attachments, attachment);
this.render();
},
onAddAttachment() {
this.trigger('choose-attachment');
},
onClose() {
this.attachments = [];
this.render();
},
// These event handlers are called by ConversationView, which listens for these events
onDragOver(e) {
if (e.originalEvent.dataTransfer.types[0] !== 'Files') {
return;
}
e.stopPropagation();
e.preventDefault();
this.$el.addClass('dropoff');
},
onDragLeave(e) {
if (e.originalEvent.dataTransfer.types[0] !== 'Files') {
return;
}
e.stopPropagation();
e.preventDefault();
this.$el.removeClass('dropoff');
},
async onDrop(e) {
if (e.originalEvent.dataTransfer.types[0] !== 'Files') {
return;
}
e.stopPropagation();
e.preventDefault();
const { files } = e.originalEvent.dataTransfer;
for (let i = 0, max = files.length; i < max; i += 1) {
const file = files[i];
// eslint-disable-next-line no-await-in-loop
await this.maybeAddAttachment(file);
}
this.$el.removeClass('dropoff');
},
onPaste(e) {
const { items } = e.originalEvent.clipboardData;
let imgBlob = null;
for (let i = 0; i < items.length; i += 1) {
if (items[i].type.split('/')[0] === 'image') {
imgBlob = items[i].getAsFile();
}
}
if (imgBlob !== null) {
const file = imgBlob;
this.maybeAddAttachment(file);
e.stopPropagation();
e.preventDefault();
}
},
// Public interface
hasFiles() {
return this.attachments.length > 0;
},
async getFiles() {
const files = await Promise.all(
this.attachments.map(attachment => this.getFile(attachment))
);
this.clearAttachments();
return files;
},
clearAttachments() {
this.attachments.forEach(attachment => {
if (attachment.url) {
URL.revokeObjectURL(attachment.url);
}
if (attachment.videoUrl) {
URL.revokeObjectURL(attachment.videoUrl);
}
});
this.attachments = [];
this.render();
this.$el.trigger('force-resize');
},
// Show errors
showLoadFailure() {
const toast = new Whisper.UnableToLoadToast();
toast.$el.insertAfter(this.$el);
toast.render();
},
showDangerousError() {
const toast = new Whisper.DangerousFileTypeToast();
toast.$el.insertAfter(this.$el);
toast.render();
},
showFileSizeError({ limit, units, u }) {
const toast = new Whisper.FileSizeToast({
model: { limit, units: units[u] },
});
toast.$el.insertAfter(this.$el);
toast.render();
},
showCannotMixError() {
const toast = new Whisper.CannotMixImageAndNonImageAttachmentsToast();
toast.$el.insertAfter(this.$el);
toast.render();
},
showMultipleNonImageError() {
const toast = new Whisper.OneNonImageAtATimeToast();
toast.$el.insertAfter(this.$el);
toast.render();
},
showMaximumAttachmentsError() {
const toast = new Whisper.MaxAttachmentsToast();
toast.$el.insertAfter(this.$el);
toast.render();
},
// Housekeeping
addAttachment(attachment) {
if (attachment.isVoiceNote && this.attachments.length > 0) {
throw new Error('A voice note cannot be sent with other attachments');
}
this.attachments.push(attachment);
this.render();
},
async maybeAddAttachment(file) {
if (!file) {
return;
}
const fileName = file.name;
const contentType = file.type;
if (window.Signal.Util.isFileDangerous(fileName)) {
this.showDangerousError();
return;
}
if (this.attachments.length >= 32) {
this.showMaximumAttachmentsError();
return;
}
const haveNonImage = _.any(
this.attachments,
attachment => !MIME.isImage(attachment.contentType)
);
// You can't add another attachment if you already have a non-image staged
if (haveNonImage) {
this.showMultipleNonImageError();
return;
}
// You can't add a non-image attachment if you already have attachments staged
if (!MIME.isImage(contentType) && this.attachments.length > 0) {
this.showCannotMixError();
return;
}
const renderVideoPreview = async () => {
const objectUrl = URL.createObjectURL(file);
try {
const type = 'image/png';
const thumbnail = await VisualAttachment.makeVideoScreenshot({
objectUrl,
contentType: type,
logger: window.log,
});
const data = await VisualAttachment.blobToArrayBuffer(thumbnail);
const url = Signal.Util.arrayBufferToObjectURL({
data,
type,
});
this.addAttachment({
file,
size: file.size,
fileName,
contentType,
videoUrl: objectUrl,
url,
});
} catch (error) {
URL.revokeObjectURL(objectUrl);
}
};
const renderImagePreview = async () => {
if (!MIME.isJPEG(contentType)) {
const url = URL.createObjectURL(file);
if (!url) {
throw new Error('Failed to create object url for image!');
}
this.addAttachment({
file,
size: file.size,
fileName,
contentType,
url,
});
return;
}
const url = await window.autoOrientImage(file);
this.addAttachment({
file,
size: file.size,
fileName,
contentType,
url,
});
};
try {
const blob = await this.autoScale({
contentType,
file,
});
let limitKb = 1000000;
const blobType =
file.type === 'image/gif' ? 'gif' : contentType.split('/')[0];
switch (blobType) {
case 'image':
limitKb = 6000;
break;
case 'gif':
limitKb = 25000;
break;
case 'audio':
limitKb = 100000;
break;
case 'video':
limitKb = 100000;
break;
default:
limitKb = 100000;
break;
}
if ((blob.size / 1024).toFixed(4) >= limitKb) {
const units = ['kB', 'MB', 'GB'];
let u = -1;
let limit = limitKb * 1000;
do {
limit /= 1000;
u += 1;
} while (limit >= 1000 && u < units.length - 1);
this.showFileSizeError({ limit, units, u });
return;
}
} catch (error) {
window.log.error(
'Error ensuring that image is properly sized:',
error && error.stack ? error.stack : error
);
this.showLoadFailure();
return;
}
try {
if (Signal.Util.GoogleChrome.isImageTypeSupported(contentType)) {
await renderImagePreview();
} else if (Signal.Util.GoogleChrome.isVideoTypeSupported(contentType)) {
await renderVideoPreview();
} else {
this.addAttachment({
file,
size: file.size,
contentType,
fileName,
});
}
} catch (e) {
window.log.error(
`Was unable to generate thumbnail for file type ${contentType}`,
e && e.stack ? e.stack : e
);
this.addAttachment({
file,
size: file.size,
contentType,
fileName,
});
}
},
autoScale(attachment) {
const { contentType, file } = attachment;
if (
contentType.split('/')[0] !== 'image' ||
contentType === 'image/tiff'
) {
// nothing to do
return Promise.resolve(attachment);
}
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = document.createElement('img');
img.onerror = reject;
img.onload = () => {
URL.revokeObjectURL(url);
const maxSize = 6000 * 1024;
const maxHeight = 4096;
const maxWidth = 4096;
if (
img.naturalWidth <= maxWidth &&
img.naturalHeight <= maxHeight &&
file.size <= maxSize
) {
resolve(attachment);
return;
}
const gifMaxSize = 25000 * 1024;
if (file.type === 'image/gif' && file.size <= gifMaxSize) {
resolve(attachment);
return;
}
if (file.type === 'image/gif') {
reject(new Error('GIF is too large'));
return;
}
const canvas = loadImage.scale(img, {
canvas: true,
maxWidth,
maxHeight,
});
let quality = 0.95;
let i = 4;
let blob;
do {
i -= 1;
blob = window.dataURLToBlobSync(
canvas.toDataURL('image/jpeg', quality)
);
quality = quality * maxSize / blob.size;
// NOTE: During testing with a large image, we observed the
// `quality` value being > 1. Should we clamp it to [0.5, 1.0]?
// See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob#Syntax
if (quality < 0.5) {
quality = 0.5;
}
} while (i > 0 && blob.size > maxSize);
resolve({
...attachment,
file: blob,
});
};
img.src = url;
});
},
async getFile(attachment) {
if (!attachment) {
return Promise.resolve();
}
const attachmentFlags = attachment.isVoiceNote
? textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE
: null;
const scaled = await this.autoScale(attachment);
const fileRead = await this.readFile(scaled);
return {
...fileRead,
url: undefined,
videoUrl: undefined,
flags: attachmentFlags || null,
};
},
readFile(attachment) {
return new Promise((resolve, reject) => {
const FR = new FileReader();
FR.onload = e => {
const data = e.target.result;
resolve({
...attachment,
data,
size: data.byteLength,
});
};
FR.onerror = reject;
FR.onabort = reject;
FR.readAsArrayBuffer(attachment.file);
});
},
});
})();