diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..5878c72c8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[{js/modules/**/*.js, test/modules/**/*.js}] +indent_size = 2 diff --git a/.eslintignore b/.eslintignore index 9569b6efc..c4a708004 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,17 +1,23 @@ build/** components/** +coverage/** dist/** libtextsecure/** -coverage/** # these aren't ready yet, pulling files in one-by-one -js/** -test/** +js/*.js +js/models/**/*.js +js/react/**/*.js +js/views/**/*.js +test/*.js +test/models/*.js +test/views/*.js /*.js + +# ES2015+ files +!js/background.js +!js/models/conversations.js +!js/views/file_input_view.js +!js/views/attachment_view.js !main.js !prepare_build.js - -# all of these files will be new -!test/server/**/*.js - -# all of app/ is included diff --git a/.eslintrc.js b/.eslintrc.js index f4db62c1e..55d953c9a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -21,7 +21,7 @@ module.exports = { }], // putting params on their own line helps stay within line length limit - 'function-paren-newline': ['error', 'consistent'], + 'function-paren-newline': ['error', 'multiline'], // 90 characters allows three+ side-by-side screens on a standard-size monitor 'max-len': ['error', { @@ -37,5 +37,7 @@ module.exports = { // though we have a logger, we still remap console to log to disk 'no-console': 'off', + + 'operator-linebreak': 'error', } }; diff --git a/Gruntfile.js b/Gruntfile.js index e286687dc..8eb6f81d7 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -103,6 +103,7 @@ module.exports = function(grunt) { '!js/Mp3LameEncoder.min.js', '!js/libsignal-protocol-worker.js', '!js/components.js', + '!js/modules/**/*.js', '!js/signal_protocol_store.js', '_locales/**/*' ], @@ -174,8 +175,10 @@ module.exports = function(grunt) { '!js/Mp3LameEncoder.min.js', '!js/libsignal-protocol-worker.js', '!js/components.js', + '!js/modules/**/*.js', 'test/**/*.js', '!test/blanket_mocha.js', + '!test/modules/**/*.js', '!test/test.js', ] } diff --git a/app/logging.js b/app/logging.js index bad52467a..52bf2cc04 100644 --- a/app/logging.js +++ b/app/logging.js @@ -117,8 +117,8 @@ function eliminateOutOfDateFiles(logPath, date) { const file = { path: target, start: isLineAfterDate(start, date), - end: isLineAfterDate(end[end.length - 1], date) - || isLineAfterDate(end[end.length - 2], date), + end: isLineAfterDate(end[end.length - 1], date) || + isLineAfterDate(end[end.length - 2], date), }; if (!file.start && !file.end) { diff --git a/background.html b/background.html index b4af657e3..193e4a19b 100644 --- a/background.html +++ b/background.html @@ -8,7 +8,7 @@ ({ + type: Message.GROUP, + id: group.id, + }); + + // Matches event data from `libtextsecure` `MessageReceiver::handleSentMessage`: + const getDescriptorForSent = ({ message, destination }) => ( + message.group + ? getGroupDescriptor(message.group) + : { type: Message.PRIVATE, id: destination } + ); + + // Matches event data from `libtextsecure` `MessageReceiver::handleDataMessage`: + const getDescriptorForReceived = ({ message, source }) => ( + message.group + ? getGroupDescriptor(message.group) + : { type: Message.PRIVATE, id: source } + ); + + function createMessageHandler({ + createMessage, + getMessageDescriptor, + handleProfileUpdate, + }) { + return async (event) => { + const { data, confirm } = event; + + const messageDescriptor = getMessageDescriptor(data); + + const { PROFILE_KEY_UPDATE } = textsecure.protobuf.DataMessage.Flags; + // eslint-disable-next-line no-bitwise + const isProfileUpdate = Boolean(data.message.flags & PROFILE_KEY_UPDATE); + if (isProfileUpdate) { + return handleProfileUpdate({ data, confirm, messageDescriptor }); + } - return isMessageDuplicate(message).then(function(isDuplicate) { - if (isDuplicate) { - console.log('Received duplicate message', message.idForLogging()); - ev.confirm(); - return; - } + const message = createMessage(data); + const isDuplicate = await isMessageDuplicate(message); + if (isDuplicate) { + console.log('Received duplicate message', message.idForLogging()); + return event.confirm(); + } - return ConversationController.getOrCreateAndWait(id, type).then(function() { - return message.handleDataMessage(data.message, ev.confirm, { - initialLoadComplete: initialLoadComplete - }); - }); - }); - } + const upgradedMessage = await Message.upgradeSchema(data.message); + await ConversationController.getOrCreateAndWait( + messageDescriptor.id, + messageDescriptor.type + ); + return message.handleDataMessage( + upgradedMessage, + event.confirm, + { initialLoadComplete } + ); + }; + } + + // Received: + async function handleMessageReceivedProfileUpdate({ + data, + confirm, + messageDescriptor, + }) { + const profileKey = data.message.profileKey.toArrayBuffer(); + const sender = await ConversationController.getOrCreateAndWait( + messageDescriptor.id, + 'private' + ); + await sender.setProfileKey(profileKey); + return confirm(); + } + + const onMessageReceived = createMessageHandler({ + handleProfileUpdate: handleMessageReceivedProfileUpdate, + getMessageDescriptor: getDescriptorForReceived, + createMessage: initIncomingMessage, + }); + + // Sent: + async function handleMessageSentProfileUpdate({ confirm, messageDescriptor }) { + const conversation = await ConversationController.getOrCreateAndWait( + messageDescriptor.id, + messageDescriptor.type + ); + await conversation.save({ profileSharing: true }); + return confirm(); + } + + function createSentMessage(data) { + const now = Date.now(); + return new Whisper.Message({ + source: textsecure.storage.user.getNumber(), + sourceDevice: data.device, + sent_at: data.timestamp, + received_at: now, + conversationId: data.destination, + type: 'outgoing', + sent: true, + expirationStartTimestamp: data.expirationStartTimestamp, + }); + } + + const onSentMessage = createMessageHandler({ + handleProfileUpdate: handleMessageSentProfileUpdate, + getMessageDescriptor: getDescriptorForSent, + createMessage: createSentMessage, + }); + /* jshint ignore:end */ + /* eslint-disable */ function isMessageDuplicate(message) { return new Promise(function(resolve) { diff --git a/js/models/conversations.js b/js/models/conversations.js index 45320b861..45bc96c94 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -1,10 +1,16 @@ -/* - * vim: ts=4:sw=4:expandtab - */ +/* eslint-disable */ + +/* global Signal: false */ +/* global storage: false */ +/* global textsecure: false */ +/* global Whisper: false */ + (function () { 'use strict'; window.Whisper = window.Whisper || {}; + const { Attachment, Message } = window.Signal.Types; + // TODO: Factor out private and group subclasses of Conversation var COLORS = [ @@ -598,54 +604,71 @@ } }, - sendMessage: function(body, attachments) { - this.queueJob(function() { - var now = Date.now(); + /* jshint ignore:start */ + /* eslint-enable */ + sendMessage(body, attachments) { + this.queueJob(async () => { + const now = Date.now(); - console.log( - 'Sending message to conversation', - this.idForLogging(), - 'with timestamp', - now - ); - - var message = this.messageCollection.add({ - body : body, - conversationId : this.id, - type : 'outgoing', - attachments : attachments, - sent_at : now, - received_at : now, - expireTimer : this.get('expireTimer'), - recipients : this.getRecipients() - }); - if (this.isPrivate()) { - message.set({destination: this.id}); - } - message.save(); + console.log( + 'Sending message to conversation', + this.idForLogging(), + 'with timestamp', + now + ); - this.save({ - active_at : now, - timestamp : now, - lastMessage : message.getNotificationText() - }); + const upgradedAttachments = + await Promise.all(attachments.map(Attachment.upgradeSchema)); + const message = this.messageCollection.add({ + body, + conversationId: this.id, + type: 'outgoing', + attachments: upgradedAttachments, + sent_at: now, + received_at: now, + expireTimer: this.get('expireTimer'), + recipients: this.getRecipients(), + }); + if (this.isPrivate()) { + message.set({ destination: this.id }); + } + message.save(); - var sendFunc; - if (this.get('type') == 'private') { - sendFunc = textsecure.messaging.sendMessageToNumber; - } - else { - sendFunc = textsecure.messaging.sendMessageToGroup; - } + this.save({ + active_at: now, + timestamp: now, + lastMessage: message.getNotificationText(), + }); - var profileKey; - if (this.get('profileSharing')) { - profileKey = storage.get('profileKey'); - } + const conversationType = this.get('type'); + const sendFunc = (() => { + switch (conversationType) { + case Message.PRIVATE: + return textsecure.messaging.sendMessageToNumber; + case Message.GROUP: + return textsecure.messaging.sendMessageToGroup; + default: + throw new TypeError(`Invalid conversation type: '${conversationType}'`); + } + })(); + + let profileKey; + if (this.get('profileSharing')) { + profileKey = storage.get('profileKey'); + } - message.send(sendFunc(this.get('id'), body, attachments, now, this.get('expireTimer'), profileKey)); - }.bind(this)); + message.send(sendFunc( + this.get('id'), + body, + upgradedAttachments, + now, + this.get('expireTimer'), + profileKey + )); + }); }, + /* jshint ignore:end */ + /* eslint-disable */ updateLastMessage: function() { var collection = new Whisper.MessageCollection(); diff --git a/js/models/messages.js b/js/models/messages.js index 7f8d5ddfe..dbde2dd90 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -373,7 +373,7 @@ // 1. on an incoming message // 2. on a sent message sync'd from another device // 3. in rare cases, an incoming message can be retried, though it will - // still through one of the previous two codepaths. + // still go through one of the previous two codepaths var message = this; var source = message.get('source'); var type = message.get('type'); diff --git a/js/modules/auto_orient_image.js b/js/modules/auto_orient_image.js new file mode 100644 index 000000000..efe37da05 --- /dev/null +++ b/js/modules/auto_orient_image.js @@ -0,0 +1,40 @@ +const loadImage = require('blueimp-load-image'); + +const DEFAULT_JPEG_QUALITY = 0.85; + +// File | Blob | URLString -> LoadImageOptions -> Promise +// +// Documentation for `options` (`LoadImageOptions`): +// https://github.com/blueimp/JavaScript-Load-Image/tree/v2.18.0#options +exports.autoOrientImage = (fileOrBlobOrURL, options = {}) => { + const optionsWithDefaults = Object.assign( + { + type: 'image/jpeg', + quality: DEFAULT_JPEG_QUALITY, + }, + options, + { + canvas: true, + orientation: true, + } + ); + + return new Promise((resolve, reject) => { + loadImage(fileOrBlobOrURL, (canvasOrError) => { + if (canvasOrError.type === 'error') { + const error = new Error('autoOrientImage: Failed to process image'); + error.cause = canvasOrError; + reject(error); + return; + } + + const canvas = canvasOrError; + const dataURL = canvas.toDataURL( + optionsWithDefaults.type, + optionsWithDefaults.quality + ); + + resolve(dataURL); + }, optionsWithDefaults); + }); +}; diff --git a/js/modules/types/attachment.js b/js/modules/types/attachment.js new file mode 100644 index 000000000..048f4f368 --- /dev/null +++ b/js/modules/types/attachment.js @@ -0,0 +1,98 @@ +const MIME = require('./mime'); +const { arrayBufferToBlob, blobToArrayBuffer, dataURLToBlob } = require('blob-util'); +const { autoOrientImage } = require('../auto_orient_image'); + +// Increment this everytime we change how attachments are upgraded. This allows us to +// retroactively upgrade existing attachments. As we add more upgrade steps, we could +// design a pipeline that does this incrementally, e.g. from version 0 (unknown) -> 1, +// 1 --> 2, etc., similar to how we do database migrations: +const CURRENT_PROCESS_VERSION = 1; + +// Schema version history +// +// Version 1 +// - Auto-orient JPEG attachments using EXIF `Orientation` data +// - Add `schemaVersion` property + +// // Incoming message attachment fields +// { +// id: string +// contentType: MIMEType +// data: ArrayBuffer +// digest: ArrayBuffer +// fileName: string +// flags: null +// key: ArrayBuffer +// size: integer +// thumbnail: ArrayBuffer +// schemaVersion: integer +// } + +// // Outgoing message attachment fields +// { +// contentType: MIMEType +// data: ArrayBuffer +// fileName: string +// size: integer +// schemaVersion: integer +// } + +// Middleware +// type UpgradeStep = Attachment -> Promise Attachment + +// UpgradeStep -> SchemaVersion -> UpgradeStep +const setSchemaVersion = (next, schemaVersion) => async (attachment) => { + const isAlreadyUpgraded = attachment.schemaVersion >= schemaVersion; + if (isAlreadyUpgraded) { + return attachment; + } + + let upgradedAttachment; + try { + upgradedAttachment = await next(attachment); + } catch (error) { + console.error('Attachment.setSchemaVersion: error:', error); + upgradedAttachment = null; + } + + const hasSuccessfullyUpgraded = upgradedAttachment !== null; + if (!hasSuccessfullyUpgraded) { + return attachment; + } + + return Object.assign( + {}, + upgradedAttachment, + { schemaVersion } + ); +}; + +// Upgrade steps +const autoOrientJPEG = async (attachment) => { + if (!MIME.isJPEG(attachment.contentType)) { + return attachment; + } + + const dataBlob = await arrayBufferToBlob(attachment.data, attachment.contentType); + const newDataBlob = await dataURLToBlob(await autoOrientImage(dataBlob)); + const newDataArrayBuffer = await blobToArrayBuffer(newDataBlob); + + // IMPORTANT: We overwrite the existing `data` `ArrayBuffer` losing the original + // image data. Ideally, we’d preserve the original image data for users who want to + // retain it but due to reports of data loss, we don’t want to overburden IndexedDB + // by potentially doubling stored image data. + // See: https://github.com/signalapp/Signal-Desktop/issues/1589 + const newAttachment = Object.assign({}, attachment, { + data: newDataArrayBuffer, + size: newDataArrayBuffer.byteLength, + }); + + // `digest` is no longer valid for auto-oriented image data, so we discard it: + delete newAttachment.digest; + + return newAttachment; +}; + +// Public API +// UpgradeStep +exports.upgradeSchema = setSchemaVersion(autoOrientJPEG, CURRENT_PROCESS_VERSION); diff --git a/js/modules/types/message.js b/js/modules/types/message.js new file mode 100644 index 000000000..195359b64 --- /dev/null +++ b/js/modules/types/message.js @@ -0,0 +1,17 @@ +const Attachment = require('./attachment'); + + +const GROUP = 'group'; +const PRIVATE = 'private'; + +// Public API +exports.GROUP = GROUP; +exports.PRIVATE = PRIVATE; + +// Schema +// Message -> Promise Message +exports.upgradeSchema = async message => + Object.assign({}, message, { + attachments: + await Promise.all(message.attachments.map(Attachment.upgradeSchema)), + }); diff --git a/js/modules/types/mime.js b/js/modules/types/mime.js new file mode 100644 index 000000000..82228f9dc --- /dev/null +++ b/js/modules/types/mime.js @@ -0,0 +1,2 @@ +exports.isJPEG = mimeType => + mimeType === 'image/jpeg'; diff --git a/js/views/attachment_view.js b/js/views/attachment_view.js index 0d1069113..03e477dc3 100644 --- a/js/views/attachment_view.js +++ b/js/views/attachment_view.js @@ -1,271 +1,290 @@ -/* - * vim: ts=4:sw=4:expandtab - */ +/* eslint-env browser */ + +/* global $: false */ +/* global _: false */ +/* global Backbone: false */ +/* global moment: false */ + +/* global i18n: false */ +/* global textsecure: false */ +/* global Whisper: false */ + +// eslint-disable-next-line func-names (function () { - 'use strict'; + const ESCAPE_KEY_CODE = 27; - var FileView = Whisper.View.extend({ - tagName: 'div', - className: 'fileView', - templateName: 'file-view', - render_attributes: function() { - return this.model; - } + const FileView = Whisper.View.extend({ + tagName: 'div', + className: 'fileView', + templateName: 'file-view', + render_attributes() { + return this.model; + }, }); - var ImageView = Backbone.View.extend({ - tagName: 'img', - initialize: function(dataUrl) { - this.dataUrl = dataUrl; - }, - events: { - 'load': 'update', - }, - update: function() { - this.trigger('update'); - }, - render: function() { - this.$el.attr('src', this.dataUrl); - return this; - } + const ImageView = Backbone.View.extend({ + tagName: 'img', + initialize(blobUrl) { + this.blobUrl = blobUrl; + }, + events: { + load: 'update', + }, + update() { + this.trigger('update'); + }, + render() { + this.$el.attr('src', this.blobUrl); + return this; + }, }); - var MediaView = Backbone.View.extend({ - initialize: function(dataUrl, contentType) { - this.dataUrl = dataUrl; - this.contentType = contentType; - this.$el.attr('controls', ''); - }, - events: { - 'canplay': 'canplay' - }, - canplay: function() { - this.trigger('update'); - }, - render: function() { - var $el = $(''); - $el.attr('src', this.dataUrl); - this.$el.append($el); - return this; - } + const MediaView = Backbone.View.extend({ + initialize(dataUrl, { contentType } = {}) { + this.dataUrl = dataUrl; + this.contentType = contentType; + this.$el.attr('controls', ''); + }, + events: { + canplay: 'canplay', + }, + canplay() { + this.trigger('update'); + }, + render() { + const $el = $(''); + $el.attr('src', this.dataUrl); + this.$el.append($el); + return this; + }, }); - var AudioView = MediaView.extend({ tagName: 'audio' }); - var VideoView = MediaView.extend({ tagName: 'video' }); + const AudioView = MediaView.extend({ tagName: 'audio' }); + const VideoView = MediaView.extend({ tagName: 'video' }); // Blacklist common file types known to be unsupported in Chrome - var UnsupportedFileTypes = [ + const UnsupportedFileTypes = [ 'audio/aiff', - 'video/quicktime' + 'video/quicktime', ]; Whisper.AttachmentView = Backbone.View.extend({ tagName: 'span', - className: function() { + className() { if (this.isImage()) { return 'attachment'; - } else { - return 'attachment bubbled'; } + return 'attachment bubbled'; }, - initialize: function(options) { - this.blob = new Blob([this.model.data], {type: this.model.contentType}); - if (!this.model.size) { - this.model.size = this.model.data.byteLength; - } - if (options.timestamp) { - this.timestamp = options.timestamp; - } + initialize(options) { + this.blob = new Blob([this.model.data], { type: this.model.contentType }); + if (!this.model.size) { + this.model.size = this.model.data.byteLength; + } + if (options.timestamp) { + this.timestamp = options.timestamp; + } }, events: { - 'click': 'onclick' + click: 'onclick', }, - unload: function() { - this.blob = null; + unload() { + this.blob = null; - if (this.lightBoxView) { - this.lightBoxView.remove(); - } - if (this.fileView) { - this.fileView.remove(); - } - if (this.view) { - this.view.remove(); - } + if (this.lightBoxView) { + this.lightBoxView.remove(); + } + if (this.fileView) { + this.fileView.remove(); + } + if (this.view) { + this.view.remove(); + } - this.remove(); + this.remove(); + }, + getFileType() { + switch (this.model.contentType) { + case 'video/quicktime': return 'mov'; + default: return this.model.contentType.split('/')[1]; + } }, - getFileType: function() { - switch(this.model.contentType) { - case 'video/quicktime': return 'mov'; - default: return this.model.contentType.split('/')[1]; - } - }, - onclick: function(e) { - if (this.isImage()) { - this.lightBoxView = new Whisper.LightboxView({ model: this }); - this.lightBoxView.render(); - this.lightBoxView.$el.appendTo(this.el); - this.lightBoxView.$el.trigger('show'); + onclick() { + if (this.isImage()) { + this.lightBoxView = new Whisper.LightboxView({ model: this }); + this.lightBoxView.render(); + this.lightBoxView.$el.appendTo(this.el); + this.lightBoxView.$el.trigger('show'); + } else { + this.saveFile(); + } + }, + isVoiceMessage() { + // eslint-disable-next-line no-bitwise + if (this.model.flags & textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE) { + return true; + } - } else { - this.saveFile(); - } - }, - isVoiceMessage: function() { - if (this.model.flags & textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE) { - return true; - } + // Support for android legacy voice messages + if (this.isAudio() && this.model.fileName === null) { + return true; + } - // Support for android legacy voice messages - if (this.isAudio() && this.model.fileName === null) { - return true; - } - }, - isAudio: function() { - return this.model.contentType.startsWith('audio/'); - }, - isVideo: function() { - return this.model.contentType.startsWith('video/'); - }, - isImage: function() { - var type = this.model.contentType; - return type.startsWith('image/') && type !== 'image/tiff'; - }, - mediaType: function() { - if (this.isVoiceMessage()) { - return 'voice'; - } else if (this.isAudio()) { - return 'audio'; - } else if (this.isVideo()) { - return 'video'; - } else if (this.isImage()) { - return 'image'; - } - }, - displayName: function() { - if (this.isVoiceMessage()) { - return i18n('voiceMessage'); - } - if (this.model.fileName) { - return this.model.fileName; - } - if (this.isAudio() || this.isVideo()) { - return i18n('mediaMessage'); - } + return false; + }, + isAudio() { + return this.model.contentType.startsWith('audio/'); + }, + isVideo() { + return this.model.contentType.startsWith('video/'); + }, + isImage() { + const type = this.model.contentType; + return type.startsWith('image/') && type !== 'image/tiff'; + }, + mediaType() { + if (this.isVoiceMessage()) { + return 'voice'; + } else if (this.isAudio()) { + return 'audio'; + } else if (this.isVideo()) { + return 'video'; + } else if (this.isImage()) { + return 'image'; + } - return i18n('unnamedFile'); + // NOTE: The existing code had no `return` but ESLint insists. Thought + // about throwing an error assuming this was unreachable code but it turns + // out that content type `image/tiff` falls through here: + return undefined; }, - suggestedName: function() { - if (this.model.fileName) { - return this.model.fileName; - } + displayName() { + if (this.isVoiceMessage()) { + return i18n('voiceMessage'); + } + if (this.model.fileName) { + return this.model.fileName; + } + if (this.isAudio() || this.isVideo()) { + return i18n('mediaMessage'); + } - var suggestion = 'signal'; - if (this.timestamp) { - suggestion += moment(this.timestamp).format('-YYYY-MM-DD-HHmmss'); - } - var fileType = this.getFileType(); - if (fileType) { - suggestion += '.' + fileType; - } - return suggestion; - }, - saveFile: function() { - var url = window.URL.createObjectURL(this.blob, { type: 'octet/stream' }); - var a = $('').attr({ href: url, download: this.suggestedName() }); - a[0].click(); - window.URL.revokeObjectURL(url); - }, - render: function() { - if (!this.isImage()) { - this.renderFileView(); - } - var View; - if (this.isImage()) { - View = ImageView; - } else if (this.isAudio()) { - View = AudioView; - } else if (this.isVideo()) { - View = VideoView; - } + return i18n('unnamedFile'); + }, + suggestedName() { + if (this.model.fileName) { + return this.model.fileName; + } - if (!View || _.contains(UnsupportedFileTypes, this.model.contentType)) { - this.update(); - return this; - } + let suggestion = 'signal'; + if (this.timestamp) { + suggestion += moment(this.timestamp).format('-YYYY-MM-DD-HHmmss'); + } + const fileType = this.getFileType(); + if (fileType) { + suggestion += `.${fileType}`; + } + return suggestion; + }, + saveFile() { + const url = window.URL.createObjectURL(this.blob, { type: 'octet/stream' }); + const a = $('').attr({ href: url, download: this.suggestedName() }); + a[0].click(); + window.URL.revokeObjectURL(url); + }, + render() { + if (!this.isImage()) { + this.renderFileView(); + } + let View; + if (this.isImage()) { + View = ImageView; + } else if (this.isAudio()) { + View = AudioView; + } else if (this.isVideo()) { + View = VideoView; + } - if (!this.objectUrl) { - this.objectUrl = window.URL.createObjectURL(this.blob); - } - this.view = new View(this.objectUrl, this.model.contentType); - this.view.$el.appendTo(this.$el); - this.listenTo(this.view, 'update', this.update); - this.view.render(); - if (View !== ImageView) { - this.timeout = setTimeout(this.onTimeout.bind(this), 5000); - } + if (!View || _.contains(UnsupportedFileTypes, this.model.contentType)) { + this.update(); return this; + } + + if (!this.objectUrl) { + this.objectUrl = window.URL.createObjectURL(this.blob); + } + + const { blob } = this; + const { contentType } = this.model; + this.view = new View(this.objectUrl, { blob, contentType }); + this.view.$el.appendTo(this.$el); + this.listenTo(this.view, 'update', this.update); + this.view.render(); + if (View !== ImageView) { + this.timeout = setTimeout(this.onTimeout.bind(this), 5000); + } + return this; }, - onTimeout: function() { - // Image or media element failed to load. Fall back to FileView. - this.stopListening(this.view); - this.update(); + onTimeout() { + // Image or media element failed to load. Fall back to FileView. + this.stopListening(this.view); + this.update(); }, - renderFileView: function() { - this.fileView = new FileView({ - model: { - mediaType: this.mediaType(), - fileName: this.displayName(), - fileSize: window.filesize(this.model.size), - altText: i18n('clickToSave') - } - }); + renderFileView() { + this.fileView = new FileView({ + model: { + mediaType: this.mediaType(), + fileName: this.displayName(), + fileSize: window.filesize(this.model.size), + altText: i18n('clickToSave'), + }, + }); - this.fileView.$el.appendTo(this.$el.empty()); - this.fileView.render(); - return this; + this.fileView.$el.appendTo(this.$el.empty()); + this.fileView.render(); + return this; + }, + update() { + clearTimeout(this.timeout); + this.trigger('update'); }, - update: function() { - clearTimeout(this.timeout); - this.trigger('update'); - } }); Whisper.LightboxView = Whisper.View.extend({ - templateName: 'lightbox', - className: 'modal lightbox', - initialize: function() { - this.window = window; - this.$document = $(this.window.document); - this.listener = this.onkeyup.bind(this); - this.$document.on('keyup', this.listener); - }, - events: { - 'click .save': 'save', - 'click .close': 'remove', - 'click': 'onclick' - }, - save: function(e) { - this.model.saveFile(); - }, - onclick: function(e) { - var $el = this.$(e.target); - if (!$el.hasClass('image') && !$el.closest('.controls').length ) { - e.preventDefault(); - this.remove(); - return false; - } - }, - onkeyup: function(e) { - if (e.keyCode === 27) { - this.remove(); - this.$document.off('keyup', this.listener); - } - }, - render_attributes: function() { - return { url: this.model.objectUrl }; + templateName: 'lightbox', + className: 'modal lightbox', + initialize() { + this.window = window; + this.$document = $(this.window.document); + this.listener = this.onkeyup.bind(this); + this.$document.on('keyup', this.listener); + }, + events: { + 'click .save': 'save', + 'click .close': 'remove', + click: 'onclick', + }, + save() { + this.model.saveFile(); + }, + onclick(e) { + const $el = this.$(e.target); + if (!$el.hasClass('image') && !$el.closest('.controls').length) { + e.preventDefault(); + this.remove(); + return false; } - }); -})(); + return true; + }, + onkeyup(e) { + if (e.keyCode === ESCAPE_KEY_CODE) { + this.remove(); + this.$document.off('keyup', this.listener); + } + }, + render_attributes() { + return { url: this.model.objectUrl }; + }, + }); +}()); diff --git a/js/views/file_input_view.js b/js/views/file_input_view.js index d89228625..943771af1 100644 --- a/js/views/file_input_view.js +++ b/js/views/file_input_view.js @@ -1,10 +1,13 @@ -/* - * vim: ts=4:sw=4:expandtab - */ +/* eslint-disable */ + +/* global textsecure: false */ + (function () { 'use strict'; window.Whisper = window.Whisper || {}; + const { MIME } = window.Signal.Types; + Whisper.FileSizeToast = Whisper.ToastView.extend({ templateName: 'file-size-modal', render_attributes: function() { @@ -30,6 +33,7 @@ this.thumb = new Whisper.AttachmentPreviewView(); this.$el.addClass('file-input'); this.window = options.window; + this.previewObjectUrl = null; }, events: { @@ -93,7 +97,6 @@ return; } - // loadImage.scale -> components/blueimp-load-image var canvas = loadImage.scale(img, { canvas: true, maxWidth: maxWidth, maxHeight: maxHeight }); @@ -103,11 +106,13 @@ var blob; do { i = i - 1; - // dataURLtoBlob -> components/blueimp-canvas-to-blob - blob = dataURLtoBlob( + 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; } @@ -132,8 +137,14 @@ case 'audio': this.addThumb('images/audio.svg'); break; case 'video': this.addThumb('images/video.svg'); break; case 'image': - this.oUrl = URL.createObjectURL(file); - this.addThumb(this.oUrl); + if (!MIME.isJPEG(file.type)) { + this.previewObjectUrl = URL.createObjectURL(file); + this.addThumb(this.previewObjectUrl); + break; + } + + window.autoOrientImage(file) + .then(dataURL => this.addThumb(dataURL)); break; default: this.addThumb('images/file.svg'); break; @@ -177,30 +188,38 @@ return files && files.length && files.length > 0; }, - getFiles: function() { - var promises = []; - var files = this.file ? [this.file] : this.$input.prop('files'); - for (var i = 0; i < files.length; i++) { - promises.push(this.getFile(files[i])); - } - this.clearForm(); - return Promise.all(promises); - }, + /* eslint-enable */ + /* jshint ignore:start */ + 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: function(file) { - file = file || this.file || this.$input.prop('files')[0]; - if (file === undefined) { return Promise.resolve(); } - var flags; - if (this.isVoiceNote) { - flags = textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE; - } - return this.autoScale(file).then(this.readFile).then(function(attachment) { - if (flags) { - attachment.flags = flags; - } - return attachment; - }.bind(this)); - }, + getFile(rawFile) { + const file = rawFile || this.file || this.$input.prop('files')[0]; + if (file === undefined) { + 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; + }; + + return this.autoScale(file) + .then(this.readFile) + .then(setFlags(attachmentFlags)); + }, + /* jshint ignore:end */ + /* eslint-disable */ getThumbnail: function() { // Scale and crop an image to 256px square @@ -228,8 +247,7 @@ crop: true, minWidth: size, minHeight: size }); - // dataURLtoBlob -> components/blueimp-canvas-to-blob - var blob = dataURLtoBlob(canvas.toDataURL('image/png')); + var blob = window.dataURLToBlobSync(canvas.toDataURL('image/png')); resolve(blob); }; @@ -237,6 +255,7 @@ }).then(this.readFile); }, + // File -> Promise Attachment readFile: function(file) { return new Promise(function(resolve, reject) { var FR = new FileReader(); @@ -255,10 +274,11 @@ }, clearForm: function() { - if (this.oUrl) { - URL.revokeObjectURL(this.oUrl); - this.oUrl = null; + if (this.previewObjectUrl) { + URL.revokeObjectURL(this.previewObjectUrl); + this.previewObjectUrl = null; } + this.thumb.remove(); this.$('.avatar').show(); this.$el.trigger('force-resize'); diff --git a/libtextsecure/test/contacts_parser_test.js b/libtextsecure/test/contacts_parser_test.js index 22261dafc..fa4e25264 100644 --- a/libtextsecure/test/contacts_parser_test.js +++ b/libtextsecure/test/contacts_parser_test.js @@ -17,7 +17,7 @@ describe("ContactBuffer", function() { var contactInfo = new textsecure.protobuf.ContactDetails({ name: "Zero Cool", number: "+10000000000", - avatar: { contentType: "image/jpg", length: avatarLen } + avatar: { contentType: "image/jpeg", length: avatarLen } }); var contactInfoBuffer = contactInfo.encode().toArrayBuffer(); @@ -41,7 +41,7 @@ describe("ContactBuffer", function() { count++; assert.strictEqual(contact.name, "Zero Cool"); assert.strictEqual(contact.number, "+10000000000"); - assert.strictEqual(contact.avatar.contentType, "image/jpg"); + assert.strictEqual(contact.avatar.contentType, "image/jpeg"); assert.strictEqual(contact.avatar.length, 255); assert.strictEqual(contact.avatar.data.byteLength, 255); var avatarBytes = new Uint8Array(contact.avatar.data); @@ -68,7 +68,7 @@ describe("GroupBuffer", function() { id: new Uint8Array([1, 3, 3, 7]).buffer, name: "Hackers", members: ['cereal', 'burn', 'phreak', 'joey'], - avatar: { contentType: "image/jpg", length: avatarLen } + avatar: { contentType: "image/jpeg", length: avatarLen } }); var groupInfoBuffer = groupInfo.encode().toArrayBuffer(); @@ -93,7 +93,7 @@ describe("GroupBuffer", function() { assert.strictEqual(group.name, "Hackers"); assertEqualArrayBuffers(group.id.toArrayBuffer(), new Uint8Array([1,3,3,7]).buffer); assert.sameMembers(group.members, ['cereal', 'burn', 'phreak', 'joey']); - assert.strictEqual(group.avatar.contentType, "image/jpg"); + assert.strictEqual(group.avatar.contentType, "image/jpeg"); assert.strictEqual(group.avatar.length, 255); assert.strictEqual(group.avatar.data.byteLength, 255); var avatarBytes = new Uint8Array(group.avatar.data); diff --git a/main.js b/main.js index 6bb2c400e..584c92d1b 100644 --- a/main.js +++ b/main.js @@ -157,10 +157,10 @@ function isVisible(window, bounds) { const topClearOfUpperBound = window.y >= boundsY; const topClearOfLowerBound = (window.y <= (boundsY + boundsHeight) - BOUNDS_BUFFER); - return rightSideClearOfLeftBound - && leftSideClearOfRightBound - && topClearOfUpperBound - && topClearOfLowerBound; + return rightSideClearOfLeftBound && + leftSideClearOfRightBound && + topClearOfUpperBound && + topClearOfLowerBound; } function createWindow() { @@ -277,8 +277,8 @@ function createWindow() { // Emitted when the window is about to be closed. mainWindow.on('close', (e) => { // If the application is terminating, just do the default - if (windowState.shouldQuit() - || config.environment === 'test' || config.environment === 'test-lib') { + if (windowState.shouldQuit() || + config.environment === 'test' || config.environment === 'test-lib') { return; } @@ -422,9 +422,9 @@ app.on('before-quit', () => { app.on('window-all-closed', () => { // On OS X it is common for applications and their menu bar // to stay active until the user quits explicitly with Cmd + Q - if (process.platform !== 'darwin' - || config.environment === 'test' - || config.environment === 'test-lib') { + if (process.platform !== 'darwin' || + config.environment === 'test' || + config.environment === 'test-lib') { app.quit(); } }); diff --git a/package.json b/package.json index a6f4de219..0ae6567a7 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "main": "main.js", "scripts": { "postinstall": "electron-builder install-app-deps && rimraf node_modules/dtrace-provider", - "test": "npm run eslint && npm run test-server && grunt test", + "test": "npm run eslint && npm run test-server && grunt test && npm run test-modules", "lint": "grunt jshint", "start": "electron .", "asarl": "asar l release/mac/Signal.app/Contents/Resources/app.asar", @@ -28,6 +28,7 @@ "build-release": "SIGNAL_ENV=production npm run build -- --config.directories.output=release", "build-mas-release": "npm run build-release -- -m --config.mac.target=mas", "build-mas-dev": "npm run build-release -- -m --config.mac.target=mas --config.type=development", + "grunt": "grunt", "prep-mac-release": "npm run build-release -- -m --dir", "prep-release": "npm run generate && grunt prep-release && npm run build-release && npm run build-mas-release && grunt test-release", "release-mac": "npm run build-release -- -m --prepackaged release/mac/Signal*.app --publish=always", @@ -36,10 +37,14 @@ "release": "npm run release-mac && npm run release-win && npm run release-lin", "test-server": "mocha --recursive test/server", "test-server-coverage": "nyc --reporter=lcov --reporter=text mocha --recursive test/server", + "test-modules": "mocha --recursive test/modules", "eslint": "eslint .", "open-coverage": "open coverage/lcov-report/index.html" }, "dependencies": { + "blob-util": "^1.3.0", + "blueimp-canvas-to-blob": "^3.14.0", + "blueimp-load-image": "^2.18.0", "bunyan": "^1.8.12", "config": "^1.28.1", "electron-config": "^1.0.0", diff --git a/preload.js b/preload.js index 03423c389..ed616e913 100644 --- a/preload.js +++ b/preload.js @@ -60,6 +60,8 @@ window.nodeSetImmediate(function() {}); }, 1000); + window.dataURLToBlobSync = require('blueimp-canvas-to-blob'); + window.loadImage = require('blueimp-load-image'); window.ProxyAgent = require('proxy-agent'); window.EmojiConvertor = require('emoji-js'); window.emojiData = require('emoji-datasource'); @@ -70,6 +72,16 @@ window.libphonenumber.PhoneNumberFormat = require('google-libphonenumber').PhoneNumberFormat; window.nodeNotifier = require('node-notifier'); + const { autoOrientImage } = require('./js/modules/auto_orient_image'); + window.autoOrientImage = autoOrientImage; + + // ES2015+ modules + window.Signal = window.Signal || {}; + window.Signal.Types = window.Signal.Types || {}; + window.Signal.Types.Attachment = require('./js/modules/types/attachment'); + window.Signal.Types.Message = require('./js/modules/types/message'); + window.Signal.Types.MIME = require('./js/modules/types/mime'); + // We pull this in last, because the native module involved appears to be sensitive to // /tmp mounted as noexec on Linux. require('./js/spell_check'); diff --git a/test/modules/types/mime_test.js b/test/modules/types/mime_test.js new file mode 100644 index 000000000..b56f831c4 --- /dev/null +++ b/test/modules/types/mime_test.js @@ -0,0 +1,30 @@ +const { assert } = require('chai'); + +const MIME = require('../../../js/modules/types/mime'); + + +describe('MIME', () => { + describe('isJPEG', () => { + it('should return true for `image/jpeg`', () => { + assert.isTrue(MIME.isJPEG('image/jpeg')); + }); + + [ + 'jpg', + 'jpeg', + 'image/jpg', // invalid MIME type: https://stackoverflow.com/a/37266399/125305 + 'image/gif', + 'image/tiff', + 'application/json', + 0, + false, + null, + undefined, + ] + .forEach((value) => { + it(`should return false for \`${value}\``, () => { + assert.isFalse(MIME.isJPEG(value)); + }); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 9a17142f8..b4d5e145e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -437,6 +437,17 @@ bl@^1.0.0: dependencies: readable-stream "^2.0.5" +blob-util@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/blob-util/-/blob-util-1.3.0.tgz#dbb4e8caffd50b5720d347e1169b6369ba34fe95" + dependencies: + blob "0.0.4" + native-or-lie "1.0.2" + +blob@0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.4.tgz#bcf13052ca54463f30f9fc7e95b9a47630a94921" + block-stream@*: version "0.0.9" resolved "https://registry.yarnpkg.com/block-stream/-/block-stream-0.0.9.tgz#13ebfe778a03205cfe03751481ebb4b3300c126a" @@ -457,6 +468,14 @@ bluebird@^3.5.1: version "3.5.1" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9" +blueimp-canvas-to-blob@^3.14.0: + version "3.14.0" + resolved "https://registry.yarnpkg.com/blueimp-canvas-to-blob/-/blueimp-canvas-to-blob-3.14.0.tgz#ea075ffbfb1436607b0c75e951fb1ceb3ca0288e" + +blueimp-load-image@^2.18.0: + version "2.18.0" + resolved "https://registry.yarnpkg.com/blueimp-load-image/-/blueimp-load-image-2.18.0.tgz#03b93687eb382a7136cfbcbd4f0e936b6763fc0e" + bmp-js@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/bmp-js/-/bmp-js-0.0.1.tgz#5ad0147099d13a9f38aa7b99af1d6e78666ed37f" @@ -2606,6 +2625,10 @@ ignore@^3.3.3: version "3.3.7" resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.7.tgz#612289bfb3c220e186a58118618d5be8c1bab021" +immediate@~3.0.5: + version "3.0.6" + resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" + import-lazy@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43" @@ -3150,6 +3173,12 @@ levn@^0.3.0, levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" +lie@*: + version "3.2.0" + resolved "https://registry.yarnpkg.com/lie/-/lie-3.2.0.tgz#4f13f2f8bbb027d383db338c43043545791aa8dc" + dependencies: + immediate "~3.0.5" + livereload-js@^2.2.0: version "2.2.2" resolved "https://registry.yarnpkg.com/livereload-js/-/livereload-js-2.2.2.tgz#6c87257e648ab475bc24ea257457edcc1f8d0bc2" @@ -3490,6 +3519,12 @@ nan@^2.0.0, nan@^2.3.2, nan@^2.3.3: version "2.6.2" resolved "https://registry.yarnpkg.com/nan/-/nan-2.6.2.tgz#e4ff34e6c95fdfb5aecc08de6596f43605a7db45" +native-or-lie@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/native-or-lie/-/native-or-lie-1.0.2.tgz#c870ee0ba0bf0ff11350595d216cfea68a6d8086" + dependencies: + lie "*" + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"