/* 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.UnsupportedFileTypeToast = Whisper.ToastView.extend({ template: i18n('unsupportedFileType'), }); Whisper.DangerousFileTypeToast = Whisper.ToastView.extend({ template: i18n('dangerousFileType'), }); Whisper.FileInputView = Backbone.View.extend({ tagName: 'span', className: 'file-input', initialize(options) { this.$input = this.$('input[type=file]'); this.$input.click(e => { e.stopPropagation(); }); this.thumb = new Whisper.AttachmentPreviewView(); this.$el.addClass('file-input'); this.window = options.window; this.previewObjectUrl = null; }, events: { 'change .choose-file': 'previewImages', 'click .close': 'deleteFiles', 'click .choose-file': 'open', drop: 'openDropped', dragover: 'showArea', dragleave: 'hideArea', paste: 'onPaste', }, open(e) { e.preventDefault(); // hack if (this.window && this.window.chrome && this.window.chrome.fileSystem) { this.window.chrome.fileSystem.chooseEntry( { type: 'openFile' }, entry => { if (!entry) { return; } entry.file(file => { this.file = file; this.previewImages(); }); } ); } else { this.$input.click(); } }, addThumb(src, options = {}) { _.defaults(options, { addPlayIcon: false }); this.$('.avatar').hide(); this.thumb.src = src; this.$('.attachment-previews').append(this.thumb.render().el); if (options.addPlayIcon) { this.$el.addClass('video-attachment'); } else { this.$el.removeClass('video-attachment'); } this.thumb.$('img')[0].onload = () => { this.$el.trigger('force-resize'); }; this.thumb.$('img')[0].onerror = () => { this.unableToLoadAttachment(); }; }, unableToLoadAttachment() { const toast = new Whisper.UnableToLoadToast(); toast.$el.insertAfter(this.$el); toast.render(); this.deleteFiles(); }, autoScale(file) { if (file.type.split('/')[0] !== 'image' || file.type === 'image/tiff') { // nothing to do return Promise.resolve(file); } 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(file); return; } const gifMaxSize = 25000 * 1024; if (file.type === 'image/gif' && file.size <= gifMaxSize) { resolve(file); 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(blob); }; img.src = url; }); }, async previewImages() { this.clearForm(); const file = this.file || this.$input.prop('files')[0]; if (!file) { return; } const { name } = file; if (window.Signal.Util.isFileDangerous(name)) { this.deleteFiles(); const toast = new Whisper.DangerousFileTypeToast(); toast.$el.insertAfter(this.$el); toast.render(); return; } const contentType = file.type; const renderVideoPreview = async () => { // we use the variable on this here to ensure cleanup if we're interrupted this.previewObjectUrl = URL.createObjectURL(file); const type = 'image/png'; const thumbnail = await VisualAttachment.makeVideoThumbnail({ size: 100, videoObjectUrl: this.previewObjectUrl, contentType: type, logger: window.log, }); URL.revokeObjectURL(this.previewObjectUrl); const data = await VisualAttachment.blobToArrayBuffer(thumbnail); this.previewObjectUrl = Signal.Util.arrayBufferToObjectURL({ data, type, }); this.addThumb(this.previewObjectUrl, { addPlayIcon: true }); }; const renderImagePreview = async () => { if (!MIME.isJPEG(file.type)) { this.previewObjectUrl = URL.createObjectURL(file); if (!this.previewObjectUrl) { throw new Error('Failed to create object url for image!'); } this.addThumb(this.previewObjectUrl); return; } const dataUrl = await window.autoOrientImage(file); this.addThumb(dataUrl); }; try { if (Signal.Util.GoogleChrome.isImageTypeSupported(contentType)) { await renderImagePreview(); } else if (Signal.Util.GoogleChrome.isVideoTypeSupported(contentType)) { await renderVideoPreview(); } else if (MIME.isAudio(contentType)) { this.addThumb('images/audio.svg'); } else { this.addThumb('images/file.svg'); } } catch (e) { window.log.error( `Was unable to generate thumbnail for file type ${contentType}`, e && e.stack ? e.stack : e ); this.addThumb('images/file.svg'); } try { const blob = await this.autoScale(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); const toast = new Whisper.FileSizeToast({ model: { limit, units: units[u] }, }); toast.$el.insertAfter(this.$el); toast.render(); this.deleteFiles(); } } catch (error) { window.log.error( 'Error ensuring that image is properly sized:', error && error.message ? error.message : error ); this.unableToLoadAttachment(); } }, hasFiles() { const files = this.file ? [this.file] : this.$input.prop('files'); return files && files.length && files.length > 0; }, getFiles() { const files = this.file ? [this.file] : Array.from(this.$input.prop('files')); const promise = Promise.all(files.map(file => this.getFile(file))); this.clearForm(); return promise; }, getFile(rawFile) { const file = rawFile || this.file || this.$input.prop('files')[0]; if (!file) { return Promise.resolve(); } const attachmentFlags = this.isVoiceNote ? textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE : null; const setFlags = flags => attachment => { const newAttachment = Object.assign({}, attachment); if (flags) { newAttachment.flags = flags; } return newAttachment; }; // NOTE: Temporarily allow `then` until we convert the entire file // to `async` / `await`: // eslint-disable-next-line more/no-then return this.autoScale(file) .then(this.readFile) .then(setFlags(attachmentFlags)); }, async getThumbnail() { // Scale and crop an image to 256px square const size = 256; const file = this.file || this.$input.prop('files')[0]; if ( file === undefined || file.type.split('/')[0] !== 'image' || file.type === 'image/gif' ) { // nothing to do return Promise.resolve(); } const objectUrl = URL.createObjectURL(file); const arrayBuffer = await VisualAttachment.makeImageThumbnail({ size, objectUrl, logger: window.log, }); URL.revokeObjectURL(objectUrl); return this.readFile(arrayBuffer); }, // File -> Promise Attachment readFile(file) { return new Promise((resolve, reject) => { const FR = new FileReader(); FR.onload = e => { resolve({ data: e.target.result, contentType: file.type, fileName: file.name, size: file.size, }); }; FR.onerror = reject; FR.onabort = reject; FR.readAsArrayBuffer(file); }); }, clearForm() { if (this.previewObjectUrl) { URL.revokeObjectURL(this.previewObjectUrl); this.previewObjectUrl = null; } this.thumb.remove(); this.$('.avatar').show(); this.$el.trigger('force-resize'); }, deleteFiles(e) { if (e) { e.stopPropagation(); } this.clearForm(); this.$input .wrap('
') .parent('form') .trigger('reset'); this.$input.unwrap(); this.file = null; this.$input.trigger('change'); this.isVoiceNote = false; }, openDropped(e) { if (e.originalEvent.dataTransfer.types[0] !== 'Files') { return; } e.stopPropagation(); e.preventDefault(); // eslint-disable-next-line prefer-destructuring this.file = e.originalEvent.dataTransfer.files[0]; this.previewImages(); this.$el.removeClass('dropoff'); }, showArea(e) { if (e.originalEvent.dataTransfer.types[0] !== 'Files') { return; } e.stopPropagation(); e.preventDefault(); this.$el.addClass('dropoff'); }, hideArea(e) { if (e.originalEvent.dataTransfer.types[0] !== 'Files') { return; } e.stopPropagation(); e.preventDefault(); 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) { this.file = imgBlob; this.previewImages(); } }, }); })();