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"