539 lines
14 KiB
JavaScript
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);
|
|
});
|
|
},
|
|
});
|
|
})();
|