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'>
<img src='{{ source }}' class='preview' /> <div class='image-container'>
<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,18 +1242,27 @@
if (!thumbnail) { if (!thumbnail) {
return false; return false;
} }
const thumbnailWithData = await loadAttachmentData(thumbnail); try {
thumbnailWithData.objectUrl = this.makeObjectUrl( const thumbnailWithData = await loadAttachmentData(thumbnail);
thumbnailWithData.data, const { data, contentType } = thumbnailWithData;
thumbnailWithData.contentType thumbnailWithData.objectUrl = Signal.Util.arrayBufferToObjectURL({
); 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
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
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);
return;
}
// Note: in the future when we generate our own thumbnail we won't need to rely // No need to go further if we already have a thumbnail
// on incoming thumbnail if we have our local message in hand. if (gotThumbnail) {
if (!message.quotedMessage.imageUrl) {
await this.loadQuoteThumbnail(message, quote);
}
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,236 +1,314 @@
/* 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 || {};
const { MIME } = window.Signal.Types; window.Whisper = window.Whisper || {};
Whisper.FileSizeToast = Whisper.ToastView.extend({ const { MIME } = window.Signal.Types;
templateName: 'file-size-modal',
render_attributes: function() {
return {
'file-size-warning': i18n('fileSizeWarning'),
limit: this.model.limit,
units: this.model.units
};
}
});
Whisper.UnsupportedFileTypeToast = Whisper.ToastView.extend({
template: i18n('unsupportedFileType')
});
function makeThumbnail(size, objectUrl) { Whisper.FileSizeToast = Whisper.ToastView.extend({
return new Promise(function(resolve, reject) { templateName: 'file-size-modal',
var img = document.createElement('img'); render_attributes() {
img.onerror = reject; return {
img.onload = function () { 'file-size-warning': i18n('fileSizeWarning'),
// using components/blueimp-load-image limit: this.model.limit,
units: this.model.units,
};
},
});
Whisper.UnsupportedFileTypeToast = Whisper.ToastView.extend({
template: i18n('unsupportedFileType'),
});
// first, make the correct size function makeImageThumbnail(size, objectUrl) {
var canvas = loadImage.scale(img, { return new Promise(((resolve, reject) => {
canvas: true, const img = document.createElement('img');
cover: true, img.onerror = reject;
maxWidth: size, img.onload = () => {
maxHeight: size, // using components/blueimp-load-image
minWidth: size,
minHeight: size,
});
// then crop // first, make the correct size
canvas = loadImage.scale(canvas, { let canvas = loadImage.scale(img, {
canvas: true, canvas: true,
crop: true, cover: true,
maxWidth: size, maxWidth: size,
maxHeight: size, maxHeight: size,
minWidth: size, minWidth: size,
minHeight: size, minHeight: size,
});
var blob = window.dataURLToBlobSync(canvas.toDataURL('image/png'));
resolve(blob);
};
img.src = objectUrl;
}); });
}
Whisper.FileInputView = Backbone.View.extend({ // then crop
tagName: 'span', canvas = loadImage.scale(canvas, {
className: 'file-input', canvas: true,
initialize: function(options) { crop: true,
this.$input = this.$('input[type=file]'); maxWidth: size,
this.$input.click(function(e) { maxHeight: size,
e.stopPropagation(); minWidth: size,
}); minHeight: size,
this.thumb = new Whisper.AttachmentPreviewView(); });
this.$el.addClass('file-input');
this.window = options.window;
this.previewObjectUrl = null;
},
events: { const blob = window.dataURLToBlobSync(canvas.toDataURL('image/png'));
'change .choose-file': 'previewImages',
'click .close': 'deleteFiles',
'click .choose-file': 'open',
'drop': 'openDropped',
'dragover': 'showArea',
'dragleave': 'hideArea',
'paste': 'onPaste'
},
open: function(e) { resolve(blob);
e.preventDefault(); };
// hack img.src = objectUrl;
if (this.window && this.window.chrome && this.window.chrome.fileSystem) { }));
this.window.chrome.fileSystem.chooseEntry({type: 'openFile'}, function(entry) { }
if (!entry) {
return; function makeVideoScreenshot(objectUrl) {
} return new Promise(((resolve, reject) => {
entry.file(function(file) { const video = document.createElement('video');
this.file = file;
this.previewImages(); function capture() {
}.bind(this)); const canvas = document.createElement('canvas');
}.bind(this)); canvas.width = video.videoWidth;
} else { canvas.height = video.videoHeight;
this.$input.click(); 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({
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');
};
},
autoScale(file) {
if (file.type.split('/')[0] !== 'image' ||
file.type === 'image/gif' ||
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.width <= maxWidth && img.height <= maxHeight && file.size <= maxSize) {
resolve(file);
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);
addThumb: function(src) { resolve(blob);
this.$('.avatar').hide(); };
this.thumb.src = src; img.src = url;
this.$('.attachment-previews').append(this.thumb.render().el); }));
this.thumb.$('img')[0].onload = function() { },
this.$el.trigger('force-resize');
}.bind(this);
},
autoScale: function(file) { async previewImages() {
if (file.type.split('/')[0] !== 'image' this.clearForm();
|| file.type === 'image/gif' const file = this.file || this.$input.prop('files')[0];
|| file.type === 'image/tiff') { if (!file) {
// nothing to do return;
return Promise.resolve(file); }
}
return new Promise(function(resolve, reject) { const contentType = file.type;
var url = URL.createObjectURL(file);
var img = document.createElement('img');
img.onerror = reject;
img.onload = function () {
URL.revokeObjectURL(url);
var maxSize = 6000 * 1024; const renderVideoPreview = async () => {
var maxHeight = 4096; // we use the variable on this here to ensure cleanup if we're interrupted
var maxWidth = 4096; this.previewObjectUrl = URL.createObjectURL(file);
if (img.width <= maxWidth && img.height <= maxHeight && const thumbnail = await makeVideoScreenshot(this.previewObjectUrl);
file.size <= maxSize) { URL.revokeObjectURL(this.previewObjectUrl);
resolve(file);
return;
}
var canvas = loadImage.scale(img, { const data = await blobToArrayBuffer(thumbnail);
canvas: true, maxWidth: maxWidth, maxHeight: maxHeight this.previewObjectUrl = Signal.Util.arrayBufferToObjectURL({
}); data,
type: 'image/png',
});
this.addThumb(this.previewObjectUrl, { addPlayIcon: true });
};
var quality = 0.95; const renderImagePreview = async () => {
var i = 4; if (!MIME.isJPEG(file.type)) {
var blob; this.previewObjectUrl = URL.createObjectURL(file);
do { this.addThumb(this.previewObjectUrl);
i = i - 1; return;
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); const dataUrl = await window.autoOrientImage(file);
}; this.addThumb(dataUrl);
img.src = url; };
});
},
previewImages: function() { if (Signal.Util.GoogleChrome.isImageTypeSupported(contentType)) {
this.clearForm(); renderImagePreview();
var file = this.file || this.$input.prop('files')[0]; } else if (Signal.Util.GoogleChrome.isVideoTypeSupported(contentType)) {
if (!file) { return; } renderVideoPreview();
} else if (MIME.isAudio(contentType)) {
this.addThumb('images/audio.svg');
} else {
this.addThumb('images/file.svg');
}
var type = file.type.split('/')[0]; const blob = await this.autoScale(file);
if (file.type === 'image/tiff') { let limitKb = 1000000;
type = 'file'; const blobType = file.type === 'image/gif'
} ? 'gif'
switch (type) { : contentType.split('/')[0];
case 'audio': this.addThumb('images/audio.svg'); break;
case 'video': this.addThumb('images/video.svg'); break;
case 'image':
if (!MIME.isJPEG(file.type)) {
this.previewObjectUrl = URL.createObjectURL(file);
this.addThumb(this.previewObjectUrl);
break;
}
// NOTE: Temporarily allow `then` until we convert the entire file switch (blobType) {
// to `async` / `await`: case 'image':
// eslint-disable-next-line more/no-then limitKb = 6000; break;
window.autoOrientImage(file) case 'gif':
.then(dataURL => this.addThumb(dataURL)); limitKb = 25000; break;
break; case 'audio':
default: limitKb = 100000; break;
this.addThumb('images/file.svg'); 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();
}
},
// NOTE: Temporarily allow `then` until we convert the entire file hasFiles() {
// to `async` / `await`: const files = this.file ? [this.file] : this.$input.prop('files');
// eslint-disable-next-line more/no-then return files && files.length && files.length > 0;
this.autoScale(file).then(function(blob) { },
var limitKb = 1000000;
var blobType = file.type === 'image/gif' ? 'gif' : type;
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) {
var units = ['kB','MB','GB'];
var u = -1;
var limit = limitKb * 1000;
do {
limit /= 1000;
++u;
} while (limit >= 1000 && u < units.length - 1);
var toast = new Whisper.FileSizeToast({
model: {limit: limit, units: units[u]}
});
toast.$el.insertAfter(this.$el);
toast.render();
this.deleteFiles();
}
}.bind(this));
},
hasFiles: function() {
var files = this.file ? [this.file] : this.$input.prop('files');
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,109 +340,114 @@
.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 ||
// nothing to do file.type.split('/')[0] !== 'image' ||
return Promise.resolve(); file.type === 'image/gif') {
} // nothing to do
return Promise.resolve();
}
const objectUrl = URL.createObjectURL(file); const objectUrl = URL.createObjectURL(file);
return makeThumbnail(256, file).then(function(arrayBuffer) {
URL.revokeObjectURL(url);
return this.readFile(arrayBuffer);
});
},
// File -> Promise Attachment const arrayBuffer = await makeImageThumbnail(size, objectUrl);
readFile: function(file) { URL.revokeObjectURL(objectUrl);
return new Promise(function(resolve, reject) {
var FR = new FileReader();
FR.onload = function(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: function() { return this.readFile(arrayBuffer);
if (this.previewObjectUrl) { },
URL.revokeObjectURL(this.previewObjectUrl);
this.previewObjectUrl = null;
}
this.thumb.remove(); // File -> Promise Attachment
this.$('.avatar').show(); readFile(file) {
this.$el.trigger('force-resize'); 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);
}));
},
deleteFiles: function(e) { clearForm() {
if (e) { e.stopPropagation(); } if (this.previewObjectUrl) {
this.clearForm(); URL.revokeObjectURL(this.previewObjectUrl);
this.$input.wrap('<form>').parent('form').trigger('reset'); this.previewObjectUrl = null;
this.$input.unwrap(); }
this.file = null;
this.$input.trigger('change');
this.isVoiceNote = false;
},
openDropped: function(e) { this.thumb.remove();
if (e.originalEvent.dataTransfer.types[0] != 'Files') { this.$('.avatar').show();
return; this.$el.trigger('force-resize');
} },
e.stopPropagation(); deleteFiles(e) {
e.preventDefault(); if (e) { e.stopPropagation(); }
this.file = e.originalEvent.dataTransfer.files[0]; this.clearForm();
this.previewImages(); this.$input.wrap('<form>').parent('form').trigger('reset');
this.$el.removeClass('dropoff'); this.$input.unwrap();
}, this.file = null;
this.$input.trigger('change');
this.isVoiceNote = false;
},
showArea: 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();
this.$el.addClass('dropoff');
},
hideArea: function(e) { // eslint-disable-next-line prefer-destructuring
if (e.originalEvent.dataTransfer.types[0] != 'Files') { this.file = e.originalEvent.dataTransfer.files[0];
return; this.previewImages();
} this.$el.removeClass('dropoff');
},
e.stopPropagation(); showArea(e) {
e.preventDefault(); if (e.originalEvent.dataTransfer.types[0] !== 'Files') {
this.$el.removeClass('dropoff'); return;
}, }
onPaste: function(e) {
var items = e.originalEvent.clipboardData.items; e.stopPropagation();
var imgBlob = null; e.preventDefault();
for (var i = 0; i < items.length; i++) { this.$el.addClass('dropoff');
if (items[i].type.split('/')[0] === 'image') { },
imgBlob = items[i].getAsFile();
} hideArea(e) {
} if (e.originalEvent.dataTransfer.types[0] !== 'Files') {
if (imgBlob !== null) { return;
this.file = imgBlob; }
this.previewImages();
} 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();
}
},
});
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);