Thumbnails for new video attachments and video quotes (#2293)

Two major changes here:

1. When you attach a video to your message, you see a thumbnail of its first frame in the composition area, instead of a generic file icon
2. When you reply to a message with a video in it, your message will include a thumbnail of that video (we'll also generate thumbnails for video quotes if not provided already)

I also made a change to our quote-loading algorithm, since I noticed that our previous approach had some performance issues now that we support video. Where before, we privileged our ability to make local thumbnails at a higher quality, now we defer to any thumbnail we have saved on the quote. So the algorithm for preparing a quote for display is now:

1. Load thumbnail from disk
2. Check to see if quoted message is in memory already. If it is, and we weren't able to load a thumbnail already, generate it from quoted message.
3. If there is an attachment in quote, and we couldn't find message in memory, and the attachment contentType is either image or video, we will go to the database to find the referenced message and create a thumbnail from it.

Bonus bug-fix: The scroll down button in the bottom right of the conversation no longer has an outline when you click it.
This commit is contained in:
Scott Nonnenberg 2018-04-26 12:27:05 -07:00 committed by GitHub
commit fdd6985a79
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 479 additions and 355 deletions

View file

@ -111,6 +111,7 @@ module.exports = function(grunt) {
'!js/views/conversation_search_view.js', '!js/views/conversation_search_view.js',
'!js/views/conversation_view.js', '!js/views/conversation_view.js',
'!js/views/debug_log_view.js', '!js/views/debug_log_view.js',
'!js/views/file_input_view.js',
'!js/views/message_view.js', '!js/views/message_view.js',
'!js/models/conversations.js', '!js/models/conversations.js',
'!js/models/messages.js', '!js/models/messages.js',
@ -170,6 +171,7 @@ module.exports = function(grunt) {
'!js/views/conversation_search_view.js', '!js/views/conversation_search_view.js',
'!js/views/conversation_view.js', '!js/views/conversation_view.js',
'!js/views/debug_log_view.js', '!js/views/debug_log_view.js',
'!js/views/file_input_view.js',
'!js/views/message_view.js', '!js/views/message_view.js',
'!js/Mp3LameEncoder.min.js', '!js/Mp3LameEncoder.min.js',
'!js/WebAudioRecorderMp3.js', '!js/WebAudioRecorderMp3.js',

View file

@ -237,7 +237,12 @@
</div> </div>
</script> </script>
<script type='text/x-tmpl-mustache' id='attachment-preview'> <script type='text/x-tmpl-mustache' id='attachment-preview'>
<div class='image-container'>
<img src='{{ source }}' class='preview' /> <img src='{{ source }}' class='preview' />
<div class='outer'>
<div class='play icon'></div>
</div>
</div>
<a class='x close' alt='remove attachment' href='#'></a> <a class='x close' alt='remove attachment' href='#'></a>
</script> </script>
<script type='text/x-tmpl-mustache' id='file-view'> <script type='text/x-tmpl-mustache' id='file-view'>

View file

@ -628,13 +628,23 @@
async makeThumbnailAttachment(attachment) { async makeThumbnailAttachment(attachment) {
const attachmentWithData = await loadAttachmentData(attachment); const attachmentWithData = await loadAttachmentData(attachment);
const { data, contentType } = attachmentWithData; const { data, contentType } = attachmentWithData;
const objectUrl = this.makeObjectUrl(data, contentType); const objectUrl = Signal.Util.arrayBufferToObjectURL({
const thumbnail = await Whisper.FileInputView.makeThumbnail(128, objectUrl); data,
type: contentType,
});
const thumbnail = Signal.Util.GoogleChrome.isImageTypeSupported(contentType)
? await Whisper.FileInputView.makeImageThumbnail(128, objectUrl)
: await Whisper.FileInputView.makeVideoThumbnail(128, objectUrl);
URL.revokeObjectURL(objectUrl); URL.revokeObjectURL(objectUrl);
const arrayBuffer = await this.blobToArrayBuffer(thumbnail); const arrayBuffer = await this.blobToArrayBuffer(thumbnail);
const finalContentType = 'image/png'; const finalContentType = 'image/png';
const finalObjectUrl = this.makeObjectUrl(arrayBuffer, finalContentType); const finalObjectUrl = Signal.Util.arrayBufferToObjectURL({
data: arrayBuffer,
type: finalContentType,
});
return { return {
data: arrayBuffer, data: arrayBuffer,
@ -654,7 +664,8 @@
attachments: await Promise.all((attachments || []).map(async (attachment) => { attachments: await Promise.all((attachments || []).map(async (attachment) => {
const { contentType } = attachment; const { contentType } = attachment;
const willMakeThumbnail = const willMakeThumbnail =
Signal.Util.GoogleChrome.isImageTypeSupported(contentType); Signal.Util.GoogleChrome.isImageTypeSupported(contentType) ||
Signal.Util.GoogleChrome.isVideoTypeSupported(contentType);
return { return {
contentType, contentType,
@ -1121,12 +1132,6 @@
forceRender(message) { forceRender(message) {
message.trigger('change', message); message.trigger('change', message);
}, },
makeObjectUrl(data, contentType) {
const blob = new Blob([data], {
type: contentType,
});
return URL.createObjectURL(blob);
},
makeMessagesLookup(messages) { makeMessagesLookup(messages) {
return messages.reduce((acc, message) => { return messages.reduce((acc, message) => {
const { source, sent_at: sentAt } = message.attributes; const { source, sent_at: sentAt } = message.attributes;
@ -1154,9 +1159,12 @@
const { attachments, id, author } = quote; const { attachments, id, author } = quote;
const first = attachments[0]; const first = attachments[0];
// Maybe in the future we could try to pull the thumbnail from a video ourselves, if (!first || message.quoteThumbnail) {
// but for now we will rely on incoming thumbnails only. return false;
if (!Signal.Util.GoogleChrome.isImageTypeSupported(first.contentType)) { }
if (!Signal.Util.GoogleChrome.isImageTypeSupported(first.contentType) &&
!Signal.Util.GoogleChrome.isVideoTypeSupported(first.contentType)) {
return false; return false;
} }
@ -1181,7 +1189,7 @@
} catch (error) { } catch (error) {
console.log( console.log(
'Problem loading attachment data for quoted message from database', 'Problem loading attachment data for quoted message from database',
error && error.stack ? error.stack : error Signal.Types.Errors.toLogFormat(error)
); );
return false; return false;
} }
@ -1194,9 +1202,12 @@
const { attachments } = quote; const { attachments } = quote;
const first = attachments[0]; const first = attachments[0];
// Maybe in the future we could try to pull thumbnails video ourselves, if (!first || message.quoteThumbnail) {
// but for now we will rely on incoming thumbnails only. return;
if (!first || !Signal.Util.GoogleChrome.isImageTypeSupported(first.contentType)) { }
if (!Signal.Util.GoogleChrome.isImageTypeSupported(first.contentType) &&
!Signal.Util.GoogleChrome.isVideoTypeSupported(first.contentType)) {
return; return;
} }
@ -1221,7 +1232,8 @@
const { quote } = message.attributes; const { quote } = message.attributes;
const { attachments } = quote; const { attachments } = quote;
const first = attachments[0]; const first = attachments[0];
if (!first) {
if (!first || message.quoteThumbnail) {
return false; return false;
} }
@ -1230,11 +1242,13 @@
if (!thumbnail) { if (!thumbnail) {
return false; return false;
} }
try {
const thumbnailWithData = await loadAttachmentData(thumbnail); const thumbnailWithData = await loadAttachmentData(thumbnail);
thumbnailWithData.objectUrl = this.makeObjectUrl( const { data, contentType } = thumbnailWithData;
thumbnailWithData.data, thumbnailWithData.objectUrl = Signal.Util.arrayBufferToObjectURL({
thumbnailWithData.contentType data,
); type: contentType,
});
// If we update this data in place, there's the risk that this data could be // If we update this data in place, there's the risk that this data could be
// saved back to the database // saved back to the database
@ -1242,6 +1256,13 @@
message.quoteThumbnail = thumbnailWithData; message.quoteThumbnail = thumbnailWithData;
return true; return true;
} catch (error) {
console.log(
'loadQuoteThumbnail: had trouble loading thumbnail data from disk',
error && error.stack ? error.stack : error
);
return false;
}
}, },
async processQuotes(messages) { async processQuotes(messages) {
const lookup = this.makeMessagesLookup(messages); const lookup = this.makeMessagesLookup(messages);
@ -1259,21 +1280,22 @@
return; return;
} }
// 1. Check to see if we've already loaded the target message into memory // 1. Load provided thumbnail
const gotThumbnail = await this.loadQuoteThumbnail(message, quote);
// 2. Check to see if we've already loaded the target message into memory
const { author, id } = quote; const { author, id } = quote;
const key = this.makeKey(author, id); const key = this.makeKey(author, id);
const quotedMessage = lookup[key]; const quotedMessage = lookup[key];
if (quotedMessage) { if (quotedMessage) {
// eslint-disable-next-line no-param-reassign
await this.loadQuotedMessage(message, quotedMessage); await this.loadQuotedMessage(message, quotedMessage);
this.forceRender(message);
// Note: in the future when we generate our own thumbnail we won't need to rely return;
// on incoming thumbnail if we have our local message in hand.
if (!message.quotedMessage.imageUrl) {
await this.loadQuoteThumbnail(message, quote);
} }
// No need to go further if we already have a thumbnail
if (gotThumbnail) {
this.forceRender(message); this.forceRender(message);
return; return;
} }
@ -1292,22 +1314,9 @@
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
message.quoteIsProcessed = true; message.quoteIsProcessed = true;
// 2. Go to the database for the real referenced attachment // 3. As a last resort, go to the database to generate a thumbnail on-demand
const loaded = await this.loadQuotedMessageFromDatabase(message, id); const loaded = await this.loadQuotedMessageFromDatabase(message, id);
if (loaded) { if (loaded) {
// Note: in the future when we generate our own thumbnail we won't need to rely
// on incoming thumbnail if we have our local message in hand.
if (!message.quotedMessageFromDatabase.imageUrl) {
await this.loadQuoteThumbnail(message, quote);
}
this.forceRender(message);
return;
}
// 3. Finally, use the provided thumbnail
const gotThumbnail = await this.loadQuoteThumbnail(message, quote);
if (gotThumbnail) {
this.forceRender(message); this.forceRender(message);
} }
}); });

View file

@ -181,10 +181,6 @@
URL.revokeObjectURL(this.quoteThumbnail.objectUrl); URL.revokeObjectURL(this.quoteThumbnail.objectUrl);
this.quoteThumbnail = null; this.quoteThumbnail = null;
} }
if (this.quotedMessageFromDatabase) {
this.quotedMessageFromDatabase.unload();
this.quotedMessageFromDatabase = null;
}
if (this.quotedMessage) { if (this.quotedMessage) {
this.quotedMessage = null; this.quotedMessage = null;
} }

View file

@ -1,36 +1,42 @@
/* eslint-disable */
/* global textsecure: false */ /* 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 () { (function () {
'use strict'; 'use strict';
window.Whisper = window.Whisper || {}; window.Whisper = window.Whisper || {};
const { MIME } = window.Signal.Types; const { MIME } = window.Signal.Types;
Whisper.FileSizeToast = Whisper.ToastView.extend({ Whisper.FileSizeToast = Whisper.ToastView.extend({
templateName: 'file-size-modal', templateName: 'file-size-modal',
render_attributes: function() { render_attributes() {
return { return {
'file-size-warning': i18n('fileSizeWarning'), 'file-size-warning': i18n('fileSizeWarning'),
limit: this.model.limit, limit: this.model.limit,
units: this.model.units units: this.model.units,
}; };
} },
}); });
Whisper.UnsupportedFileTypeToast = Whisper.ToastView.extend({ Whisper.UnsupportedFileTypeToast = Whisper.ToastView.extend({
template: i18n('unsupportedFileType') template: i18n('unsupportedFileType'),
}); });
function makeThumbnail(size, objectUrl) { function makeImageThumbnail(size, objectUrl) {
return new Promise(function(resolve, reject) { return new Promise(((resolve, reject) => {
var img = document.createElement('img'); const img = document.createElement('img');
img.onerror = reject; img.onerror = reject;
img.onload = function () { img.onload = () => {
// using components/blueimp-load-image // using components/blueimp-load-image
// first, make the correct size // first, make the correct size
var canvas = loadImage.scale(img, { let canvas = loadImage.scale(img, {
canvas: true, canvas: true,
cover: true, cover: true,
maxWidth: size, maxWidth: size,
@ -49,20 +55,75 @@
minHeight: size, minHeight: size,
}); });
var blob = window.dataURLToBlobSync(canvas.toDataURL('image/png')); const blob = window.dataURLToBlobSync(canvas.toDataURL('image/png'));
resolve(blob); resolve(blob);
}; };
img.src = objectUrl; img.src = objectUrl;
}));
}
function makeVideoScreenshot(objectUrl) {
return new Promise(((resolve, reject) => {
const video = document.createElement('video');
function capture() {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
const image = window.dataURLToBlobSync(canvas.toDataURL('image/png'));
video.removeEventListener('canplay', capture);
resolve(image);
}
video.addEventListener('canplay', capture);
video.addEventListener('error', (error) => {
console.log(
'makeVideoThumbnail error',
Signal.Types.Errors.toLogFormat(error)
);
reject(error);
}); });
video.src = objectUrl;
}));
}
function blobToArrayBuffer(blob) {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = e => resolve(e.target.result);
fileReader.onerror = reject;
fileReader.onabort = reject;
fileReader.readAsArrayBuffer(blob);
});
}
async function makeVideoThumbnail(size, videoObjectUrl) {
const blob = await makeVideoScreenshot(videoObjectUrl);
const data = await blobToArrayBuffer(blob);
const screenshotObjectUrl = Signal.Util.arrayBufferToObjectURL({
data,
type: 'image/png',
});
const thumbnail = await makeImageThumbnail(size, screenshotObjectUrl);
URL.revokeObjectURL(screenshotObjectUrl);
return thumbnail;
} }
Whisper.FileInputView = Backbone.View.extend({ Whisper.FileInputView = Backbone.View.extend({
tagName: 'span', tagName: 'span',
className: 'file-input', className: 'file-input',
initialize: function(options) { initialize(options) {
this.$input = this.$('input[type=file]'); this.$input = this.$('input[type=file]');
this.$input.click(function(e) { this.$input.click((e) => {
e.stopPropagation(); e.stopPropagation();
}); });
this.thumb = new Whisper.AttachmentPreviewView(); this.thumb = new Whisper.AttachmentPreviewView();
@ -75,76 +136,81 @@
'change .choose-file': 'previewImages', 'change .choose-file': 'previewImages',
'click .close': 'deleteFiles', 'click .close': 'deleteFiles',
'click .choose-file': 'open', 'click .choose-file': 'open',
'drop': 'openDropped', drop: 'openDropped',
'dragover': 'showArea', dragover: 'showArea',
'dragleave': 'hideArea', dragleave: 'hideArea',
'paste': 'onPaste' paste: 'onPaste',
}, },
open: function(e) { open(e) {
e.preventDefault(); e.preventDefault();
// hack // hack
if (this.window && this.window.chrome && this.window.chrome.fileSystem) { if (this.window && this.window.chrome && this.window.chrome.fileSystem) {
this.window.chrome.fileSystem.chooseEntry({type: 'openFile'}, function(entry) { this.window.chrome.fileSystem.chooseEntry({ type: 'openFile' }, (entry) => {
if (!entry) { if (!entry) {
return; return;
} }
entry.file(function(file) { entry.file((file) => {
this.file = file; this.file = file;
this.previewImages(); this.previewImages();
}.bind(this)); });
}.bind(this)); });
} else { } else {
this.$input.click(); this.$input.click();
} }
}, },
addThumb: function(src) { addThumb(src, options = {}) {
_.defaults(options, { addPlayIcon: false });
this.$('.avatar').hide(); this.$('.avatar').hide();
this.thumb.src = src; this.thumb.src = src;
this.$('.attachment-previews').append(this.thumb.render().el); this.$('.attachment-previews').append(this.thumb.render().el);
this.thumb.$('img')[0].onload = function() {
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.$el.trigger('force-resize');
}.bind(this); };
}, },
autoScale: function(file) { autoScale(file) {
if (file.type.split('/')[0] !== 'image' if (file.type.split('/')[0] !== 'image' ||
|| file.type === 'image/gif' file.type === 'image/gif' ||
|| file.type === 'image/tiff') { file.type === 'image/tiff') {
// nothing to do // nothing to do
return Promise.resolve(file); return Promise.resolve(file);
} }
return new Promise(function(resolve, reject) { return new Promise(((resolve, reject) => {
var url = URL.createObjectURL(file); const url = URL.createObjectURL(file);
var img = document.createElement('img'); const img = document.createElement('img');
img.onerror = reject; img.onerror = reject;
img.onload = function () { img.onload = () => {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
var maxSize = 6000 * 1024; const maxSize = 6000 * 1024;
var maxHeight = 4096; const maxHeight = 4096;
var maxWidth = 4096; const maxWidth = 4096;
if (img.width <= maxWidth && img.height <= maxHeight && if (img.width <= maxWidth && img.height <= maxHeight && file.size <= maxSize) {
file.size <= maxSize) {
resolve(file); resolve(file);
return; return;
} }
var canvas = loadImage.scale(img, { const canvas = loadImage.scale(img, {
canvas: true, maxWidth: maxWidth, maxHeight: maxHeight canvas: true, maxWidth, maxHeight,
}); });
var quality = 0.95; let quality = 0.95;
var i = 4; let i = 4;
var blob; let blob;
do { do {
i = i - 1; i -= 1;
blob = window.dataURLToBlobSync( blob = window.dataURLToBlobSync(canvas.toDataURL('image/jpeg', quality));
canvas.toDataURL('image/jpeg', quality) quality = (quality * maxSize) / blob.size;
);
quality = quality * maxSize / blob.size;
// NOTE: During testing with a large image, we observed the // NOTE: During testing with a large image, we observed the
// `quality` value being > 1. Should we clamp it to [0.5, 1.0]? // `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 // See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob#Syntax
@ -156,44 +222,59 @@
resolve(blob); resolve(blob);
}; };
img.src = url; img.src = url;
}); }));
}, },
previewImages: function() { async previewImages() {
this.clearForm(); this.clearForm();
var file = this.file || this.$input.prop('files')[0]; const file = this.file || this.$input.prop('files')[0];
if (!file) { return; } if (!file) {
return;
var type = file.type.split('/')[0];
if (file.type === 'image/tiff') {
type = 'file';
} }
switch (type) {
case 'audio': this.addThumb('images/audio.svg'); break; const contentType = file.type;
case 'video': this.addThumb('images/video.svg'); break;
case 'image': const renderVideoPreview = async () => {
// we use the variable on this here to ensure cleanup if we're interrupted
this.previewObjectUrl = URL.createObjectURL(file);
const thumbnail = await makeVideoScreenshot(this.previewObjectUrl);
URL.revokeObjectURL(this.previewObjectUrl);
const data = await blobToArrayBuffer(thumbnail);
this.previewObjectUrl = Signal.Util.arrayBufferToObjectURL({
data,
type: 'image/png',
});
this.addThumb(this.previewObjectUrl, { addPlayIcon: true });
};
const renderImagePreview = async () => {
if (!MIME.isJPEG(file.type)) { if (!MIME.isJPEG(file.type)) {
this.previewObjectUrl = URL.createObjectURL(file); this.previewObjectUrl = URL.createObjectURL(file);
this.addThumb(this.previewObjectUrl); this.addThumb(this.previewObjectUrl);
break; return;
} }
// NOTE: Temporarily allow `then` until we convert the entire file const dataUrl = await window.autoOrientImage(file);
// to `async` / `await`: this.addThumb(dataUrl);
// eslint-disable-next-line more/no-then };
window.autoOrientImage(file)
.then(dataURL => this.addThumb(dataURL)); if (Signal.Util.GoogleChrome.isImageTypeSupported(contentType)) {
break; renderImagePreview();
default: } else if (Signal.Util.GoogleChrome.isVideoTypeSupported(contentType)) {
this.addThumb('images/file.svg'); break; renderVideoPreview();
} else if (MIME.isAudio(contentType)) {
this.addThumb('images/audio.svg');
} else {
this.addThumb('images/file.svg');
} }
// NOTE: Temporarily allow `then` until we convert the entire file const blob = await this.autoScale(file);
// to `async` / `await`: let limitKb = 1000000;
// eslint-disable-next-line more/no-then const blobType = file.type === 'image/gif'
this.autoScale(file).then(function(blob) { ? 'gif'
var limitKb = 1000000; : contentType.split('/')[0];
var blobType = file.type === 'image/gif' ? 'gif' : type;
switch (blobType) { switch (blobType) {
case 'image': case 'image':
limitKb = 6000; break; limitKb = 6000; break;
@ -206,31 +287,28 @@
default: default:
limitKb = 100000; break; limitKb = 100000; break;
} }
if ((blob.size/1024).toFixed(4) >= limitKb) { if ((blob.size / 1024).toFixed(4) >= limitKb) {
var units = ['kB','MB','GB']; const units = ['kB', 'MB', 'GB'];
var u = -1; let u = -1;
var limit = limitKb * 1000; let limit = limitKb * 1000;
do { do {
limit /= 1000; limit /= 1000;
++u; u += 1;
} while (limit >= 1000 && u < units.length - 1); } while (limit >= 1000 && u < units.length - 1);
var toast = new Whisper.FileSizeToast({ const toast = new Whisper.FileSizeToast({
model: {limit: limit, units: units[u]} model: { limit, units: units[u] },
}); });
toast.$el.insertAfter(this.$el); toast.$el.insertAfter(this.$el);
toast.render(); toast.render();
this.deleteFiles(); this.deleteFiles();
} }
}.bind(this));
}, },
hasFiles: function() { hasFiles() {
var files = this.file ? [this.file] : this.$input.prop('files'); const files = this.file ? [this.file] : this.$input.prop('files');
return files && files.length && files.length > 0; return files && files.length && files.length > 0;
}, },
/* eslint-enable */
/* jshint ignore:start */
getFiles() { getFiles() {
const files = this.file ? [this.file] : Array.from(this.$input.prop('files')); const files = this.file ? [this.file] : Array.from(this.$input.prop('files'));
const promise = Promise.all(files.map(file => this.getFile(file))); const promise = Promise.all(files.map(file => this.getFile(file)));
@ -262,44 +340,45 @@
.then(this.readFile) .then(this.readFile)
.then(setFlags(attachmentFlags)); .then(setFlags(attachmentFlags));
}, },
/* jshint ignore:end */
/* eslint-disable */
getThumbnail: function() { async getThumbnail() {
// Scale and crop an image to 256px square // Scale and crop an image to 256px square
var size = 256; const size = 256;
var file = this.file || this.$input.prop('files')[0]; const file = this.file || this.$input.prop('files')[0];
if (file === undefined || file.type.split('/')[0] !== 'image' || file.type === 'image/gif') { if (file === undefined ||
file.type.split('/')[0] !== 'image' ||
file.type === 'image/gif') {
// nothing to do // nothing to do
return Promise.resolve(); return Promise.resolve();
} }
const objectUrl = URL.createObjectURL(file); const objectUrl = URL.createObjectURL(file);
return makeThumbnail(256, file).then(function(arrayBuffer) {
URL.revokeObjectURL(url); const arrayBuffer = await makeImageThumbnail(size, objectUrl);
URL.revokeObjectURL(objectUrl);
return this.readFile(arrayBuffer); return this.readFile(arrayBuffer);
});
}, },
// File -> Promise Attachment // File -> Promise Attachment
readFile: function(file) { readFile(file) {
return new Promise(function(resolve, reject) { return new Promise(((resolve, reject) => {
var FR = new FileReader(); const FR = new FileReader();
FR.onload = function(e) { FR.onload = (e) => {
resolve({ resolve({
data: e.target.result, data: e.target.result,
contentType: file.type, contentType: file.type,
fileName: file.name, fileName: file.name,
size: file.size size: file.size,
}); });
}; };
FR.onerror = reject; FR.onerror = reject;
FR.onabort = reject; FR.onabort = reject;
FR.readAsArrayBuffer(file); FR.readAsArrayBuffer(file);
}); }));
}, },
clearForm: function() { clearForm() {
if (this.previewObjectUrl) { if (this.previewObjectUrl) {
URL.revokeObjectURL(this.previewObjectUrl); URL.revokeObjectURL(this.previewObjectUrl);
this.previewObjectUrl = null; this.previewObjectUrl = null;
@ -310,7 +389,7 @@
this.$el.trigger('force-resize'); this.$el.trigger('force-resize');
}, },
deleteFiles: function(e) { deleteFiles(e) {
if (e) { e.stopPropagation(); } if (e) { e.stopPropagation(); }
this.clearForm(); this.clearForm();
this.$input.wrap('<form>').parent('form').trigger('reset'); this.$input.wrap('<form>').parent('form').trigger('reset');
@ -320,20 +399,22 @@
this.isVoiceNote = false; this.isVoiceNote = false;
}, },
openDropped: function(e) { openDropped(e) {
if (e.originalEvent.dataTransfer.types[0] != 'Files') { if (e.originalEvent.dataTransfer.types[0] !== 'Files') {
return; return;
} }
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
// eslint-disable-next-line prefer-destructuring
this.file = e.originalEvent.dataTransfer.files[0]; this.file = e.originalEvent.dataTransfer.files[0];
this.previewImages(); this.previewImages();
this.$el.removeClass('dropoff'); this.$el.removeClass('dropoff');
}, },
showArea: function(e) { showArea(e) {
if (e.originalEvent.dataTransfer.types[0] != 'Files') { if (e.originalEvent.dataTransfer.types[0] !== 'Files') {
return; return;
} }
@ -342,8 +423,8 @@
this.$el.addClass('dropoff'); this.$el.addClass('dropoff');
}, },
hideArea: function(e) { hideArea(e) {
if (e.originalEvent.dataTransfer.types[0] != 'Files') { if (e.originalEvent.dataTransfer.types[0] !== 'Files') {
return; return;
} }
@ -351,10 +432,10 @@
e.preventDefault(); e.preventDefault();
this.$el.removeClass('dropoff'); this.$el.removeClass('dropoff');
}, },
onPaste: function(e) { onPaste(e) {
var items = e.originalEvent.clipboardData.items; const { items } = e.originalEvent.clipboardData;
var imgBlob = null; let imgBlob = null;
for (var i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i += 1) {
if (items[i].type.split('/')[0] === 'image') { if (items[i].type.split('/')[0] === 'image') {
imgBlob = items[i].getAsFile(); imgBlob = items[i].getAsFile();
} }
@ -363,8 +444,10 @@
this.file = imgBlob; this.file = imgBlob;
this.previewImages(); this.previewImages();
} }
} },
}); });
Whisper.FileInputView.makeThumbnail = makeThumbnail; Whisper.FileInputView.makeImageThumbnail = makeImageThumbnail;
})(); Whisper.FileInputView.makeVideoThumbnail = makeVideoThumbnail;
Whisper.FileInputView.makeVideoScreenshot = makeVideoScreenshot;
}());

View file

@ -927,6 +927,32 @@ span.status {
form.send { form.send {
background: #ffffff; background: #ffffff;
&.video-attachment {
.image-container {
position: relative;
}
.outer {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
.play.icon {
height: 30px;
width: 30px;
@include color-svg('../images/play.svg', white);
}
}
}
} }
input, textarea { input, textarea {
@ -935,8 +961,10 @@ span.status {
.attachment-previews { .attachment-previews {
padding: 0 36px; padding: 0 36px;
.attachment-preview { .attachment-preview {
padding: 13px 10px 0; padding: 13px 10px 0;
} }
img { img {
border: 2px solid #ddd; border: 2px solid #ddd;
@ -1122,6 +1150,7 @@ span.status {
background-color: white; background-color: white;
border: none; border: none;
box-shadow: 0px 3px 5px 0px rgba(0,0,0,0.2); box-shadow: 0px 3px 5px 0px rgba(0,0,0,0.2);
outline: none;
.icon { .icon {
@include color-svg('../images/down.svg', $grey_l3); @include color-svg('../images/down.svg', $grey_l3);