diff --git a/.eslintignore b/.eslintignore index e03245044ef1..460ba666c4d1 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,9 +2,9 @@ build/** components/** coverage/** dist/** -libtextsecure/** # these aren't ready yet, pulling files in one-by-one +libtextsecure/** js/*.js js/models/**/*.js js/views/**/*.js @@ -22,6 +22,7 @@ ts/**/*.js !js/database.js !js/logging.js !js/models/conversations.js +!js/models/messages.js !js/views/attachment_view.js !js/views/conversation_search_view.js !js/views/backbone_wrapper_view.js @@ -30,6 +31,7 @@ ts/**/*.js !js/views/inbox_view.js !js/views/message_view.js !js/views/settings_view.js +!libtextsecure/message_receiver.js !main.js !preload.js !prepare_build.js diff --git a/Gruntfile.js b/Gruntfile.js index ab96bbf69f53..daf093bda548 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -110,7 +110,11 @@ module.exports = function(grunt) { '!js/signal_protocol_store.js', '!js/views/conversation_search_view.js', '!js/views/debug_log_view.js', + '!js/views/message_view.js', + '!js/models/conversations.js', + '!js/models/messages.js', '!js/WebAudioRecorderMp3.js', + '!libtextsecure/message_receiver.js', '_locales/**/*' ], options: { jshintrc: '.jshintrc' }, @@ -160,6 +164,8 @@ module.exports = function(grunt) { '!js/libsignal-protocol-worker.js', '!js/libtextsecure.js', '!js/modules/**/*.js', + '!js/models/conversations.js', + '!js/models/messages.js', '!js/Mp3LameEncoder.min.js', '!js/WebAudioRecorderMp3.js', 'test/**/*.js', diff --git a/_locales/en/messages.json b/_locales/en/messages.json index de3771b9c4b9..7d1d9c86f765 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -428,6 +428,36 @@ "selectAContact": { "message": "Select a contact or group to start chatting." }, + "replyingToYourself": { + "message": "Replying to Yourself", + "description": "Shown in iOS theme when you quote yourself" + }, + "replyingToYou": { + "message": "Replying to You", + "description": "Shown in iOS theme when someone else quotes a message from you" + }, + "replyingTo": { + "message": "Replying to $name$", + "description": "Shown in iOS theme when you or someone quotes to a message which is not from you", + "placeholders": { + "name": { + "content": "$1", + "example": "John" + } + } + }, + "audio": { + "message": "Audio", + "description": "Shown in a quotation of a message containing an audio attachment if no text was originally provided with that attachment" + }, + "video": { + "message": "Video", + "description": "Shown in a quotation of a message containing a video if no text was originally provided with that video" + }, + "photo": { + "message": "Photo", + "description": "Shown in a quotation of a message containing a photo if no text was originally provided with that image" + }, "ok": { "message": "OK" }, diff --git a/background.html b/background.html index 0b5fa5cbdf77..29ff71e2caad 100644 --- a/background.html +++ b/background.html @@ -277,10 +277,15 @@ {{ profileName }} {{ /profileName }} -
-

- {{ #message }}{{ message }}{{ /message }} -

+
+
+
+
+
+ {{ #message }}
{{ message }}
{{ /message }} +
+
+
diff --git a/images/image.svg b/images/image.svg new file mode 100644 index 000000000000..5c61724ff1e6 --- /dev/null +++ b/images/image.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/play.svg b/images/play.svg new file mode 100644 index 000000000000..87a70f2d1c64 --- /dev/null +++ b/images/play.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/models/conversations.js b/js/models/conversations.js index e2bcc7d60c74..dc074c61831e 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -1,612 +1,615 @@ -/* eslint-disable */ - -/* global Signal: false */ /* global storage: false */ /* global textsecure: false */ /* global Whisper: false */ +/* global Backbone: false */ +/* global _: false */ +/* global ConversationController: false */ +/* global libphonenumber: false */ +/* global wrapDeferred: false */ +/* global dcodeIO: false */ +/* global libsignal: false */ +/* eslint-disable more/no-then */ + +// eslint-disable-next-line func-names (function () { 'use strict'; - window.Whisper = window.Whisper || {}; - const { Attachment, Message } = window.Signal.Types; - const { upgradeMessageSchema, loadAttachmentData } = window.Signal.Migrations; + window.Whisper = window.Whisper || {}; - // TODO: Factor out private and group subclasses of Conversation + const { Message, MIME } = window.Signal.Types; + const { upgradeMessageSchema, loadAttachmentData } = window.Signal.Migrations; - var COLORS = [ - 'red', - 'pink', - 'purple', - 'deep_purple', - 'indigo', - 'blue', - 'light_blue', - 'cyan', - 'teal', - 'green', - 'light_green', - 'orange', - 'deep_orange', - 'amber', - 'blue_grey', - ]; + // TODO: Factor out private and group subclasses of Conversation - function constantTimeEqualArrayBuffers(ab1, ab2) { - if (!(ab1 instanceof ArrayBuffer && ab2 instanceof ArrayBuffer)) { - return false; - } - if (ab1.byteLength !== ab2.byteLength) { - return false; - } - var result = 0; - var ta1 = new Uint8Array(ab1); - var ta2 = new Uint8Array(ab2); - for (var i = 0; i < ab1.byteLength; ++i) { - result = result | ta1[i] ^ ta2[i]; - } - return result === 0; + const COLORS = [ + 'red', + 'pink', + 'purple', + 'deep_purple', + 'indigo', + 'blue', + 'light_blue', + 'cyan', + 'teal', + 'green', + 'light_green', + 'orange', + 'deep_orange', + 'amber', + 'blue_grey', + ]; + + function constantTimeEqualArrayBuffers(ab1, ab2) { + if (!(ab1 instanceof ArrayBuffer && ab2 instanceof ArrayBuffer)) { + return false; } + if (ab1.byteLength !== ab2.byteLength) { + return false; + } + let result = 0; + const ta1 = new Uint8Array(ab1); + const ta2 = new Uint8Array(ab2); + for (let i = 0; i < ab1.byteLength; i += 1) { + // eslint-disable-next-line no-bitwise + result |= ta1[i] ^ ta2[i]; + } + return result === 0; + } Whisper.Conversation = Backbone.Model.extend({ database: Whisper.Database, storeName: 'conversations', - defaults: function() { - return { - unreadCount: 0, - verified: textsecure.storage.protocol.VerifiedStatus.DEFAULT - }; + defaults() { + return { + unreadCount: 0, + verified: textsecure.storage.protocol.VerifiedStatus.DEFAULT, + }; }, - idForLogging: function() { - if (this.isPrivate()) { - return this.id; - } + idForLogging() { + if (this.isPrivate()) { + return this.id; + } - return 'group(' + this.id + ')'; + return `group(${this.id})`; }, - handleMessageError: function(message, errors) { - this.trigger('messageError', message, errors); + handleMessageError(message, errors) { + this.trigger('messageError', message, errors); }, - initialize: function() { - this.ourNumber = textsecure.storage.user.getNumber(); - this.verifiedEnum = textsecure.storage.protocol.VerifiedStatus; + initialize() { + this.ourNumber = textsecure.storage.user.getNumber(); + this.verifiedEnum = textsecure.storage.protocol.VerifiedStatus; - // This may be overridden by ConversationController.getOrCreate, and signify - // our first save to the database. Or first fetch from the database. - this.initialPromise = Promise.resolve(); + // This may be overridden by ConversationController.getOrCreate, and signify + // our first save to the database. Or first fetch from the database. + this.initialPromise = Promise.resolve(); - this.contactCollection = new Backbone.Collection(); - var collator = new Intl.Collator(); - this.contactCollection.comparator = function(left, right) { - left = left.getTitle().toLowerCase(); - right = right.getTitle().toLowerCase(); - return collator.compare(left, right); - }; - this.messageCollection = new Whisper.MessageCollection([], { - conversation: this + this.contactCollection = new Backbone.Collection(); + const collator = new Intl.Collator(); + this.contactCollection.comparator = (left, right) => { + const leftLower = left.getTitle().toLowerCase(); + const rightLower = right.getTitle().toLowerCase(); + return collator.compare(leftLower, rightLower); + }; + this.messageCollection = new Whisper.MessageCollection([], { + conversation: this, + }); + + this.messageCollection.on('change:errors', this.handleMessageError, this); + this.messageCollection.on('send-error', this.onMessageError, this); + + this.on('change:avatar', this.updateAvatarUrl); + this.on('change:profileAvatar', this.updateAvatarUrl); + this.on('change:profileKey', this.onChangeProfileKey); + this.on('destroy', this.revokeAvatarUrl); + }, + + isMe() { + return this.id === this.ourNumber; + }, + + addSingleMessage(message) { + this.messageCollection.add(message, { merge: true }); + this.processQuotes(this.messageCollection); + }, + + onMessageError() { + this.updateVerified(); + }, + safeGetVerified() { + const promise = textsecure.storage.protocol.getVerified(this.id); + return promise.catch(() => textsecure.storage.protocol.VerifiedStatus.DEFAULT); + }, + updateVerified() { + if (this.isPrivate()) { + return Promise.all([ + this.safeGetVerified(), + this.initialPromise, + ]).then((results) => { + const trust = results[0]; + // we don't return here because we don't need to wait for this to finish + this.save({ verified: trust }); }); + } + const promise = this.fetchContacts(); - this.messageCollection.on('change:errors', this.handleMessageError, this); - this.messageCollection.on('send-error', this.onMessageError, this); - - this.on('change:avatar', this.updateAvatarUrl); - this.on('change:profileAvatar', this.updateAvatarUrl); - this.on('change:profileKey', this.onChangeProfileKey); - this.on('destroy', this.revokeAvatarUrl); - }, - - isMe: function() { - return this.id === this.ourNumber; - }, - - onMessageError: function() { - this.updateVerified(); - }, - safeGetVerified: function() { - return textsecure.storage.protocol.getVerified(this.id).catch(function() { - return textsecure.storage.protocol.VerifiedStatus.DEFAULT; - }); - }, - updateVerified: function() { - if (this.isPrivate()) { - return Promise.all([ - this.safeGetVerified(), - this.initialPromise, - ]).then(function(results) { - var trust = results[0]; - // we don't return here because we don't need to wait for this to finish - this.save({verified: trust}); - }.bind(this)); - } else { - return this.fetchContacts().then(function() { - return Promise.all(this.contactCollection.map(function(contact) { - if (!contact.isMe()) { - return contact.updateVerified(); - } - }.bind(this))); - }.bind(this)).then(this.onMemberVerifiedChange.bind(this)); + return promise.then(() => Promise.all(this.contactCollection.map((contact) => { + if (!contact.isMe()) { + return contact.updateVerified(); } + return Promise.resolve(); + }))).then(this.onMemberVerifiedChange.bind(this)); }, - setVerifiedDefault: function(options) { - var DEFAULT = this.verifiedEnum.DEFAULT; - return this.queueJob(function() { - return this._setVerified(DEFAULT, options); - }.bind(this)); + setVerifiedDefault(options) { + const { DEFAULT } = this.verifiedEnum; + return this.queueJob(() => this._setVerified(DEFAULT, options)); }, - setVerified: function(options) { - var VERIFIED = this.verifiedEnum.VERIFIED; - return this.queueJob(function() { - return this._setVerified(VERIFIED, options); - }.bind(this)); + setVerified(options) { + const { VERIFIED } = this.verifiedEnum; + return this.queueJob(() => this._setVerified(VERIFIED, options)); }, - setUnverified: function(options) { - var UNVERIFIED = this.verifiedEnum.UNVERIFIED; - return this.queueJob(function() { - return this._setVerified(UNVERIFIED, options); - }.bind(this)); + setUnverified(options) { + const { UNVERIFIED } = this.verifiedEnum; + return this.queueJob(() => this._setVerified(UNVERIFIED, options)); }, - _setVerified: function(verified, options) { - options = options || {}; - _.defaults(options, {viaSyncMessage: false, viaContactSync: false, key: null}); + _setVerified(verified, providedOptions) { + const options = providedOptions || {}; + _.defaults(options, { viaSyncMessage: false, viaContactSync: false, key: null }); - var DEFAULT = this.verifiedEnum.DEFAULT; - var VERIFIED = this.verifiedEnum.VERIFIED; - var UNVERIFIED = this.verifiedEnum.UNVERIFIED; + const { + VERIFIED, + UNVERIFIED, + } = this.verifiedEnum; - if (!this.isPrivate()) { - throw new Error('You cannot verify a group conversation. ' + + if (!this.isPrivate()) { + throw new Error('You cannot verify a group conversation. ' + 'You must verify individual contacts.'); + } + + const beginningVerified = this.get('verified'); + let promise; + if (options.viaSyncMessage) { + // handle the incoming key from the sync messages - need different + // behavior if that key doesn't match the current key + promise = textsecure.storage.protocol.processVerifiedMessage( + this.id, + verified, + options.key + ); + } else { + promise = textsecure.storage.protocol.setVerified(this.id, verified); + } + + let keychange; + return promise.then((updatedKey) => { + keychange = updatedKey; + return new Promise((resolve => this.save({ verified }).always(resolve))); + }).then(() => { + // Three situations result in a verification notice in the conversation: + // 1) The message came from an explicit verification in another client (not + // a contact sync) + // 2) The verification value received by the contact sync is different + // from what we have on record (and it's not a transition to UNVERIFIED) + // 3) Our local verification status is VERIFIED and it hasn't changed, + // but the key did change (Key1/VERIFIED to Key2/VERIFIED - but we don't + // want to show DEFAULT->DEFAULT or UNVERIFIED->UNVERIFIED) + if (!options.viaContactSync || + (beginningVerified !== verified && verified !== UNVERIFIED) || + (keychange && verified === VERIFIED)) { + return this.addVerifiedChange( + this.id, + verified === VERIFIED, + { local: !options.viaSyncMessage } + ); } - - var beginningVerified = this.get('verified'); - var promise; - if (options.viaSyncMessage) { - // handle the incoming key from the sync messages - need different - // behavior if that key doesn't match the current key - promise = textsecure.storage.protocol.processVerifiedMessage( - this.id, verified, options.key - ); - } else { - promise = textsecure.storage.protocol.setVerified( - this.id, verified - ); + if (!options.viaSyncMessage) { + return this.sendVerifySyncMessage(this.id, verified); } - - var keychange; - return promise.then(function(updatedKey) { - keychange = updatedKey; - return new Promise(function(resolve) { - return this.save({verified: verified}).always(resolve); - }.bind(this)); - }.bind(this)).then(function() { - // Three situations result in a verification notice in the conversation: - // 1) The message came from an explicit verification in another client (not - // a contact sync) - // 2) The verification value received by the contact sync is different - // from what we have on record (and it's not a transition to UNVERIFIED) - // 3) Our local verification status is VERIFIED and it hasn't changed, - // but the key did change (Key1/VERIFIED to Key2/VERIFIED - but we don't - // want to show DEFAULT->DEFAULT or UNVERIFIED->UNVERIFIED) - if (!options.viaContactSync - || (beginningVerified !== verified && verified !== UNVERIFIED) - || (keychange && verified === VERIFIED)) { - - this.addVerifiedChange(this.id, verified === VERIFIED, {local: !options.viaSyncMessage}); - } - if (!options.viaSyncMessage) { - return this.sendVerifySyncMessage(this.id, verified); - } - }.bind(this)); + return Promise.resolve(); + }); }, - sendVerifySyncMessage: function(number, state) { - return textsecure.storage.protocol.loadIdentityKey(number).then(function(key) { - return textsecure.messaging.syncVerification(number, state, key); + sendVerifySyncMessage(number, state) { + const promise = textsecure.storage.protocol.loadIdentityKey(number); + return promise.then(key => textsecure.messaging.syncVerification( + number, + state, + key + )); + }, + getIdentityKeys() { + const lookup = {}; + + if (this.isPrivate()) { + return textsecure.storage.protocol.loadIdentityKey(this.id).then((key) => { + lookup[this.id] = key; + return lookup; + }).catch((error) => { + console.log( + 'getIdentityKeys error for conversation', + this.idForLogging(), + error && error.stack ? error.stack : error + ); + return lookup; }); - }, - getIdentityKeys: function() { - var lookup = {}; - - if (this.isPrivate()) { - return textsecure.storage.protocol.loadIdentityKey(this.id).then(function(key) { - lookup[this.id] = key; - return lookup; - }.bind(this)).catch(function(error) { - console.log( - 'getIdentityKeys error for conversation', - this.idForLogging(), - error && error.stack ? error.stack : error - ); - return lookup; - }.bind(this)); - } else { - return Promise.all(this.contactCollection.map(function(contact) { - return textsecure.storage.protocol.loadIdentityKey(contact.id).then(function(key) { - lookup[contact.id] = key; - }).catch(function(error) { - console.log( - 'getIdentityKeys error for group member', - contact.idForLogging(), - error && error.stack ? error.stack : error - ); - }); - })).then(function() { - return lookup; - }); - } - }, - replay: function(error, message) { - var replayable = new textsecure.ReplayableError(error); - return replayable.replay(message.attributes).catch(function(error) { + } + const promises = this.contactCollection.map(contact => + textsecure.storage.protocol.loadIdentityKey(contact.id).then( + (key) => { + lookup[contact.id] = key; + }, + (error) => { console.log( - 'replay error:', - error && error.stack ? error.stack : error + 'getIdentityKeys error for group member', + contact.idForLogging(), + error && error.stack ? error.stack : error ); + } + )); + + return Promise.all(promises).then(() => lookup); + }, + replay(error, message) { + const replayable = new textsecure.ReplayableError(error); + return replayable.replay(message.attributes).catch((e) => { + console.log( + 'replay error:', + e && e.stack ? e.stack : e + ); + }); + }, + decryptOldIncomingKeyErrors() { + // We want to run just once per conversation + if (this.get('decryptedOldIncomingKeyErrors')) { + return Promise.resolve(); + } + console.log('decryptOldIncomingKeyErrors start for', this.idForLogging()); + + const messages = this.messageCollection.filter((message) => { + const errors = message.get('errors'); + if (!errors || !errors[0]) { + return false; + } + const error = _.find(errors, e => e.name === 'IncomingIdentityKeyError'); + + return Boolean(error); + }); + + const markComplete = () => { + console.log('decryptOldIncomingKeyErrors complete for', this.idForLogging()); + return new Promise((resolve) => { + this.save({ decryptedOldIncomingKeyErrors: true }).always(resolve); }); - }, - decryptOldIncomingKeyErrors: function() { - // We want to run just once per conversation - if (this.get('decryptedOldIncomingKeyErrors')) { - return Promise.resolve(); - } - console.log('decryptOldIncomingKeyErrors start for', this.idForLogging()); + }; - var messages = this.messageCollection.filter(function(message) { - var errors = message.get('errors'); - if (!errors || !errors[0]) { - return false; - } - var error = _.find(errors, function(error) { - return error.name === 'IncomingIdentityKeyError'; - }); + if (!messages.length) { + return markComplete(); + } - return Boolean(error); - }); + console.log( + 'decryptOldIncomingKeyErrors found', + messages.length, + 'messages to process' + ); + const safeDelete = message => new Promise((resolve) => { + message.destroy().always(resolve); + }); - var markComplete = function() { - console.log('decryptOldIncomingKeyErrors complete for', this.idForLogging()); - return new Promise(function(resolve) { - this.save({decryptedOldIncomingKeyErrors: true}).always(resolve); - }.bind(this)); - }.bind(this); + const promise = this.getIdentityKeys(); + return promise.then(lookup => Promise.all(_.map(messages, (message) => { + const source = message.get('source'); + const error = _.find( + message.get('errors'), + e => e.name === 'IncomingIdentityKeyError' + ); - if (!messages.length) { - return markComplete(); + const key = lookup[source]; + if (!key) { + return Promise.resolve(); } - console.log('decryptOldIncomingKeyErrors found', messages.length, 'messages to process'); - var safeDelete = function(message) { - return new Promise(function(resolve) { - message.destroy().always(resolve); - }); - }; - - return this.getIdentityKeys().then(function(lookup) { - return Promise.all(_.map(messages, function(message) { - var source = message.get('source'); - var error = _.find(message.get('errors'), function(error) { - return error.name === 'IncomingIdentityKeyError'; - }); - - var key = lookup[source]; - if (!key) { - return; - } - - if (constantTimeEqualArrayBuffers(key, error.identityKey)) { - return this.replay(error, message).then(function() { - return safeDelete(message); - }); - } - }.bind(this))); - }.bind(this)).catch(function(error) { - console.log( - 'decryptOldIncomingKeyErrors error:', - error && error.stack ? error.stack : error - ); - }).then(markComplete); - }, - isVerified: function() { - if (this.isPrivate()) { - return this.get('verified') === this.verifiedEnum.VERIFIED; - } else { - if (!this.contactCollection.length) { - return false; - } - - return this.contactCollection.every(function(contact) { - if (contact.isMe()) { - return true; - } else { - return contact.isVerified(); - } - }.bind(this)); + if (constantTimeEqualArrayBuffers(key, error.identityKey)) { + return this.replay(error, message).then(() => safeDelete(message)); } - }, - isUnverified: function() { - if (this.isPrivate()) { - var verified = this.get('verified'); - return verified !== this.verifiedEnum.VERIFIED && verified !== this.verifiedEnum.DEFAULT; - } else { - if (!this.contactCollection.length) { - return true; - } - return this.contactCollection.any(function(contact) { - if (contact.isMe()) { - return false; - } else { - return contact.isUnverified(); - } - }.bind(this)); - } + return Promise.resolve(); + }))).catch((error) => { + console.log( + 'decryptOldIncomingKeyErrors error:', + error && error.stack ? error.stack : error + ); + }).then(markComplete); }, - getUnverified: function() { - if (this.isPrivate()) { - return this.isUnverified() ? new Backbone.Collection([this]) : new Backbone.Collection(); - } else { - return new Backbone.Collection(this.contactCollection.filter(function(contact) { - if (contact.isMe()) { - return false; - } else { - return contact.isUnverified(); - } - }.bind(this))); + isVerified() { + if (this.isPrivate()) { + return this.get('verified') === this.verifiedEnum.VERIFIED; + } + if (!this.contactCollection.length) { + return false; + } + + return this.contactCollection.every((contact) => { + if (contact.isMe()) { + return true; } + return contact.isVerified(); + }); }, - setApproved: function() { - if (!this.isPrivate()) { - throw new Error('You cannot set a group conversation as trusted. ' + + isUnverified() { + if (this.isPrivate()) { + const verified = this.get('verified'); + return verified !== this.verifiedEnum.VERIFIED && + verified !== this.verifiedEnum.DEFAULT; + } + if (!this.contactCollection.length) { + return true; + } + + return this.contactCollection.any((contact) => { + if (contact.isMe()) { + return false; + } + return contact.isUnverified(); + }); + }, + getUnverified() { + if (this.isPrivate()) { + return this.isUnverified() + ? new Backbone.Collection([this]) + : new Backbone.Collection(); + } + return new Backbone.Collection(this.contactCollection.filter((contact) => { + if (contact.isMe()) { + return false; + } + return contact.isUnverified(); + })); + }, + setApproved() { + if (!this.isPrivate()) { + throw new Error('You cannot set a group conversation as trusted. ' + 'You must set individual contacts as trusted.'); - } + } - return textsecure.storage.protocol.setApproval(this.id, true); + return textsecure.storage.protocol.setApproval(this.id, true); }, - safeIsUntrusted: function() { - return textsecure.storage.protocol.isUntrusted(this.id).catch(function() { - return false; + safeIsUntrusted() { + return textsecure.storage.protocol.isUntrusted(this.id).catch(() => false); + }, + isUntrusted() { + if (this.isPrivate()) { + return this.safeIsUntrusted(); + } + if (!this.contactCollection.length) { + return Promise.resolve(false); + } + + return Promise.all(this.contactCollection.map((contact) => { + if (contact.isMe()) { + return false; + } + return contact.safeIsUntrusted(); + })).then(results => _.any(results, result => result)); + }, + getUntrusted() { + // This is a bit ugly because isUntrusted() is async. Could do the work to cache + // it locally, but we really only need it for this call. + if (this.isPrivate()) { + return this.isUntrusted().then((untrusted) => { + if (untrusted) { + return new Backbone.Collection([this]); + } + + return new Backbone.Collection(); }); - }, - isUntrusted: function() { - if (this.isPrivate()) { - return this.safeIsUntrusted(); - } else { - if (!this.contactCollection.length) { - return Promise.resolve(false); - } - - return Promise.all(this.contactCollection.map(function(contact) { - if (contact.isMe()) { - return false; - } else { - return contact.safeIsUntrusted(); - } - }.bind(this))).then(function(results) { - return _.any(results, function(result) { - return result; - }); - }); + } + return Promise.all(this.contactCollection.map((contact) => { + if (contact.isMe()) { + return [false, contact]; } + return Promise.all([contact.isUntrusted(), contact]); + })).then((results) => { + const filtered = _.filter(results, (result) => { + const untrusted = result[0]; + return untrusted; + }); + return new Backbone.Collection(_.map(filtered, (result) => { + const contact = result[1]; + return contact; + })); + }); }, - getUntrusted: function() { - // This is a bit ugly because isUntrusted() is async. Could do the work to cache - // it locally, but we really only need it for this call. - if (this.isPrivate()) { - return this.isUntrusted().then(function(untrusted) { - if (untrusted) { - return new Backbone.Collection([this]); - } - - return new Backbone.Collection(); - }.bind(this)); - } else { - return Promise.all(this.contactCollection.map(function(contact) { - if (contact.isMe()) { - return [false, contact]; - } else { - return Promise.all([contact.isUntrusted(), contact]); - } - }.bind(this))).then(function(results) { - results = _.filter(results, function(result) { - var untrusted = result[0]; - return untrusted; - }); - return new Backbone.Collection(_.map(results, function(result) { - var contact = result[1]; - return contact; - })); - }.bind(this)); - } + onMemberVerifiedChange() { + // If the verified state of a member changes, our aggregate state changes. + // We trigger both events to replicate the behavior of Backbone.Model.set() + this.trigger('change:verified'); + this.trigger('change'); }, - onMemberVerifiedChange: function() { - // If the verified state of a member changes, our aggregate state changes. - // We trigger both events to replicate the behavior of Backbone.Model.set() - this.trigger('change:verified'); - this.trigger('change'); - }, - toggleVerified: function() { - if (this.isVerified()) { - return this.setVerifiedDefault(); - } else { - return this.setVerified(); - } + toggleVerified() { + if (this.isVerified()) { + return this.setVerifiedDefault(); + } + return this.setVerified(); }, - addKeyChange: function(id) { - console.log( - 'adding key change advisory for', - this.idForLogging(), - id, - this.get('timestamp') + addKeyChange(id) { + console.log( + 'adding key change advisory for', + this.idForLogging(), + id, + this.get('timestamp') + ); + + const timestamp = Date.now(); + const message = new Whisper.Message({ + conversationId: this.id, + type: 'keychange', + sent_at: this.get('timestamp'), + received_at: timestamp, + key_changed: id, + unread: 1, + }); + message.save().then(this.trigger.bind(this, 'newmessage', message)); + }, + addVerifiedChange(id, verified, providedOptions) { + const options = providedOptions || {}; + _.defaults(options, { local: true }); + + if (this.isMe()) { + console.log('refusing to add verified change advisory for our own number'); + return; + } + + const lastMessage = this.get('timestamp') || Date.now(); + + console.log( + 'adding verified change advisory for', + this.idForLogging(), + id, + lastMessage + ); + + const timestamp = Date.now(); + const message = new Whisper.Message({ + conversationId: this.id, + type: 'verified-change', + sent_at: lastMessage, + received_at: timestamp, + verifiedChanged: id, + verified, + local: options.local, + unread: 1, + }); + message.save().then(this.trigger.bind(this, 'newmessage', message)); + + if (this.isPrivate()) { + ConversationController.getAllGroupsInvolvingId(id).then((groups) => { + _.forEach(groups, (group) => { + group.addVerifiedChange(id, verified, options); + }); + }); + } + }, + + onReadMessage(message) { + if (this.messageCollection.get(message.id)) { + this.messageCollection.get(message.id).fetch(); + } + + // We mark as read everything older than this message - to clean up old stuff + // still marked unread in the database. If the user generally doesn't read in + // the desktop app, so the desktop app only gets read syncs, we can very + // easily end up with messages never marked as read (our previous early read + // sync handling, read syncs never sent because app was offline) + + // We queue it because we often get a whole lot of read syncs at once, and + // their markRead calls could very easily overlap given the async pull from DB. + + // Lastly, we don't send read syncs for any message marked read due to a read + // sync. That's a notification explosion we don't need. + return this.queueJob(() => this.markRead( + message.get('received_at'), + { sendReadReceipts: false } + )); + }, + + getUnread() { + const conversationId = this.id; + const unreadMessages = new Whisper.MessageCollection(); + return new Promise((resolve => unreadMessages.fetch({ + index: { + // 'unread' index + name: 'unread', + lower: [conversationId], + upper: [conversationId, Number.MAX_VALUE], + }, + }).always(() => { + resolve(unreadMessages); + }))); + }, + + validate(attributes) { + const required = ['id', 'type']; + const missing = _.filter(required, attr => !attributes[attr]); + if (missing.length) { return `Conversation must have ${missing}`; } + + if (attributes.type !== 'private' && attributes.type !== 'group') { + return `Invalid conversation type: ${attributes.type}`; + } + + const error = this.validateNumber(); + if (error) { + return error; + } + + this.updateTokens(); + + return null; + }, + + validateNumber() { + if (this.isPrivate()) { + const regionCode = storage.get('regionCode'); + const number = libphonenumber.util.parseNumber(this.id, regionCode); + if (number.isValidNumber) { + this.set({ id: number.e164 }); + return null; + } + + return number.error || 'Invalid phone number'; + } + + return null; + }, + + updateTokens() { + let tokens = []; + const name = this.get('name'); + if (typeof name === 'string') { + tokens.push(name.toLowerCase()); + tokens = tokens.concat(name.trim().toLowerCase().split(/[\s\-_()+]+/)); + } + if (this.isPrivate()) { + const regionCode = storage.get('regionCode'); + const number = libphonenumber.util.parseNumber(this.id, regionCode); + tokens.push( + number.nationalNumber, + number.countryCode + number.nationalNumber ); - - var timestamp = Date.now(); - var message = new Whisper.Message({ - conversationId : this.id, - type : 'keychange', - sent_at : this.get('timestamp'), - received_at : timestamp, - key_changed : id, - unread : 1 - }); - message.save().then(this.trigger.bind(this,'newmessage', message)); + } + this.set({ tokens }); }, - addVerifiedChange: function(id, verified, options) { - options = options || {}; - _.defaults(options, {local: true}); - if (this.isMe()) { - console.log('refusing to add verified change advisory for our own number'); - return; + queueJob(callback) { + const previous = this.pending || Promise.resolve(); + + const taskWithTimeout = textsecure.createTaskWithTimeout( + callback, + `conversation ${this.idForLogging()}` + ); + + this.pending = previous.then(taskWithTimeout, taskWithTimeout); + const current = this.pending; + + current.then(() => { + if (this.pending === current) { + delete this.pending; } + }); - var lastMessage = this.get('timestamp') || Date.now(); - - console.log( - 'adding verified change advisory for', - this.idForLogging(), - id, - lastMessage - ); - - var timestamp = Date.now(); - var message = new Whisper.Message({ - conversationId : this.id, - type : 'verified-change', - sent_at : lastMessage, - received_at : timestamp, - verifiedChanged : id, - verified : verified, - local : options.local, - unread : 1 - }); - message.save().then(this.trigger.bind(this,'newmessage', message)); - - if (this.isPrivate()) { - ConversationController.getAllGroupsInvolvingId(id).then(function(groups) { - _.forEach(groups, function(group) { - group.addVerifiedChange(id, verified, options); - }); - }); - } + return current; }, - onReadMessage: function(message) { - if (this.messageCollection.get(message.id)) { - this.messageCollection.get(message.id).fetch(); - } - - // We mark as read everything older than this message - to clean up old stuff - // still marked unread in the database. If the user generally doesn't read in - // the desktop app, so the desktop app only gets read syncs, we can very - // easily end up with messages never marked as read (our previous early read - // sync handling, read syncs never sent because app was offline) - - // We queue it because we often get a whole lot of read syncs at once, and - // their markRead calls could very easily overlap given the async pull from DB. - - // Lastly, we don't send read syncs for any message marked read due to a read - // sync. That's a notification explosion we don't need. - return this.queueJob(function() { - return this.markRead(message.get('received_at'), {sendReadReceipts: false}); - }.bind(this)); + getRecipients() { + if (this.isPrivate()) { + return [this.id]; + } + const me = textsecure.storage.user.getNumber(); + return _.without(this.get('members'), me); }, - getUnread: function() { - var conversationId = this.id; - var unreadMessages = new Whisper.MessageCollection(); - return new Promise(function(resolve) { - return unreadMessages.fetch({ - index: { - // 'unread' index - name : 'unread', - lower : [conversationId], - upper : [conversationId, Number.MAX_VALUE], - } - }).always(function() { - resolve(unreadMessages); - }); - }); - - }, - - validate: function(attributes, options) { - var required = ['id', 'type']; - var missing = _.filter(required, function(attr) { return !attributes[attr]; }); - if (missing.length) { return "Conversation must have " + missing; } - - if (attributes.type !== 'private' && attributes.type !== 'group') { - return "Invalid conversation type: " + attributes.type; - } - - var error = this.validateNumber(); - if (error) { return error; } - - this.updateTokens(); - }, - - validateNumber: function() { - if (this.isPrivate()) { - var regionCode = storage.get('regionCode'); - var number = libphonenumber.util.parseNumber(this.id, regionCode); - if (number.isValidNumber) { - this.set({ id: number.e164 }); - } else { - return number.error || "Invalid phone number"; - } - } - }, - - updateTokens: function() { - var tokens = []; - var name = this.get('name'); - if (typeof name === 'string') { - tokens.push(name.toLowerCase()); - tokens = tokens.concat(name.trim().toLowerCase().split(/[\s\-_\(\)\+]+/)); - } - if (this.isPrivate()) { - var regionCode = storage.get('regionCode'); - var number = libphonenumber.util.parseNumber(this.id, regionCode); - tokens.push( - number.nationalNumber, - number.countryCode + number.nationalNumber - ); - } - this.set({tokens: tokens}); - }, - - queueJob: function(callback) { - var previous = this.pending || Promise.resolve(); - - var taskWithTimeout = textsecure.createTaskWithTimeout( - callback, - 'conversation ' + this.idForLogging() - ); - - var current = this.pending = previous.then(taskWithTimeout, taskWithTimeout); - - current.then(function() { - if (this.pending === current) { - delete this.pending; - } - }.bind(this)); - - return current; - }, - - getRecipients: function() { - if (this.isPrivate()) { - return [ this.id ]; - } else { - var me = textsecure.storage.user.getNumber(); - return _.without(this.get('members'), me); - } - }, - - /* jshint ignore:start */ - /* eslint-enable */ sendMessage(body, attachments) { this.queueJob(async () => { const now = Date.now(); @@ -675,7 +678,7 @@ await collection.fetchConversation(this.id, 1); const lastMessage = collection.at(0); - const lastMessageUpdate = Signal.Types.Conversation.createLastMessageUpdate({ + const lastMessageUpdate = window.Signal.Types.Conversation.createLastMessageUpdate({ currentLastMessageText: this.get('lastMessage') || null, currentTimestamp: this.get('timestamp') || null, lastMessage: lastMessage ? lastMessage.toJSON() : null, @@ -689,549 +692,810 @@ this.save(); } }, - /* jshint ignore:end */ - /* eslint-disable */ - updateExpirationTimer: function(expireTimer, source, received_at, options) { - options = options || {}; - _.defaults(options, {fromSync: false}); + updateExpirationTimer( + providedExpireTimer, + providedSource, + receivedAt, + providedOptions + ) { + const options = providedOptions || {}; + let expireTimer = providedExpireTimer; + let source = providedSource; - if (!expireTimer) { - expireTimer = null; - } - if (this.get('expireTimer') === expireTimer - || (!expireTimer && !this.get('expireTimer'))) { + _.defaults(options, { fromSync: false }); - return; + if (!expireTimer) { + expireTimer = null; + } + if (this.get('expireTimer') === expireTimer || + (!expireTimer && !this.get('expireTimer'))) { + return Promise.resolve(); + } + + console.log( + 'Updating expireTimer for conversation', + this.idForLogging(), + 'to', + expireTimer, + 'via', + source + ); + source = source || textsecure.storage.user.getNumber(); + const timestamp = receivedAt || Date.now(); + + const message = this.messageCollection.add({ + conversationId: this.id, + type: receivedAt ? 'incoming' : 'outgoing', + sent_at: timestamp, + received_at: timestamp, + flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, + expirationTimerUpdate: { + expireTimer, + source, + fromSync: options.fromSync, + }, + }); + if (this.isPrivate()) { + message.set({ destination: this.id }); + } + if (message.isOutgoing()) { + message.set({ recipients: this.getRecipients() }); + } + + return Promise.all([ + wrapDeferred(message.save()), + wrapDeferred(this.save({ expireTimer })), + ]).then(() => { + if (message.isIncoming()) { + return message; } - console.log( - 'Updating expireTimer for conversation', - this.idForLogging(), - 'to', - expireTimer, - 'via', - source - ); - source = source || textsecure.storage.user.getNumber(); - var timestamp = received_at || Date.now(); - - var message = this.messageCollection.add({ - conversationId : this.id, - type : received_at ? 'incoming' : 'outgoing', - sent_at : timestamp, - received_at : timestamp, - flags : textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, - expirationTimerUpdate : { - expireTimer : expireTimer, - source : source, - fromSync : options.fromSync, - } - }); - if (this.isPrivate()) { - message.set({destination: this.id}); - } - if (message.isOutgoing()) { - message.set({recipients: this.getRecipients() }); - } - - return Promise.all([ - wrapDeferred(message.save()), - wrapDeferred(this.save({ expireTimer: expireTimer })), - ]).then(function() { - if (message.isIncoming()) { - return message; - } - - // change was made locally, send it to the number/group - var sendFunc; - if (this.get('type') == 'private') { - sendFunc = textsecure.messaging.sendExpirationTimerUpdateToNumber; - } - else { - sendFunc = textsecure.messaging.sendExpirationTimerUpdateToGroup; - } - var profileKey; - if (this.get('profileSharing')) { - profileKey = storage.get('profileKey'); - } - var promise = sendFunc(this.get('id'), - this.get('expireTimer'), - message.get('sent_at'), - profileKey - ); - - return message.send(promise).then(function() { - return message; - }); - }.bind(this)); - }, - - isSearchable: function() { - return !this.get('left') || !!this.get('lastMessage'); - }, - - endSession: function() { - if (this.isPrivate()) { - var now = Date.now(); - var message = this.messageCollection.create({ - conversationId : this.id, - type : 'outgoing', - sent_at : now, - received_at : now, - destination : this.id, - recipients : this.getRecipients(), - flags : textsecure.protobuf.DataMessage.Flags.END_SESSION - }); - message.send(textsecure.messaging.resetSession(this.id, now)); - } - - }, - - updateGroup: function(group_update) { - if (this.isPrivate()) { - throw new Error("Called update group on private conversation"); - } - if (group_update === undefined) { - group_update = this.pick(['name', 'avatar', 'members']); - } - var now = Date.now(); - var message = this.messageCollection.create({ - conversationId : this.id, - type : 'outgoing', - sent_at : now, - received_at : now, - group_update : group_update - }); - message.send(textsecure.messaging.updateGroup( - this.id, - this.get('name'), - this.get('avatar'), - this.get('members') - )); - }, - - leaveGroup: function() { - var now = Date.now(); - if (this.get('type') === 'group') { - this.save({left: true}); - var message = this.messageCollection.create({ - group_update: { left: 'You' }, - conversationId : this.id, - type : 'outgoing', - sent_at : now, - received_at : now - }); - message.send(textsecure.messaging.leaveGroup(this.id)); - } - }, - - markRead: function(newestUnreadDate, options) { - options = options || {}; - _.defaults(options, {sendReadReceipts: true}); - - var conversationId = this.id; - Whisper.Notifications.remove(Whisper.Notifications.where({ - conversationId: conversationId - })); - - return this.getUnread().then(function(unreadMessages) { - var promises = []; - var oldUnread = unreadMessages.filter(function(message) { - return message.get('received_at') <= newestUnreadDate; - }); - - var read = _.map(oldUnread, function(m) { - if (this.messageCollection.get(m.id)) { - m = this.messageCollection.get(m.id); - } else { - console.log('Marked a message as read in the database, but ' + - 'it was not in messageCollection.'); - } - promises.push(m.markRead()); - var errors = m.get('errors'); - return { - sender : m.get('source'), - timestamp : m.get('sent_at'), - hasErrors : Boolean(errors && errors.length) - }; - }.bind(this)); - - // Some messages we're marking read are local notifications with no sender - read = _.filter(read, function(m) { - return Boolean(m.sender); - }); - unreadMessages = unreadMessages.filter(function(m) { - return Boolean(m.isIncoming()); - }); - - var unreadCount = unreadMessages.length - read.length; - var promise = new Promise(function(resolve, reject) { - this.save({ unreadCount: unreadCount }).then(resolve, reject); - }.bind(this)); - promises.push(promise); - - // If a message has errors, we don't want to send anything out about it. - // read syncs - let's wait for a client that really understands the message - // to mark it read. we'll mark our local error read locally, though. - // read receipts - here we can run into infinite loops, where each time the - // conversation is viewed, another error message shows up for the contact - read = read.filter(function(item) { - return !item.hasErrors; - }); - - if (read.length && options.sendReadReceipts) { - console.log('Sending', read.length, 'read receipts'); - promises.push(textsecure.messaging.syncReadMessages(read)); - - if (storage.get('read-receipt-setting')) { - _.each(_.groupBy(read, 'sender'), function(receipts, sender) { - var timestamps = _.map(receipts, 'timestamp'); - promises.push(textsecure.messaging.sendReadReceipts(sender, timestamps)); - }); - } - } - - return Promise.all(promises); - }.bind(this)); - }, - - onChangeProfileKey: function() { - if (this.isPrivate()) { - this.getProfiles(); - } - }, - - getProfiles: function() { - // request all conversation members' keys - var ids = []; - if (this.isPrivate()) { - ids = [this.id]; + // change was made locally, send it to the number/group + let sendFunc; + if (this.get('type') === 'private') { + sendFunc = textsecure.messaging.sendExpirationTimerUpdateToNumber; } else { - ids = this.get('members'); + sendFunc = textsecure.messaging.sendExpirationTimerUpdateToGroup; } - return Promise.all(_.map(ids, this.getProfile)); + let profileKey; + if (this.get('profileSharing')) { + profileKey = storage.get('profileKey'); + } + const promise = sendFunc( + this.get('id'), + this.get('expireTimer'), + message.get('sent_at'), + profileKey + ); + + return message.send(promise).then(() => message); + }); }, - getProfile: function(id) { - if (!textsecure.messaging) { - var message = 'Conversation.getProfile: textsecure.messaging not available'; - return Promise.reject(new Error(message)); - } + isSearchable() { + return !this.get('left') || !!this.get('lastMessage'); + }, - return textsecure.messaging.getProfile(id).then(function(profile) { - var identityKey = dcodeIO.ByteBuffer.wrap(profile.identityKey, 'base64').toArrayBuffer(); - - return textsecure.storage.protocol.saveIdentity( - id + '.1', identityKey, false - ).then(function(changed) { - if (changed) { - // save identity will close all sessions except for .1, so we - // must close that one manually. - var address = new libsignal.SignalProtocolAddress(id, 1); - console.log('closing session for', address.toString()); - var sessionCipher = new libsignal.SessionCipher(textsecure.storage.protocol, address); - return sessionCipher.closeOpenSessionForDevice(); - } - }).then(function() { - var c = ConversationController.get(id); - return Promise.all([ - c.setProfileName(profile.name), - c.setProfileAvatar(profile.avatar) - ]).then(function() { - // success - return new Promise(function(resolve, reject) { - c.save().then(resolve, reject); - }); - }, function(e) { - // fail - if (e.name === 'ProfileDecryptError') { - // probably the profile key has changed. - console.log( - 'decryptProfile error:', - id, - profile, - e && e.stack ? e.stack : e - ); - } - }); - }.bind(this)); - }.bind(this)).catch(function(error) { - console.log( - 'getProfile error:', - error && error.stack ? error.stack : error - ); + endSession() { + if (this.isPrivate()) { + const now = Date.now(); + const message = this.messageCollection.create({ + conversationId: this.id, + type: 'outgoing', + sent_at: now, + received_at: now, + destination: this.id, + recipients: this.getRecipients(), + flags: textsecure.protobuf.DataMessage.Flags.END_SESSION, }); + message.send(textsecure.messaging.resetSession(this.id, now)); + } }, - setProfileName: function(encryptedName) { - var key = this.get('profileKey'); - if (!key) { return; } + + updateGroup(providedGroupUpdate) { + let groupUpdate = providedGroupUpdate; + + if (this.isPrivate()) { + throw new Error('Called update group on private conversation'); + } + if (groupUpdate === undefined) { + groupUpdate = this.pick(['name', 'avatar', 'members']); + } + const now = Date.now(); + const message = this.messageCollection.create({ + conversationId: this.id, + type: 'outgoing', + sent_at: now, + received_at: now, + group_update: groupUpdate, + }); + message.send(textsecure.messaging.updateGroup( + this.id, + this.get('name'), + this.get('avatar'), + this.get('members') + )); + }, + + leaveGroup() { + const now = Date.now(); + if (this.get('type') === 'group') { + this.save({ left: true }); + const message = this.messageCollection.create({ + group_update: { left: 'You' }, + conversationId: this.id, + type: 'outgoing', + sent_at: now, + received_at: now, + }); + message.send(textsecure.messaging.leaveGroup(this.id)); + } + }, + + markRead(newestUnreadDate, providedOptions) { + const options = providedOptions || {}; + _.defaults(options, { sendReadReceipts: true }); + + const conversationId = this.id; + Whisper.Notifications.remove(Whisper.Notifications.where({ + conversationId, + })); + + return this.getUnread().then((providedUnreadMessages) => { + let unreadMessages = providedUnreadMessages; + + const promises = []; + const oldUnread = unreadMessages.filter(message => + message.get('received_at') <= newestUnreadDate); + + let read = _.map(oldUnread, (providedM) => { + let m = providedM; + + if (this.messageCollection.get(m.id)) { + m = this.messageCollection.get(m.id); + } else { + console.log('Marked a message as read in the database, but ' + + 'it was not in messageCollection.'); + } + promises.push(m.markRead()); + const errors = m.get('errors'); + return { + sender: m.get('source'), + timestamp: m.get('sent_at'), + hasErrors: Boolean(errors && errors.length), + }; + }); + + // Some messages we're marking read are local notifications with no sender + read = _.filter(read, m => Boolean(m.sender)); + unreadMessages = unreadMessages.filter(m => Boolean(m.isIncoming())); + + const unreadCount = unreadMessages.length - read.length; + const promise = new Promise((resolve, reject) => { + this.save({ unreadCount }).then(resolve, reject); + }); + promises.push(promise); + + // If a message has errors, we don't want to send anything out about it. + // read syncs - let's wait for a client that really understands the message + // to mark it read. we'll mark our local error read locally, though. + // read receipts - here we can run into infinite loops, where each time the + // conversation is viewed, another error message shows up for the contact + read = read.filter(item => !item.hasErrors); + + if (read.length && options.sendReadReceipts) { + console.log('Sending', read.length, 'read receipts'); + promises.push(textsecure.messaging.syncReadMessages(read)); + + if (storage.get('read-receipt-setting')) { + _.each(_.groupBy(read, 'sender'), (receipts, sender) => { + const timestamps = _.map(receipts, 'timestamp'); + promises.push(textsecure.messaging.sendReadReceipts(sender, timestamps)); + }); + } + } + + return Promise.all(promises); + }); + }, + + onChangeProfileKey() { + if (this.isPrivate()) { + this.getProfiles(); + } + }, + + getProfiles() { + // request all conversation members' keys + let ids = []; + if (this.isPrivate()) { + ids = [this.id]; + } else { + ids = this.get('members'); + } + return Promise.all(_.map(ids, this.getProfile)); + }, + + getProfile(id) { + if (!textsecure.messaging) { + const message = 'Conversation.getProfile: textsecure.messaging not available'; + return Promise.reject(new Error(message)); + } + + return textsecure.messaging.getProfile(id).then((profile) => { + const identityKey = dcodeIO.ByteBuffer.wrap( + profile.identityKey, + 'base64' + ).toArrayBuffer(); + + return textsecure.storage.protocol.saveIdentity( + `${id}.1`, + identityKey, + false + ).then((changed) => { + if (changed) { + // save identity will close all sessions except for .1, so we + // must close that one manually. + const address = new libsignal.SignalProtocolAddress(id, 1); + console.log('closing session for', address.toString()); + const sessionCipher = new libsignal.SessionCipher( + textsecure.storage.protocol, + address + ); + return sessionCipher.closeOpenSessionForDevice(); + } + return Promise.resolve(); + }).then(() => { + const c = ConversationController.get(id); + return Promise.all([ + c.setProfileName(profile.name), + c.setProfileAvatar(profile.avatar), + ]).then( + // success + () => new Promise((resolve, reject) => { + c.save().then(resolve, reject); + }), + // fail + (e) => { + if (e.name === 'ProfileDecryptError') { + // probably the profile key has changed. + console.log( + 'decryptProfile error:', + id, + profile, + e && e.stack ? e.stack : e + ); + } + } + ); + }); + }).catch((error) => { + console.log( + 'getProfile error:', + error && error.stack ? error.stack : error + ); + }); + }, + setProfileName(encryptedName) { + const key = this.get('profileKey'); + if (!key) { + return Promise.resolve(); + } try { // decode - var data = dcodeIO.ByteBuffer.wrap(encryptedName, 'base64').toArrayBuffer(); + const data = dcodeIO.ByteBuffer.wrap(encryptedName, 'base64').toArrayBuffer(); // decrypt - return textsecure.crypto.decryptProfileName(data, key).then(function(decrypted) { - + return textsecure.crypto.decryptProfileName(data, key).then((decrypted) => { // encode - var name = dcodeIO.ByteBuffer.wrap(decrypted).toString('utf8'); + const name = dcodeIO.ByteBuffer.wrap(decrypted).toString('utf8'); // set - this.set({profileName: name}); - }.bind(this)); - } - catch (e) { + this.set({ profileName: name }); + }); + } catch (e) { return Promise.reject(e); } }, - setProfileAvatar: function(avatarPath) { - if (!avatarPath) { return; } - return textsecure.messaging.getAvatar(avatarPath).then(function(avatar) { - var key = this.get('profileKey'); - if (!key) { return; } + setProfileAvatar(avatarPath) { + if (!avatarPath) { + return Promise.resolve(); + } + + return textsecure.messaging.getAvatar(avatarPath).then((avatar) => { + const key = this.get('profileKey'); + if (!key) { + return Promise.resolve(); + } // decrypt - return textsecure.crypto.decryptProfile(avatar, key).then(function(decrypted) { + return textsecure.crypto.decryptProfile(avatar, key).then((decrypted) => { // set this.set({ profileAvatar: { data: decrypted, contentType: 'image/jpeg', - size: decrypted.byteLength - } + size: decrypted.byteLength, + }, }); - }.bind(this)); - }.bind(this)); + }); + }); }, - setProfileKey: function(key) { - return new Promise(function(resolve, reject) { + setProfileKey(key) { + return new Promise((resolve, reject) => { if (!constantTimeEqualArrayBuffers(this.get('profileKey'), key)) { - this.save({profileKey: key}).then(resolve, reject); + this.save({ profileKey: key }).then(resolve, reject); } else { resolve(); } - }.bind(this)); + }); }, - fetchMessages: function() { - if (!this.id) { - return Promise.reject('This conversation has no id!'); + makeKey(author, id) { + return `${author}-${id}`; + }, + doesMessageMatch(id, author, message) { + const messageAuthor = message.getContact().id; + + if (author !== messageAuthor) { + return false; + } + if (id !== message.get('sent_at')) { + return false; + } + return true; + }, + needData(attachments) { + if (!attachments || attachments.length === 0) { + return false; + } + + const first = attachments[0]; + const { thumbnail, contentType } = first; + + return thumbnail || MIME.isVideo(contentType) || MIME.isImage(contentType); + }, + forceRender(message) { + message.trigger('change', message); + }, + makeObjectUrl(data, contentType) { + const blob = new Blob([data], { + type: contentType, + }); + return URL.createObjectURL(blob); + }, + makeMessagesLookup(messages) { + return messages.reduce((acc, message) => { + const { source, sent_at: sentAt } = message.attributes; + + // Checking for notification messages (safety number change, timer change) + if (!source && message.isIncoming()) { + return acc; } - return this.messageCollection.fetchConversation(this.id, null, this.get('unreadCount')); + + const contact = message.getContact(); + if (!contact) { + return acc; + } + + const author = contact.id; + const key = this.makeKey(author, sentAt); + + acc[key] = message; + + return acc; + }, {}); + }, + async loadQuotedMessageFromDatabase(message) { + const { quote } = message.attributes; + const { attachments, id, author } = quote; + const first = attachments[0]; + + // Maybe in the future we could try to pull the thumbnail from a video ourselves, + // but for now we will rely on incoming thumbnails only. + if (!MIME.isImage(first.contentType)) { + return false; + } + + const collection = new Whisper.MessageCollection(); + await collection.fetchSentAt(id); + const queryMessage = collection.find(m => this.doesMessageMatch(id, author, m)); + + if (!queryMessage) { + return false; + } + + const queryAttachments = queryMessage.attachments || []; + if (queryAttachments.length === 0) { + return false; + } + + const queryFirst = queryAttachments[0]; + try { + queryMessage.attachments[0] = await loadAttachmentData(queryFirst); + + // Note: it would be nice to take the full-size image and downsample it into + // a true thumbnail here. + queryMessage.updateImageUrl(); + + // We need to differentiate between messages we load from database and those + // already in memory. More cleanup needs to happen on messages from the database + // because they aren't tracked any other way. + // eslint-disable-next-line no-param-reassign + message.quotedMessageFromDatabase = queryMessage; + + return true; + } catch (error) { + console.log( + 'Problem loading attachment data for quoted message from database', + error && error.stack ? error.stack : error + ); + return false; + } + }, + async loadQuotedMessage(message, quotedMessage) { + // eslint-disable-next-line no-param-reassign + message.quotedMessage = quotedMessage; + + const { quote } = message.attributes; + const { attachments } = quote; + const first = attachments[0]; + + // Maybe in the future we could try to pull thumbnails video ourselves, + // but for now we will rely on incoming thumbnails only. + if (!first || !MIME.isImage(first.contentType)) { + return; + } + + const quotedAttachments = quotedMessage.get('attachments') || []; + if (quotedAttachments.length === 0) { + return; + } + + try { + const queryFirst = quotedAttachments[0]; + // eslint-disable-next-line no-param-reassign + quotedMessage.attributes.attachments[0] = await loadAttachmentData(queryFirst); + + // Note: it would be nice to take the full-size image and downsample it into + // a true thumbnail here. + quotedMessage.updateImageUrl(); + } catch (error) { + console.log( + 'Problem loading attachment data for quoted message', + error && error.stack ? error.stack : error + ); + } + }, + async loadQuoteThumbnail(message) { + const { quote } = message.attributes; + const { attachments } = quote; + const first = attachments[0]; + if (!first) { + return false; + } + + const { thumbnail } = first; + + if (!thumbnail) { + return false; + } + const thumbnailWithData = await loadAttachmentData(thumbnail); + thumbnailWithData.objectUrl = this.makeObjectUrl( + thumbnailWithData.data, + thumbnailWithData.contentType + ); + + // If we update this data in place, there's the risk that this data could be + // saved back to the database + // eslint-disable-next-line no-param-reassign + message.quoteThumbnail = thumbnailWithData; + + return true; + }, + async processQuotes(messages) { + const lookup = this.makeMessagesLookup(messages); + + const promises = messages.map(async (message) => { + const { quote } = message.attributes; + if (!quote) { + return; + } + + // If we already have a quoted message, then we exit early. If we don't have it, + // then we'll continue to look again for an in-memory message to use. Why? This + // will enable us to scroll to it when the user clicks. + if (messages.quotedMessage) { + return; + } + + // 1. Check to see if we've already loaded the target message into memory + const { author, id } = quote; + const key = this.makeKey(author, id); + const quotedMessage = lookup[key]; + + if (quotedMessage) { + // eslint-disable-next-line no-param-reassign + await this.loadQuotedMessage(message, quotedMessage); + + // 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.quotedMessage.imageUrl) { + await this.loadQuoteThumbnail(message, quote); + } + + this.forceRender(message); + return; + } + + // We only go further if we need more data for this message. It's always important + // to grab the quoted message to allow for navigating to it by clicking. + const { attachments } = quote; + if (!this.needData(attachments)) { + return; + } + + // We've don't want to go to the database or load thumbnails a second time. + if (message.quoteIsProcessed) { + return; + } + // eslint-disable-next-line no-param-reassign + message.quoteIsProcessed = true; + + // 2. Go to the database for the real referenced attachment + const loaded = await this.loadQuotedMessageFromDatabase(message, id); + 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); + } + }); + + return Promise.all(promises); }, - hasMember: function(number) { - return _.contains(this.get('members'), number); + async fetchMessages() { + if (!this.id) { + throw new Error('This conversation has no id!'); + } + + await this.messageCollection.fetchConversation( + this.id, + null, + this.get('unreadCount') + ); + + // We kick this process off, but don't wait for it. If async updates happen on a + // given Message, 'change' will be triggered + this.processQuotes(this.messageCollection); }, - fetchContacts: function(options) { + + hasMember(number) { + return _.contains(this.get('members'), number); + }, + fetchContacts() { + if (this.isPrivate()) { + this.contactCollection.reset([this]); + return Promise.resolve(); + } + const members = this.get('members') || []; + const promises = members.map(number => + ConversationController.getOrCreateAndWait(number, 'private')); + + return Promise.all(promises).then((contacts) => { + _.forEach(contacts, (contact) => { + this.listenTo(contact, 'change:verified', this.onMemberVerifiedChange); + }); + + this.contactCollection.reset(contacts); + }); + }, + + destroyMessages() { + this.messageCollection.fetch({ + index: { + // 'conversation' index on [conversationId, received_at] + name: 'conversation', + lower: [this.id], + upper: [this.id, Number.MAX_VALUE], + }, + }).then(() => { + const { models } = this.messageCollection; + this.messageCollection.reset([]); + _.each(models, (message) => { + message.destroy(); + }); + this.save({ + lastMessage: null, + timestamp: null, + active_at: null, + }); + }); + }, + + getName() { + if (this.isPrivate()) { + return this.get('name'); + } + return this.get('name') || 'Unknown group'; + }, + + getTitle() { + if (this.isPrivate()) { + return this.get('name') || this.getNumber(); + } + return this.get('name') || 'Unknown group'; + }, + + getProfileName() { + if (this.isPrivate() && !this.get('name')) { + return this.get('profileName'); + } + return null; + }, + + getDisplayName() { + if (!this.isPrivate()) { + return this.getTitle(); + } + + const name = this.get('name'); + if (name) { + return name; + } + + const profileName = this.get('profileName'); + if (profileName) { + return `${this.getNumber()} ~${profileName}`; + } + + return this.getNumber(); + }, + + getNumber() { + if (!this.isPrivate()) { + return ''; + } + const number = this.id; + try { + const parsedNumber = libphonenumber.parse(number); + const regionCode = libphonenumber.getRegionCodeForNumber(parsedNumber); + if (regionCode === storage.get('regionCode')) { + return libphonenumber.format( + parsedNumber, + libphonenumber.PhoneNumberFormat.NATIONAL + ); + } + return libphonenumber.format( + parsedNumber, + libphonenumber.PhoneNumberFormat.INTERNATIONAL + ); + } catch (e) { + return number; + } + }, + + isPrivate() { + return this.get('type') === 'private'; + }, + + revokeAvatarUrl() { + if (this.avatarUrl) { + URL.revokeObjectURL(this.avatarUrl); + this.avatarUrl = null; + } + }, + + updateAvatarUrl(silent) { + this.revokeAvatarUrl(); + const avatar = this.get('avatar') || this.get('profileAvatar'); + if (avatar) { + this.avatarUrl = URL.createObjectURL(new Blob( + [avatar.data], + { type: avatar.contentType } + )); + } else { + this.avatarUrl = null; + } + if (!silent) { + this.trigger('change'); + } + }, + getColor() { + const title = this.get('name'); + let color = this.get('color'); + if (!color) { if (this.isPrivate()) { - this.contactCollection.reset([this]); - return Promise.resolve(); + if (title) { + color = COLORS[Math.abs(this.hashCode()) % 15]; + } else { + color = 'grey'; + } } else { - var members = this.get('members') || []; - var promises = members.map(function(number) { - return ConversationController.getOrCreateAndWait(number, 'private'); - }); - - return Promise.all(promises).then(function(contacts) { - _.forEach(contacts, function(contact) { - this.listenTo(contact, 'change:verified', this.onMemberVerifiedChange); - }.bind(this)); - - this.contactCollection.reset(contacts); - }.bind(this)); + color = 'default'; } + } + return color; + }, + getAvatar() { + if (this.avatarUrl === undefined) { + this.updateAvatarUrl(true); + } + + const title = this.get('name'); + const color = this.getColor(); + + if (this.avatarUrl) { + return { url: this.avatarUrl, color }; + } else if (this.isPrivate()) { + return { + color, + content: title ? title.trim()[0] : '#', + }; + } + return { url: 'images/group_default.png', color }; }, - destroyMessages: function() { - this.messageCollection.fetch({ - index: { - // 'conversation' index on [conversationId, received_at] - name : 'conversation', - lower : [this.id], - upper : [this.id, Number.MAX_VALUE], - } - }).then(function() { - var models = this.messageCollection.models; - this.messageCollection.reset([]); - _.each(models, function(message) { - message.destroy(); - }); - this.save({ - lastMessage: null, - timestamp: null, - active_at: null, - }); - }.bind(this)); - }, - - getName: function() { - if (this.isPrivate()) { - return this.get('name'); + getNotificationIcon() { + return new Promise((resolve) => { + const avatar = this.getAvatar(); + if (avatar.url) { + resolve(avatar.url); } else { - return this.get('name') || 'Unknown group'; + resolve(new Whisper.IdenticonSVGView(avatar).getDataUrl()); } + }); }, - getTitle: function() { - if (this.isPrivate()) { - return this.get('name') || this.getNumber(); - } else { - return this.get('name') || 'Unknown group'; - } + notify(message) { + if (!message.isIncoming()) { + return Promise.resolve(); + } + const conversationId = this.id; + + return ConversationController.getOrCreateAndWait(message.get('source'), 'private') + .then(sender => sender.getNotificationIcon().then((iconUrl) => { + console.log('adding notification'); + Whisper.Notifications.add({ + title: sender.getTitle(), + message: message.getNotificationText(), + iconUrl, + imageUrl: message.getImageUrl(), + conversationId, + messageId: message.id, + }); + })); }, - - getProfileName: function() { - if (this.isPrivate() && !this.get('name')) { - return this.get('profileName'); + hashCode() { + if (this.hash === undefined) { + const string = this.getTitle() || ''; + if (string.length === 0) { + return 0; } + let hash = 0; + for (let i = 0; i < string.length; i += 1) { + // eslint-disable-next-line no-bitwise + hash = ((hash << 5) - hash) + string.charCodeAt(i); + // eslint-disable-next-line no-bitwise + hash &= hash; // Convert to 32bit integer + } + + this.hash = hash; + } + return this.hash; }, - - getDisplayName: function() { - if (!this.isPrivate()) { - return this.getTitle(); - } - - var name = this.get('name'); - if (name) { - return name; - } - - var profileName = this.get('profileName'); - if (profileName) { - return this.getNumber() + ' ~' + profileName; - } - - return this.getNumber(); - }, - - getNumber: function() { - if (!this.isPrivate()) { - return ''; - } - var number = this.id; - try { - var parsedNumber = libphonenumber.parse(number); - var regionCode = libphonenumber.getRegionCodeForNumber(parsedNumber); - if (regionCode === storage.get('regionCode')) { - return libphonenumber.format(parsedNumber, libphonenumber.PhoneNumberFormat.NATIONAL); - } else { - return libphonenumber.format(parsedNumber, libphonenumber.PhoneNumberFormat.INTERNATIONAL); - } - } catch (e) { - return number; - } - }, - - isPrivate: function() { - return this.get('type') === 'private'; - }, - - revokeAvatarUrl: function() { - if (this.avatarUrl) { - URL.revokeObjectURL(this.avatarUrl); - this.avatarUrl = null; - } - }, - - updateAvatarUrl: function(silent) { - this.revokeAvatarUrl(); - var avatar = this.get('avatar') || this.get('profileAvatar'); - if (avatar) { - this.avatarUrl = URL.createObjectURL( - new Blob([avatar.data], {type: avatar.contentType}) - ); - } else { - this.avatarUrl = null; - } - if (!silent) { - this.trigger('change'); - } - }, - getColor: function() { - var title = this.get('name'); - var color = this.get('color'); - if (!color) { - if (this.isPrivate()) { - if (title) { - color = COLORS[Math.abs(this.hashCode()) % 15]; - } else { - color = 'grey'; - } - } else { - color = 'default'; - } - } - return color; - }, - getAvatar: function() { - if (this.avatarUrl === undefined) { - this.updateAvatarUrl(true); - } - - var title = this.get('name'); - var color = this.getColor(); - - if (this.avatarUrl) { - return { url: this.avatarUrl, color: color }; - } else if (this.isPrivate()) { - return { - color: color, - content: title ? title.trim()[0] : '#' - }; - } else { - return { url: 'images/group_default.png', color: color }; - } - }, - - getNotificationIcon: function() { - return new Promise(function(resolve) { - var avatar = this.getAvatar(); - if (avatar.url) { - resolve(avatar.url); - } else { - resolve(new Whisper.IdenticonSVGView(avatar).getDataUrl()); - } - }.bind(this)); - }, - - notify: function(message) { - if (!message.isIncoming()) { - return Promise.resolve(); - } - var conversationId = this.id; - - return ConversationController.getOrCreateAndWait(message.get('source'), 'private') - .then(function(sender) { - return sender.getNotificationIcon().then(function(iconUrl) { - console.log('adding notification'); - Whisper.Notifications.add({ - title : sender.getTitle(), - message : message.getNotificationText(), - iconUrl : iconUrl, - imageUrl : message.getImageUrl(), - conversationId : conversationId, - messageId : message.id - }); - }); - }); - }, - hashCode: function() { - if (this.hash === undefined) { - var string = this.getTitle() || ''; - if (string.length === 0) { - return 0; - } - var hash = 0; - for (var i = 0; i < string.length; i++) { - hash = ((hash<<5)-hash) + string.charCodeAt(i); - hash = hash & hash; // Convert to 32bit integer - } - - this.hash = hash; - } - return this.hash; - } }); Whisper.ConversationCollection = Backbone.Collection.extend({ @@ -1239,59 +1503,58 @@ storeName: 'conversations', model: Whisper.Conversation, - comparator: function(m) { + comparator(m) { return -m.get('timestamp'); }, - destroyAll: function () { - return Promise.all(this.models.map(function(m) { - return new Promise(function(resolve, reject) { - m.destroy().then(resolve).fail(reject); - }); - })); + destroyAll() { + return Promise.all(this.models.map(m => new Promise((resolve, reject) => { + m.destroy().then(resolve).fail(reject); + }))); }, - search: function(query) { - query = query.trim().toLowerCase(); - if (query.length > 0) { - query = query.replace(/[-.\(\)]*/g,'').replace(/^\+(\d*)$/, '$1'); - var lastCharCode = query.charCodeAt(query.length - 1); - var nextChar = String.fromCharCode(lastCharCode + 1); - var upper = query.slice(0, -1) + nextChar; - return new Promise(function(resolve) { - this.fetch({ - index: { - name: 'search', // 'search' index on tokens array - lower: query, - upper: upper, - excludeUpper: true - } - }).always(resolve); - }.bind(this)); - } + search(providedQuery) { + let query = providedQuery.trim().toLowerCase(); + if (query.length > 0) { + query = query.replace(/[-.()]*/g, '').replace(/^\+(\d*)$/, '$1'); + const lastCharCode = query.charCodeAt(query.length - 1); + const nextChar = String.fromCharCode(lastCharCode + 1); + const upper = query.slice(0, -1) + nextChar; + return new Promise((resolve) => { + this.fetch({ + index: { + name: 'search', // 'search' index on tokens array + lower: query, + upper, + excludeUpper: true, + }, + }).always(resolve); + }); + } + return Promise.resolve(); }, - fetchAlphabetical: function() { - return new Promise(function(resolve) { - this.fetch({ - index: { - name: 'search', // 'search' index on tokens array - }, - limit: 100 - }).always(resolve); - }.bind(this)); + fetchAlphabetical() { + return new Promise((resolve) => { + this.fetch({ + index: { + name: 'search', // 'search' index on tokens array + }, + limit: 100, + }).always(resolve); + }); }, - fetchGroups: function(number) { - return new Promise(function(resolve) { - this.fetch({ - index: { - name: 'group', - only: number - } - }).always(resolve); - }.bind(this)); - } + fetchGroups(number) { + return new Promise((resolve) => { + this.fetch({ + index: { + name: 'group', + only: number, + }, + }).always(resolve); + }); + }, }); Whisper.Conversation.COLORS = COLORS.concat(['grey', 'default']).join(' '); @@ -1301,15 +1564,15 @@ database: Whisper.Database, storeName: 'conversations', model: Whisper.Conversation, - fetchGroups: function(number) { - return new Promise(function(resolve) { - this.fetch({ - index: { - name: 'group', - only: number - } - }).always(resolve); - }.bind(this)); - } + fetchGroups(number) { + return new Promise((resolve) => { + this.fetch({ + index: { + name: 'group', + only: number, + }, + }).always(resolve); + }); + }, }); -})(); +}()); diff --git a/js/models/messages.js b/js/models/messages.js index 62455799439b..fbaada39afdc 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -1,771 +1,814 @@ -/* eslint-disable */ +/* global _: false */ +/* global Backbone: false */ +/* global Whisper: false */ +/* global textsecure: false */ +/* global ConversationController: false */ +/* global i18n: false */ +/* global getAccountManager: false */ +/* eslint-disable more/no-then */ + +// eslint-disable-next-line func-names (function () { - 'use strict'; - window.Whisper = window.Whisper || {}; + 'use strict'; - const { Attachment, Message: TypedMessage } = window.Signal.Types; - const { deleteAttachmentData } = window.Signal.Migrations; + window.Whisper = window.Whisper || {}; - var Message = window.Whisper.Message = Backbone.Model.extend({ - database : Whisper.Database, - storeName : 'messages', - initialize: function(attributes) { - if (_.isObject(attributes)) { - this.set(TypedMessage.initializeSchemaVersion(attributes)); - } + const { Message: TypedMessage } = window.Signal.Types; + const { deleteAttachmentData } = window.Signal.Migrations; - this.on('change:attachments', this.updateImageUrl); - this.on('destroy', this.onDestroy); - this.on('change:expirationStartTimestamp', this.setToExpire); - this.on('change:expireTimer', this.setToExpire); - this.on('unload', this.revokeImageUrl); - this.setToExpire(); - }, - idForLogging: function() { - return this.get('source') + '.' + this.get('sourceDevice') + ' ' + this.get('sent_at'); - }, - defaults: function() { - return { - timestamp: new Date().getTime(), - attachments: [] - }; - }, - validate: function(attributes, options) { - var required = ['conversationId', 'received_at', 'sent_at']; - var missing = _.filter(required, function(attr) { return !attributes[attr]; }); - if (missing.length) { - console.log("Message missing attributes: " + missing); - } - }, - isEndSession: function() { - var flag = textsecure.protobuf.DataMessage.Flags.END_SESSION; - return !!(this.get('flags') & flag); - }, - isExpirationTimerUpdate: function() { - var flag = textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE; - return !!(this.get('flags') & flag); - }, - isGroupUpdate: function() { - return !!(this.get('group_update')); - }, - isIncoming: function() { - return this.get('type') === 'incoming'; - }, - isUnread: function() { - return !!this.get('unread'); - }, - // overriding this to allow for this.unset('unread'), save to db, then fetch() - // to propagate. We don't want the unset key in the db so our unread index stays - // small. - // jscs:disable - fetch: function(options) { - options = options ? _.clone(options) : {}; - if (options.parse === void 0) options.parse = true; - var model = this; - var success = options.success; - options.success = function(resp) { - model.attributes = {}; // this is the only changed line - if (!model.set(model.parse(resp, options), options)) return false; - if (success) success(model, resp, options); - model.trigger('sync', model, resp, options); - }; - var error = options.error; - options.error = function(resp) { - if (error) error(model, resp, options); - model.trigger('error', model, resp, options); - }; - return this.sync('read', this, options); - }, - // jscs:enable - getNameForNumber: function(number) { - var conversation = ConversationController.get(number); - if (!conversation) { - return number; - } - return conversation.getDisplayName(); - }, - getDescription: function() { - if (this.isGroupUpdate()) { - var group_update = this.get('group_update'); - if (group_update.left === 'You') { - return i18n('youLeftTheGroup'); - } else if (group_update.left) { - return i18n('leftTheGroup', this.getNameForNumber(group_update.left)); - } + window.Whisper.Message = Backbone.Model.extend({ + database: Whisper.Database, + storeName: 'messages', + initialize(attributes) { + if (_.isObject(attributes)) { + this.set(TypedMessage.initializeSchemaVersion(attributes)); + } - var messages = [i18n('updatedTheGroup')]; - if (group_update.name) { - messages.push(i18n('titleIsNow', group_update.name)); - } - if (group_update.joined && group_update.joined.length) { - var names = _.map(group_update.joined, this.getNameForNumber.bind(this)); - if (names.length > 1) { - messages.push(i18n('multipleJoinedTheGroup', names.join(', '))); - } else { - messages.push(i18n('joinedTheGroup', names[0])); - } - } + this.on('change:attachments', this.updateImageUrl); + this.on('destroy', this.onDestroy); + this.on('change:expirationStartTimestamp', this.setToExpire); + this.on('change:expireTimer', this.setToExpire); + this.on('unload', this.unload); + this.setToExpire(); + }, + idForLogging() { + return `${this.get('source')}.${this.get('sourceDevice')} ${this.get('sent_at')}`; + }, + defaults() { + return { + timestamp: new Date().getTime(), + attachments: [], + }; + }, + validate(attributes) { + const required = ['conversationId', 'received_at', 'sent_at']; + const missing = _.filter(required, attr => !attributes[attr]); + if (missing.length) { + console.log(`Message missing attributes: ${missing}`); + } + }, + isEndSession() { + const flag = textsecure.protobuf.DataMessage.Flags.END_SESSION; + // eslint-disable-next-line no-bitwise + return !!(this.get('flags') & flag); + }, + isExpirationTimerUpdate() { + const flag = textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE; + // eslint-disable-next-line no-bitwise + return !!(this.get('flags') & flag); + }, + isGroupUpdate() { + return !!(this.get('group_update')); + }, + isIncoming() { + return this.get('type') === 'incoming'; + }, + isUnread() { + return !!this.get('unread'); + }, + // overriding this to allow for this.unset('unread'), save to db, then fetch() + // to propagate. We don't want the unset key in the db so our unread index stays + // small. + /* eslint-disable */ + /* jscs:disable */ + fetch(options) { + options = options ? _.clone(options) : {}; + if (options.parse === void 0) options.parse = true; + const model = this; + const success = options.success; + options.success = function (resp) { + model.attributes = {}; // this is the only changed line + if (!model.set(model.parse(resp, options), options)) return false; + if (success) success(model, resp, options); + model.trigger('sync', model, resp, options); + }; + const error = options.error; + options.error = function (resp) { + if (error) error(model, resp, options); + model.trigger('error', model, resp, options); + }; + return this.sync('read', this, options); + }, + /* jscs:enable */ + /* eslint-enable */ + /* eslint-disable more/no-then */ + getNameForNumber(number) { + const conversation = ConversationController.get(number); + if (!conversation) { + return number; + } + return conversation.getDisplayName(); + }, + getDescription() { + if (this.isGroupUpdate()) { + const groupUpdate = this.get('group_update'); + if (groupUpdate.left === 'You') { + return i18n('youLeftTheGroup'); + } else if (groupUpdate.left) { + return i18n('leftTheGroup', this.getNameForNumber(groupUpdate.left)); + } - return messages.join(' '); - } - if (this.isEndSession()) { - return i18n('sessionEnded'); - } - if (this.isIncoming() && this.hasErrors()) { - return i18n('incomingError'); - } - return this.get('body'); - }, - isKeyChange: function() { - return this.get('type') === 'keychange'; - }, - getNotificationText: function() { - var description = this.getDescription(); - if (description) { - return description; - } - if (this.get('attachments').length > 0) { - return i18n('mediaMessage'); - } - if (this.isExpirationTimerUpdate()) { - return i18n('timerSetTo', - Whisper.ExpirationTimerOptions.getAbbreviated( - this.get('expirationTimerUpdate').expireTimer - ) - ); - } - if (this.isKeyChange()) { - var conversation = this.getModelForKeyChange(); - return i18n('keychanged', conversation.getTitle()); - } + const messages = [i18n('updatedTheGroup')]; + if (groupUpdate.name) { + messages.push(i18n('titleIsNow', groupUpdate.name)); + } + if (groupUpdate.joined && groupUpdate.joined.length) { + const names = _.map(groupUpdate.joined, this.getNameForNumber.bind(this)); + if (names.length > 1) { + messages.push(i18n('multipleJoinedTheGroup', names.join(', '))); + } else { + messages.push(i18n('joinedTheGroup', names[0])); + } + } - return ''; - }, - /* eslint-enable */ - /* jshint ignore:start */ - async onDestroy() { - this.revokeImageUrl(); - const attachments = this.get('attachments'); - await Promise.all(attachments.map(deleteAttachmentData)); - return; - }, - /* jshint ignore:end */ - /* eslint-disable */ - updateImageUrl: function() { - this.revokeImageUrl(); - var attachment = this.get('attachments')[0]; - if (attachment) { - var blob = new Blob([attachment.data], { - type: attachment.contentType - }); - this.imageUrl = URL.createObjectURL(blob); - } else { - this.imageUrl = null; - } - }, - revokeImageUrl: function() { - if (this.imageUrl) { - URL.revokeObjectURL(this.imageUrl); - this.imageUrl = null; - } - }, - getImageUrl: function() { - if (this.imageUrl === undefined) { - this.updateImageUrl(); - } - return this.imageUrl; - }, - getConversation: function() { - // This needs to be an unsafe call, because this method is called during - // initial module setup. We may be in the middle of the initial fetch to - // the database. - return ConversationController.getUnsafe(this.get('conversationId')); - }, - getExpirationTimerUpdateSource: function() { - if (this.isExpirationTimerUpdate()) { - var conversationId = this.get('expirationTimerUpdate').source; - return ConversationController.getOrCreate(conversationId, 'private'); - } - }, - getContact: function() { - var conversationId = this.get('source'); - if (!this.isIncoming()) { - conversationId = textsecure.storage.user.getNumber(); - } - return ConversationController.getOrCreate(conversationId, 'private'); - }, - getModelForKeyChange: function() { - var id = this.get('key_changed'); - if (!this.modelForKeyChange) { - var c = ConversationController.getOrCreate(id, 'private'); - this.modelForKeyChange = c; - } - return this.modelForKeyChange; - }, - getModelForVerifiedChange: function() { - var id = this.get('verifiedChanged'); - if (!this.modelForVerifiedChange) { - var c = ConversationController.getOrCreate(id, 'private'); - this.modelForVerifiedChange = c; - } - return this.modelForVerifiedChange; - }, - isOutgoing: function() { - return this.get('type') === 'outgoing'; - }, - hasErrors: function() { - return _.size(this.get('errors')) > 0; - }, + return messages.join(' '); + } + if (this.isEndSession()) { + return i18n('sessionEnded'); + } + if (this.isIncoming() && this.hasErrors()) { + return i18n('incomingError'); + } + return this.get('body'); + }, + isKeyChange() { + return this.get('type') === 'keychange'; + }, + getNotificationText() { + const description = this.getDescription(); + if (description) { + return description; + } + if (this.get('attachments').length > 0) { + return i18n('mediaMessage'); + } + if (this.isExpirationTimerUpdate()) { + const { expireTimer } = this.get('expirationTimerUpdate'); + return i18n( + 'timerSetTo', + Whisper.ExpirationTimerOptions.getAbbreviated(expireTimer) + ); + } + if (this.isKeyChange()) { + const conversation = this.getModelForKeyChange(); + return i18n('keychanged', conversation.getTitle()); + } - getStatus: function(number) { - var read_by = this.get('read_by') || []; - if (read_by.indexOf(number) >= 0) { - return 'read'; - } - var delivered_to = this.get('delivered_to') || []; - if (delivered_to.indexOf(number) >= 0) { - return 'delivered'; - } - var sent_to = this.get('sent_to') || []; - if (sent_to.indexOf(number) >= 0) { - return 'sent'; - } - }, + return ''; + }, + async onDestroy() { + this.revokeImageUrl(); + const attachments = this.get('attachments'); + await Promise.all(attachments.map(deleteAttachmentData)); + }, + updateImageUrl() { + this.revokeImageUrl(); + const attachment = this.get('attachments')[0]; + if (attachment) { + const blob = new Blob([attachment.data], { + type: attachment.contentType, + }); + this.imageUrl = URL.createObjectURL(blob); + } else { + this.imageUrl = null; + } + }, + unload() { + if (this.quoteThumbnail) { + URL.revokeObjectURL(this.quoteThumbnail.objectUrl); + this.quoteThumbnail = null; + } + if (this.quotedMessageFromDatabase) { + this.quotedMessageFromDatabase.unload(); + this.quotedMessageFromDatabase = null; + } + if (this.quotedMessage) { + this.quotedMessage = null; + } + this.revokeImageUrl(); + }, + revokeImageUrl() { + if (this.imageUrl) { + URL.revokeObjectURL(this.imageUrl); + this.imageUrl = null; + } + }, + getImageUrl() { + if (this.imageUrl === undefined) { + this.updateImageUrl(); + } + return this.imageUrl; + }, + getConversation() { + // This needs to be an unsafe call, because this method is called during + // initial module setup. We may be in the middle of the initial fetch to + // the database. + return ConversationController.getUnsafe(this.get('conversationId')); + }, + getExpirationTimerUpdateSource() { + if (this.isExpirationTimerUpdate()) { + const conversationId = this.get('expirationTimerUpdate').source; + return ConversationController.getOrCreate(conversationId, 'private'); + } - send: function(promise) { - this.trigger('pending'); - return promise.then(function(result) { - var now = Date.now(); - this.trigger('done'); - if (result.dataMessage) { - this.set({dataMessage: result.dataMessage}); - } - var sent_to = this.get('sent_to') || []; - this.save({ - sent_to: _.union(sent_to, result.successfulNumbers), - sent: true, - expirationStartTimestamp: now - }); - this.sendSyncMessage(); - }.bind(this)).catch(function(result) { - var now = Date.now(); - this.trigger('done'); - if (result.dataMessage) { - this.set({dataMessage: result.dataMessage}); - } + return Promise.resolve(); + }, + getContact() { + let conversationId = this.get('source'); + if (!this.isIncoming()) { + conversationId = textsecure.storage.user.getNumber(); + } + return ConversationController.getOrCreate(conversationId, 'private'); + }, + getModelForKeyChange() { + const id = this.get('key_changed'); + if (!this.modelForKeyChange) { + const c = ConversationController.getOrCreate(id, 'private'); + this.modelForKeyChange = c; + } + return this.modelForKeyChange; + }, + getModelForVerifiedChange() { + const id = this.get('verifiedChanged'); + if (!this.modelForVerifiedChange) { + const c = ConversationController.getOrCreate(id, 'private'); + this.modelForVerifiedChange = c; + } + return this.modelForVerifiedChange; + }, + isOutgoing() { + return this.get('type') === 'outgoing'; + }, + hasErrors() { + return _.size(this.get('errors')) > 0; + }, - var promises = []; + getStatus(number) { + const readBy = this.get('read_by') || []; + if (readBy.indexOf(number) >= 0) { + return 'read'; + } + const deliveredTo = this.get('delivered_to') || []; + if (deliveredTo.indexOf(number) >= 0) { + return 'delivered'; + } + const sentTo = this.get('sent_to') || []; + if (sentTo.indexOf(number) >= 0) { + return 'sent'; + } - if (result instanceof Error) { - this.saveErrors(result); - if (result.name === 'SignedPreKeyRotationError') { - promises.push(getAccountManager().rotateSignedPreKey()); - } - else if (result.name === 'OutgoingIdentityKeyError') { - var c = ConversationController.get(result.number); - promises.push(c.getProfiles()); - } - } else { - this.saveErrors(result.errors); - if (result.successfulNumbers.length > 0) { - var sent_to = this.get('sent_to') || []; - this.set({ - sent_to: _.union(sent_to, result.successfulNumbers), - sent: true, - expirationStartTimestamp: now - }); - promises.push(this.sendSyncMessage()); - } - promises = promises.concat(_.map(result.errors, function(error) { - if (error.name === 'OutgoingIdentityKeyError') { - var c = ConversationController.get(error.number); - promises.push(c.getProfiles()); - } - })); - } + return null; + }, - return Promise.all(promises).then(function() { - this.trigger('send-error', this.get('errors')); - }.bind(this)); - }.bind(this)); - }, + send(promise) { + this.trigger('pending'); + return promise.then((result) => { + const now = Date.now(); + this.trigger('done'); + if (result.dataMessage) { + this.set({ dataMessage: result.dataMessage }); + } + const sentTo = this.get('sent_to') || []; + this.save({ + sent_to: _.union(sentTo, result.successfulNumbers), + sent: true, + expirationStartTimestamp: now, + }); + this.sendSyncMessage(); + }).catch((result) => { + const now = Date.now(); + this.trigger('done'); + if (result.dataMessage) { + this.set({ dataMessage: result.dataMessage }); + } - someRecipientsFailed: function() { - var c = this.getConversation(); - if (!c || c.isPrivate()) { - return false; - } + let promises = []; - var recipients = c.contactCollection.length - 1; - var errors = this.get('errors'); - if (!errors) { - return false; - } - - if (errors.length > 0 && recipients > 0 && errors.length < recipients) { - return true; - } - - return false; - }, - - sendSyncMessage: function() { - this.syncPromise = this.syncPromise || Promise.resolve(); - this.syncPromise = this.syncPromise.then(function() { - var dataMessage = this.get('dataMessage'); - if (this.get('synced') || !dataMessage) { - return; - } - return textsecure.messaging.sendSyncMessage( - dataMessage, this.get('sent_at'), this.get('destination'), this.get('expirationStartTimestamp') - ).then(function() { - this.save({synced: true, dataMessage: null}); - }.bind(this)); - }.bind(this)); - }, - - saveErrors: function(errors) { - if (!(errors instanceof Array)) { - errors = [errors]; - } - errors.forEach(function(e) { - console.log( - 'Message.saveErrors:', - e && e.reason ? e.reason : null, - e && e.stack ? e.stack : e - ); + if (result instanceof Error) { + this.saveErrors(result); + if (result.name === 'SignedPreKeyRotationError') { + promises.push(getAccountManager().rotateSignedPreKey()); + } else if (result.name === 'OutgoingIdentityKeyError') { + const c = ConversationController.get(result.number); + promises.push(c.getProfiles()); + } + } else { + this.saveErrors(result.errors); + if (result.successfulNumbers.length > 0) { + const sentTo = this.get('sent_to') || []; + this.set({ + sent_to: _.union(sentTo, result.successfulNumbers), + sent: true, + expirationStartTimestamp: now, }); - errors = errors.map(function(e) { - if (e.constructor === Error || + promises.push(this.sendSyncMessage()); + } + promises = promises.concat(_.map(result.errors, (error) => { + if (error.name === 'OutgoingIdentityKeyError') { + const c = ConversationController.get(error.number); + promises.push(c.getProfiles()); + } + })); + } + + return Promise.all(promises).then(() => { + this.trigger('send-error', this.get('errors')); + }); + }); + }, + + someRecipientsFailed() { + const c = this.getConversation(); + if (!c || c.isPrivate()) { + return false; + } + + const recipients = c.contactCollection.length - 1; + const errors = this.get('errors'); + if (!errors) { + return false; + } + + if (errors.length > 0 && recipients > 0 && errors.length < recipients) { + return true; + } + + return false; + }, + + sendSyncMessage() { + this.syncPromise = this.syncPromise || Promise.resolve(); + this.syncPromise = this.syncPromise.then(() => { + const dataMessage = this.get('dataMessage'); + if (this.get('synced') || !dataMessage) { + return Promise.resolve(); + } + return textsecure.messaging.sendSyncMessage( + dataMessage, + this.get('sent_at'), + this.get('destination'), + this.get('expirationStartTimestamp') + ).then(() => { + this.save({ synced: true, dataMessage: null }); + }); + }); + }, + + saveErrors(providedErrors) { + let errors = providedErrors; + + if (!(errors instanceof Array)) { + errors = [errors]; + } + errors.forEach((e) => { + console.log( + 'Message.saveErrors:', + e && e.reason ? e.reason : null, + e && e.stack ? e.stack : e + ); + }); + errors = errors.map((e) => { + if (e.constructor === Error || e.constructor === TypeError || e.constructor === ReferenceError) { - return _.pick(e, 'name', 'message', 'code', 'number', 'reason'); - } - return e; - }); - errors = errors.concat(this.get('errors') || []); + return _.pick(e, 'name', 'message', 'code', 'number', 'reason'); + } + return e; + }); + errors = errors.concat(this.get('errors') || []); - return this.save({errors : errors}); - }, + return this.save({ errors }); + }, - hasNetworkError: function(number) { - var error = _.find(this.get('errors'), function(e) { - return (e.name === 'MessageError' || - e.name === 'OutgoingMessageError' || - e.name === 'SendMessageNetworkError' || - e.name === 'SignedPreKeyRotationError'); - }); - return !!error; - }, - removeOutgoingErrors: function(number) { - var errors = _.partition(this.get('errors'), function(e) { - return e.number === number && - (e.name === 'MessageError' || - e.name === 'OutgoingMessageError' || - e.name === 'SendMessageNetworkError' || - e.name === 'SignedPreKeyRotationError' || - e.name === 'OutgoingIdentityKeyError'); - }); - this.set({errors: errors[1]}); - return errors[0][0]; - }, - isReplayableError: function(e) { - return (e.name === 'MessageError' || - e.name === 'OutgoingMessageError' || - e.name === 'SendMessageNetworkError' || - e.name === 'SignedPreKeyRotationError' || - e.name === 'OutgoingIdentityKeyError'); - }, - resend: function(number) { - var error = this.removeOutgoingErrors(number); - if (error) { - var promise = new textsecure.ReplayableError(error).replay(); - this.send(promise); + hasNetworkError() { + const error = _.find( + this.get('errors'), + e => (e.name === 'MessageError' || + e.name === 'OutgoingMessageError' || + e.name === 'SendMessageNetworkError' || + e.name === 'SignedPreKeyRotationError') + ); + return !!error; + }, + removeOutgoingErrors(number) { + const errors = _.partition( + this.get('errors'), + e => e.number === number && + (e.name === 'MessageError' || + e.name === 'OutgoingMessageError' || + e.name === 'SendMessageNetworkError' || + e.name === 'SignedPreKeyRotationError' || + e.name === 'OutgoingIdentityKeyError') + ); + this.set({ errors: errors[1] }); + return errors[0][0]; + }, + isReplayableError(e) { + return (e.name === 'MessageError' || + e.name === 'OutgoingMessageError' || + e.name === 'SendMessageNetworkError' || + e.name === 'SignedPreKeyRotationError' || + e.name === 'OutgoingIdentityKeyError'); + }, + resend(number) { + const error = this.removeOutgoingErrors(number); + if (error) { + const promise = new textsecure.ReplayableError(error).replay(); + this.send(promise); + } + }, + handleDataMessage(dataMessage, confirm) { + // This function is called from the background script in a few scenarios: + // 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 go through one of the previous two codepaths + const message = this; + const source = message.get('source'); + const type = message.get('type'); + let conversationId = message.get('conversationId'); + if (dataMessage.group) { + conversationId = dataMessage.group.id; + } + const GROUP_TYPES = textsecure.protobuf.GroupContext.Type; + + const conversation = ConversationController.get(conversationId); + return conversation.queueJob(() => new Promise((resolve) => { + const now = new Date().getTime(); + let attributes = { type: 'private' }; + if (dataMessage.group) { + let groupUpdate = null; + attributes = { + type: 'group', + groupId: dataMessage.group.id, + }; + if (dataMessage.group.type === GROUP_TYPES.UPDATE) { + attributes = { + type: 'group', + groupId: dataMessage.group.id, + name: dataMessage.group.name, + avatar: dataMessage.group.avatar, + members: _.union(dataMessage.group.members, conversation.get('members')), + }; + groupUpdate = conversation.changedAttributes(_.pick( + dataMessage.group, + 'name', + 'avatar' + )) || {}; + const difference = _.difference( + attributes.members, + conversation.get('members') + ); + if (difference.length > 0) { + groupUpdate.joined = difference; } - }, - handleDataMessage: function(dataMessage, confirm) { - // This function is called from the background script in a few scenarios: - // 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 go through one of the previous two codepaths - var message = this; - var source = message.get('source'); - var type = message.get('type'); - var timestamp = message.get('sent_at'); - var conversationId = message.get('conversationId'); - if (dataMessage.group) { - conversationId = dataMessage.group.id; + if (conversation.get('left')) { + console.log('re-added to a left group'); + attributes.left = false; } + } else if (dataMessage.group.type === GROUP_TYPES.QUIT) { + if (source === textsecure.storage.user.getNumber()) { + attributes.left = true; + groupUpdate = { left: 'You' }; + } else { + groupUpdate = { left: source }; + } + attributes.members = _.without(conversation.get('members'), source); + } - var conversation = ConversationController.get(conversationId); - return conversation.queueJob(function() { - return new Promise(function(resolve) { - var now = new Date().getTime(); - var attributes = { type: 'private' }; - if (dataMessage.group) { - var group_update = null; - attributes = { - type: 'group', - groupId: dataMessage.group.id, - }; - if (dataMessage.group.type === textsecure.protobuf.GroupContext.Type.UPDATE) { - attributes = { - type : 'group', - groupId : dataMessage.group.id, - name : dataMessage.group.name, - avatar : dataMessage.group.avatar, - members : _.union(dataMessage.group.members, conversation.get('members')), - }; - group_update = conversation.changedAttributes(_.pick(dataMessage.group, 'name', 'avatar')) || {}; - var difference = _.difference(attributes.members, conversation.get('members')); - if (difference.length > 0) { - group_update.joined = difference; - } - if (conversation.get('left')) { - console.log('re-added to a left group'); - attributes.left = false; - } - } - else if (dataMessage.group.type === textsecure.protobuf.GroupContext.Type.QUIT) { - if (source == textsecure.storage.user.getNumber()) { - attributes.left = true; - group_update = { left: "You" }; - } else { - group_update = { left: source }; - } - attributes.members = _.without(conversation.get('members'), source); - } + if (groupUpdate !== null) { + message.set({ group_update: groupUpdate }); + } + } + message.set({ + schemaVersion: dataMessage.schemaVersion, + body: dataMessage.body, + conversationId: conversation.id, + attachments: dataMessage.attachments, + quote: dataMessage.quote, + decrypted_at: now, + flags: dataMessage.flags, + errors: [], + }); + if (type === 'outgoing') { + const receipts = Whisper.DeliveryReceipts.forMessage(conversation, message); + receipts.forEach(() => message.set({ + delivered: (message.get('delivered') || 0) + 1, + })); + } + attributes.active_at = now; + conversation.set(attributes); - if (group_update !== null) { - message.set({group_update: group_update}); - } - } - message.set({ - schemaVersion : dataMessage.schemaVersion, - body : dataMessage.body, - conversationId : conversation.id, - attachments : dataMessage.attachments, - decrypted_at : now, - flags : dataMessage.flags, - errors : [] - }); - if (type === 'outgoing') { - var receipts = Whisper.DeliveryReceipts.forMessage(conversation, message); - receipts.forEach(function(receipt) { - message.set({ - delivered: (message.get('delivered') || 0) + 1 - }); - }); - } - attributes.active_at = now; - conversation.set(attributes); + if (message.isExpirationTimerUpdate()) { + message.set({ + expirationTimerUpdate: { + source, + expireTimer: dataMessage.expireTimer, + }, + }); + conversation.set({ expireTimer: dataMessage.expireTimer }); + } else if (dataMessage.expireTimer) { + message.set({ expireTimer: dataMessage.expireTimer }); + } - if (message.isExpirationTimerUpdate()) { - message.set({ - expirationTimerUpdate: { - source : source, - expireTimer : dataMessage.expireTimer - } - }); - conversation.set({expireTimer: dataMessage.expireTimer}); - } else if (dataMessage.expireTimer) { - message.set({expireTimer: dataMessage.expireTimer}); - } - - // NOTE: Remove once the above uses - // `Conversation::updateExpirationTimer`: - const { expireTimer } = dataMessage; - const shouldLogExpireTimerChange = + // NOTE: Remove once the above uses + // `Conversation::updateExpirationTimer`: + const { expireTimer } = dataMessage; + const shouldLogExpireTimerChange = message.isExpirationTimerUpdate() || expireTimer; - if (shouldLogExpireTimerChange) { - console.log( - 'Updating expireTimer for conversation', - conversation.idForLogging(), - 'to', - expireTimer, - 'via `handleDataMessage`' - ); - } + if (shouldLogExpireTimerChange) { + console.log( + 'Updating expireTimer for conversation', + conversation.idForLogging(), + 'to', + expireTimer, + 'via `handleDataMessage`' + ); + } - if (!message.isEndSession() && !message.isGroupUpdate()) { - if (dataMessage.expireTimer) { - if (dataMessage.expireTimer !== conversation.get('expireTimer')) { - conversation.updateExpirationTimer( - dataMessage.expireTimer, source, - message.get('received_at')); - } - } else if (conversation.get('expireTimer')) { - conversation.updateExpirationTimer(null, source, - message.get('received_at')); - } - } - if (type === 'incoming') { - var readSync = Whisper.ReadSyncs.forMessage(message); - if (readSync) { - if (message.get('expireTimer') && !message.get('expirationStartTimestamp')) { - message.set('expirationStartTimestamp', readSync.get('read_at')); - } - } - if (readSync || message.isExpirationTimerUpdate()) { - message.unset('unread'); - // This is primarily to allow the conversation to mark all older messages as - // read, as is done when we receive a read sync for a message we already - // know about. - Whisper.ReadSyncs.notifyConversation(message); - } else { - conversation.set('unreadCount', conversation.get('unreadCount') + 1); - } - } + if (!message.isEndSession() && !message.isGroupUpdate()) { + if (dataMessage.expireTimer) { + if (dataMessage.expireTimer !== conversation.get('expireTimer')) { + conversation.updateExpirationTimer( + dataMessage.expireTimer, source, + message.get('received_at') + ); + } + } else if (conversation.get('expireTimer')) { + conversation.updateExpirationTimer( + null, source, + message.get('received_at') + ); + } + } + if (type === 'incoming') { + const readSync = Whisper.ReadSyncs.forMessage(message); + if (readSync) { + if (message.get('expireTimer') && !message.get('expirationStartTimestamp')) { + message.set('expirationStartTimestamp', readSync.get('read_at')); + } + } + if (readSync || message.isExpirationTimerUpdate()) { + message.unset('unread'); + // This is primarily to allow the conversation to mark all older messages as + // read, as is done when we receive a read sync for a message we already + // know about. + Whisper.ReadSyncs.notifyConversation(message); + } else { + conversation.set('unreadCount', conversation.get('unreadCount') + 1); + } + } - if (type === 'outgoing') { - var reads = Whisper.ReadReceipts.forMessage(conversation, message); - if (reads.length) { - var read_by = reads.map(function(receipt) { - return receipt.get('reader'); - }); - message.set({ - read_by: _.union(message.get('read_by'), read_by) - }); - } + if (type === 'outgoing') { + const reads = Whisper.ReadReceipts.forMessage(conversation, message); + if (reads.length) { + const readBy = reads.map(receipt => receipt.get('reader')); + message.set({ + read_by: _.union(message.get('read_by'), readBy), + }); + } - message.set({recipients: conversation.getRecipients()}); - } + message.set({ recipients: conversation.getRecipients() }); + } - var conversation_timestamp = conversation.get('timestamp'); - if (!conversation_timestamp || message.get('sent_at') > conversation_timestamp) { - conversation.set({ - lastMessage : message.getNotificationText(), - timestamp: message.get('sent_at') - }); - } + const conversationTimestamp = conversation.get('timestamp'); + if (!conversationTimestamp || message.get('sent_at') > conversationTimestamp) { + conversation.set({ + lastMessage: message.getNotificationText(), + timestamp: message.get('sent_at'), + }); + } - if (dataMessage.profileKey) { - var profileKey = dataMessage.profileKey.toArrayBuffer(); - if (source == textsecure.storage.user.getNumber()) { - conversation.set({profileSharing: true}); - } else if (conversation.isPrivate()) { - conversation.set({profileKey: profileKey}); - } else { - ConversationController.getOrCreateAndWait(source, 'private').then(function(sender) { - sender.setProfileKey(profileKey); - }); - } - } + if (dataMessage.profileKey) { + const profileKey = dataMessage.profileKey.toArrayBuffer(); + if (source === textsecure.storage.user.getNumber()) { + conversation.set({ profileSharing: true }); + } else if (conversation.isPrivate()) { + conversation.set({ profileKey }); + } else { + ConversationController.getOrCreateAndWait( + source, + 'private' + ).then((sender) => { + sender.setProfileKey(profileKey); + }); + } + } - var handleError = function(error) { - error = error && error.stack ? error.stack : error; - console.log('handleDataMessage', message.idForLogging(), 'error:', error); - return resolve(); - }; + const handleError = (error) => { + const errorForLog = error && error.stack ? error.stack : error; + console.log('handleDataMessage', message.idForLogging(), 'error:', errorForLog); + return resolve(); + }; - message.save().then(function() { - conversation.save().then(function() { - try { - conversation.trigger('newmessage', message); - } - catch (e) { - return handleError(e); - } - // We fetch() here because, between the message.save() above and the previous - // line's trigger() call, we might have marked all messages unread in the - // database. This message might already be read! - var previousUnread = message.get('unread'); - message.fetch().then(function() { - try { - if (previousUnread !== message.get('unread')) { - console.log('Caught race condition on new message read state! ' + + message.save().then(() => { + conversation.save().then(() => { + try { + conversation.trigger('newmessage', message); + } catch (e) { + return handleError(e); + } + // We fetch() here because, between the message.save() above and the previous + // line's trigger() call, we might have marked all messages unread in the + // database. This message might already be read! + const previousUnread = message.get('unread'); + return message.fetch().then(() => { + try { + if (previousUnread !== message.get('unread')) { + console.log('Caught race condition on new message read state! ' + 'Manually starting timers.'); - // We call markRead() even though the message is already marked read - // because we need to start expiration timers, etc. - message.markRead(); - } - - if (message.get('unread')) { - conversation.notify(message).then(function() { - confirm(); - return resolve(); - }, handleError); - } else { - confirm(); - return resolve(); - } - } - catch (e) { - handleError(e); - } - }, function(error) { - try { - console.log('handleDataMessage: Message', message.idForLogging(), 'was deleted'); - - confirm(); - return resolve(); - } - catch (e) { - handleError(e); - } - }); - }, handleError); - }, handleError); - }); - }); - }, - markRead: function(read_at) { - this.unset('unread'); - if (this.get('expireTimer') && !this.get('expirationStartTimestamp')) { - this.set('expirationStartTimestamp', read_at || Date.now()); - } - Whisper.Notifications.remove(Whisper.Notifications.where({ - messageId: this.id - })); - return new Promise(function(resolve, reject) { - this.save().then(resolve, reject); - }.bind(this)); - }, - isExpiring: function() { - return this.get('expireTimer') && this.get('expirationStartTimestamp'); - }, - isExpired: function() { - return this.msTilExpire() <= 0; - }, - msTilExpire: function() { - if (!this.isExpiring()) { - return Infinity; - } - var now = Date.now(); - var start = this.get('expirationStartTimestamp'); - var delta = this.get('expireTimer') * 1000; - var ms_from_now = start + delta - now; - if (ms_from_now < 0) { - ms_from_now = 0; - } - return ms_from_now; - }, - setToExpire: function() { - if (this.isExpiring() && !this.get('expires_at')) { - var start = this.get('expirationStartTimestamp'); - var delta = this.get('expireTimer') * 1000; - var expires_at = start + delta; - - // This method can be called due to the expiration-related .set() calls in - // handleDataMessage(), but the .save() here would conflict with the - // same call at the end of handleDataMessage(). So we only call .save() - // here if we've previously saved this model. - if (!this.isNew()) { - this.save('expires_at', expires_at); + // We call markRead() even though the message is already marked read + // because we need to start expiration timers, etc. + message.markRead(); } - Whisper.ExpiringMessagesListener.update(); - console.log('message', this.get('sent_at'), 'expires at', expires_at); - } + if (message.get('unread')) { + return conversation.notify(message).then(() => { + confirm(); + return resolve(); + }, handleError); + } + + confirm(); + return resolve(); + } catch (e) { + return handleError(e); + } + }, () => { + try { + console.log( + 'handleDataMessage: Message', + message.idForLogging(), + 'was deleted' + ); + + confirm(); + return resolve(); + } catch (e) { + return handleError(e); + } + }); + }, handleError); + }, handleError); + })); + }, + markRead(readAt) { + this.unset('unread'); + if (this.get('expireTimer') && !this.get('expirationStartTimestamp')) { + this.set('expirationStartTimestamp', readAt || Date.now()); + } + Whisper.Notifications.remove(Whisper.Notifications.where({ + messageId: this.id, + })); + return new Promise((resolve, reject) => { + this.save().then(resolve, reject); + }); + }, + isExpiring() { + return this.get('expireTimer') && this.get('expirationStartTimestamp'); + }, + isExpired() { + return this.msTilExpire() <= 0; + }, + msTilExpire() { + if (!this.isExpiring()) { + return Infinity; + } + const now = Date.now(); + const start = this.get('expirationStartTimestamp'); + const delta = this.get('expireTimer') * 1000; + let msFromNow = (start + delta) - now; + if (msFromNow < 0) { + msFromNow = 0; + } + return msFromNow; + }, + setToExpire() { + if (this.isExpiring() && !this.get('expires_at')) { + const start = this.get('expirationStartTimestamp'); + const delta = this.get('expireTimer') * 1000; + const expiresAt = start + delta; + + // This method can be called due to the expiration-related .set() calls in + // handleDataMessage(), but the .save() here would conflict with the + // same call at the end of handleDataMessage(). So we only call .save() + // here if we've previously saved this model. + if (!this.isNew()) { + this.save('expires_at', expiresAt); } - }); + Whisper.ExpiringMessagesListener.update(); + console.log('message', this.get('sent_at'), 'expires at', expiresAt); + } + }, - Whisper.MessageCollection = Backbone.Collection.extend({ - model : Message, - database : Whisper.Database, - storeName : 'messages', - comparator : function(left, right) { - if (left.get('received_at') === right.get('received_at')) { - return (left.get('sent_at') || 0) - (right.get('sent_at') || 0); - } + }); - return (left.get('received_at') || 0) - (right.get('received_at') || 0); - }, - initialize : function(models, options) { - if (options) { - this.conversation = options.conversation; - } - }, - destroyAll : function () { - return Promise.all(this.models.map(function(m) { - return new Promise(function(resolve, reject) { - m.destroy().then(resolve).fail(reject); - }); - })); + Whisper.MessageCollection = Backbone.Collection.extend({ + model: Whisper.Message, + database: Whisper.Database, + storeName: 'messages', + comparator(left, right) { + if (left.get('received_at') === right.get('received_at')) { + return (left.get('sent_at') || 0) - (right.get('sent_at') || 0); + } + + return (left.get('received_at') || 0) - (right.get('received_at') || 0); + }, + initialize(models, options) { + if (options) { + this.conversation = options.conversation; + } + }, + destroyAll() { + return Promise.all(this.models.map(m => new Promise((resolve, reject) => { + m.destroy().then(resolve).fail(reject); + }))); + }, + + fetchSentAt(timestamp) { + return new Promise((resolve => this.fetch({ + index: { + // 'receipt' index on sent_at + name: 'receipt', + only: timestamp, }, + }).always(resolve))); + }, - fetchSentAt: function(timestamp) { - return new Promise(function(resolve) { - return this.fetch({ - index: { - // 'receipt' index on sent_at - name: 'receipt', - only: timestamp - } - }).always(resolve); - }.bind(this)); - }, + getLoadedUnreadCount() { + return this.reduce((total, model) => { + const unread = model.get('unread') && model.isIncoming(); + return total + (unread ? 1 : 0); + }, 0); + }, - getLoadedUnreadCount: function() { - return this.reduce(function(total, model) { - var unread = model.get('unread') && model.isIncoming(); - return total + (unread ? 1 : 0); - }, 0); - }, + fetchConversation(conversationId, providedLimit, providedUnreadCount) { + let limit = providedLimit; + let unreadCount = providedUnreadCount; - fetchConversation: function(conversationId, limit, unreadCount) { - if (typeof limit !== 'number') { - limit = 100; - } - if (typeof unreadCount !== 'number') { - unreadCount = 0; - } + if (typeof limit !== 'number') { + limit = 100; + } + if (typeof unreadCount !== 'number') { + unreadCount = 0; + } - var startingLoadedUnread = 0; - if (unreadCount > 0) { - startingLoadedUnread = this.getLoadedUnreadCount(); - } - return new Promise(function(resolve) { - var upper; - if (this.length === 0) { - // fetch the most recent messages first - upper = Number.MAX_VALUE; - } else { - // not our first rodeo, fetch older messages. - upper = this.at(0).get('received_at'); - } - var options = {remove: false, limit: limit}; - options.index = { - // 'conversation' index on [conversationId, received_at] - name : 'conversation', - lower : [conversationId], - upper : [conversationId, upper], - order : 'desc' - // SELECT messages WHERE conversationId = this.id ORDER - // received_at DESC - }; - this.fetch(options).always(resolve); - }.bind(this)).then(function() { - if (unreadCount > 0) { - var loadedUnread = this.getLoadedUnreadCount(); - if (loadedUnread >= unreadCount) { - return; - } - - if (startingLoadedUnread === loadedUnread) { - // that fetch didn't get us any more unread. stop fetching more. - return; - } - - console.log('fetchConversation: doing another fetch to get all unread'); - return this.fetchConversation(conversationId, limit, unreadCount); - } - }.bind(this)); - }, - - fetchNextExpiring: function() { - this.fetch({ index: { name: 'expires_at' }, limit: 1 }); - }, - - fetchExpired: function() { - console.log('loading expired messages'); - this.fetch({ - conditions: { expires_at: { $lte: Date.now() } }, - addIndividually: true - }); + let startingLoadedUnread = 0; + if (unreadCount > 0) { + startingLoadedUnread = this.getLoadedUnreadCount(); + } + return new Promise((resolve) => { + let upper; + if (this.length === 0) { + // fetch the most recent messages first + upper = Number.MAX_VALUE; + } else { + // not our first rodeo, fetch older messages. + upper = this.at(0).get('received_at'); } - }); -})(); + const options = { remove: false, limit }; + options.index = { + // 'conversation' index on [conversationId, received_at] + name: 'conversation', + lower: [conversationId], + upper: [conversationId, upper], + order: 'desc', + // SELECT messages WHERE conversationId = this.id ORDER + // received_at DESC + }; + this.fetch(options).always(resolve); + }).then(() => { + if (unreadCount <= 0) { + return Promise.resolve(); + } + + const loadedUnread = this.getLoadedUnreadCount(); + if (loadedUnread >= unreadCount) { + return Promise.resolve(); + } + + if (startingLoadedUnread === loadedUnread) { + // that fetch didn't get us any more unread. stop fetching more. + return Promise.resolve(); + } + + console.log('fetchConversation: doing another fetch to get all unread'); + return this.fetchConversation(conversationId, limit, unreadCount); + }); + }, + + fetchNextExpiring() { + this.fetch({ index: { name: 'expires_at' }, limit: 1 }); + }, + + fetchExpired() { + console.log('loading expired messages'); + this.fetch({ + conditions: { expires_at: { $lte: Date.now() } }, + addIndividually: true, + }); + }, + }); +}()); diff --git a/js/modules/types/message.js b/js/modules/types/message.js index f17bcf88ba8f..ec84e7299680 100644 --- a/js/modules/types/message.js +++ b/js/modules/types/message.js @@ -26,7 +26,7 @@ const INITIAL_SCHEMA_VERSION = 0; // 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: -exports.CURRENT_SCHEMA_VERSION = 3; +exports.CURRENT_SCHEMA_VERSION = 4; // Public API @@ -149,6 +149,35 @@ exports._mapAttachments = upgradeAttachment => async (message, context) => { return Object.assign({}, message, { attachments }); }; +// _mapQuotedAttachments :: (QuotedAttachment -> Promise QuotedAttachment) -> +// (Message, Context) -> +// Promise Message +exports._mapQuotedAttachments = upgradeAttachment => async (message, context) => { + if (!message.quote) { + return message; + } + + const upgradeWithContext = async (attachment) => { + if (!attachment || !attachment.thumbnail) { + return attachment; + } + + const thumbnail = await upgradeAttachment(attachment.thumbnail, context); + return Object.assign({}, attachment, { + thumbnail, + }); + }; + + const quotedAttachments = (message.quote && message.quote.attachments) || []; + + const attachments = await Promise.all(quotedAttachments.map(upgradeWithContext)); + return Object.assign({}, message, { + quote: Object.assign({}, message.quote, { + attachments, + }), + }); +}; + const toVersion0 = async message => exports.initializeSchemaVersion(message); @@ -164,17 +193,29 @@ const toVersion3 = exports._withSchemaVersion( 3, exports._mapAttachments(Attachment.migrateDataToFileSystem) ); +const toVersion4 = exports._withSchemaVersion( + 4, + exports._mapQuotedAttachments(Attachment.migrateDataToFileSystem) +); // UpgradeStep -exports.upgradeSchema = async (message, { writeNewAttachmentData } = {}) => { +exports.upgradeSchema = async (rawMessage, { writeNewAttachmentData } = {}) => { if (!isFunction(writeNewAttachmentData)) { throw new TypeError('`context.writeNewAttachmentData` is required'); } - return toVersion3( - await toVersion2(await toVersion1(await toVersion0(message))), - { writeNewAttachmentData } - ); + let message = rawMessage; + const versions = [toVersion0, toVersion1, toVersion2, toVersion3, toVersion4]; + + for (let i = 0, max = versions.length; i < max; i += 1) { + const currentVersion = versions[i]; + // We really do want this intra-loop await because this is a chained async action, + // each step dependent on the previous + // eslint-disable-next-line no-await-in-loop + message = await currentVersion(message, { writeNewAttachmentData }); + } + + return message; }; exports.createAttachmentLoader = (loadAttachmentData) => { diff --git a/js/modules/types/mime.js b/js/modules/types/mime.js index 82228f9dc6af..b149aead43b7 100644 --- a/js/modules/types/mime.js +++ b/js/modules/types/mime.js @@ -1,2 +1,10 @@ exports.isJPEG = mimeType => mimeType === 'image/jpeg'; + +exports.isVideo = mimeType => + mimeType.startsWith('video/') && mimeType !== 'video/wmv'; + +exports.isImage = mimeType => + mimeType.startsWith('image/') && mimeType !== 'image/tiff'; + +exports.isAudio = mimeType => mimeType.startsWith('audio/'); diff --git a/js/views/attachment_view.js b/js/views/attachment_view.js index 570d78d25521..b82614ef3743 100644 --- a/js/views/attachment_view.js +++ b/js/views/attachment_view.js @@ -12,6 +12,7 @@ 'use strict'; const ESCAPE_KEY_CODE = 27; + const { Signal } = window; const FileView = Whisper.View.extend({ tagName: 'div', @@ -69,7 +70,7 @@ ]; Whisper.AttachmentView = Backbone.View.extend({ - tagName: 'span', + tagName: 'div', className() { if (this.isImage()) { return 'attachment'; @@ -133,14 +134,16 @@ return false; }, isAudio() { - return this.model.contentType.startsWith('audio/'); + const { contentType } = this.model; + return Signal.Types.MIME.isAudio(contentType); }, isVideo() { - return this.model.contentType.startsWith('video/'); + const { contentType } = this.model; + return Signal.Types.MIME.isVideo(contentType); }, isImage() { - const type = this.model.contentType; - return type.startsWith('image/') && type !== 'image/tiff'; + const { contentType } = this.model; + return Signal.Types.MIME.isImage(contentType); }, mediaType() { if (this.isVoiceMessage()) { diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index d19ceb636076..ba5f0fb2c8ac 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -114,6 +114,7 @@ this.listenTo(this.model, 'expired', this.onExpired); this.listenTo(this.model, 'prune', this.onPrune); this.listenTo(this.model.messageCollection, 'expired', this.onExpiredCollection); + this.listenTo(this.model.messageCollection, 'scroll-to-message', this.scrollToMessage); this.lazyUpdateVerified = _.debounce( this.model.updateVerified.bind(this.model), @@ -191,7 +192,7 @@ 'click' : 'onClick', 'click .bottom-bar': 'focusMessageField', 'click .back': 'resetPanel', - 'click .microphone': 'captureAudio', + 'click .capture-audio .microphone': 'captureAudio', 'click .disappearing-messages': 'enableDisappearingMessages', 'click .scroll-down-button-view': 'scrollToBottom', 'click button.emoji': 'toggleEmojiPanel', @@ -529,6 +530,21 @@ } }, + scrollToMessage: function(options = {}) { + const { id } = options; + + if (!id) { + return; + } + + const el = this.$(`#${id}`); + if (!el || el.length === 0) { + return; + } + + el[0].scrollIntoView(); + }, + scrollToBottom: function() { // If we're above the last seen indicator, we should scroll there instead // Note: if we don't end up at the bottom of the conversation, button will not go away! @@ -669,7 +685,7 @@ // This is debounced, so it won't hit the database too often. this.lazyUpdateVerified(); - this.model.messageCollection.add(message, {merge: true}); + this.model.addSingleMessage(message); message.setToExpire(); if (message.isOutgoing()) { diff --git a/js/views/message_view.js b/js/views/message_view.js index 59f0e54d8e6d..f49189aac7a7 100644 --- a/js/views/message_view.js +++ b/js/views/message_view.js @@ -1,417 +1,523 @@ -/* eslint-disable */ - /* global Whisper: false */ +/* global i18n: false */ +/* global textsecure: false */ +/* global _: false */ +/* global emoji_util: false */ +/* global Mustache: false */ +/* global ConversationController: false */ +// eslint-disable-next-line func-names (function () { - 'use strict'; - window.Whisper = window.Whisper || {}; + 'use strict'; - const { HTML } = window.Signal; - const { Attachment } = window.Signal.Types; - const { loadAttachmentData } = window.Signal.Migrations; + const { Signal } = window; + const { loadAttachmentData } = window.Signal.Migrations; - var URL_REGEX = /(^|[\s\n]|)((?:https?|ftp):\/\/[\-A-Z0-9\u00A0-\uD7FF\uE000-\uFDCF\uFDF0-\uFFFD+\u0026\u2019@#\/%?=()~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~()_|])/gi; + window.Whisper = window.Whisper || {}; - var ErrorIconView = Whisper.View.extend({ - templateName: 'error-icon', - className: 'error-icon-container', - initialize: function() { - if (this.model.name === 'UnregisteredUserError') { - this.$el.addClass('unregistered-user-error'); - } + const ErrorIconView = Whisper.View.extend({ + templateName: 'error-icon', + className: 'error-icon-container', + initialize() { + if (this.model.name === 'UnregisteredUserError') { + this.$el.addClass('unregistered-user-error'); + } + }, + }); + const NetworkErrorView = Whisper.View.extend({ + tagName: 'span', + className: 'hasRetry', + templateName: 'hasRetry', + render_attributes() { + let messageNotSent; + + if (!this.model.someRecipientsFailed()) { + messageNotSent = i18n('messageNotSent'); + } + + return { + messageNotSent, + resend: i18n('resend'), + }; + }, + }); + const SomeFailedView = Whisper.View.extend({ + tagName: 'span', + className: 'some-failed', + templateName: 'some-failed', + render_attributes: { + someFailed: i18n('someRecipientsFailed'), + }, + }); + const TimerView = Whisper.View.extend({ + templateName: 'hourglass', + initialize() { + this.listenTo(this.model, 'unload', this.remove); + }, + update() { + if (this.timeout) { + clearTimeout(this.timeout); + this.timeout = null; + } + if (this.model.isExpired()) { + return this; + } + if (this.model.isExpiring()) { + this.render(); + const totalTime = this.model.get('expireTimer') * 1000; + const remainingTime = this.model.msTilExpire(); + const elapsed = (totalTime - remainingTime) / totalTime; + this.$('.sand').css('transform', `translateY(${elapsed * 100}%)`); + this.$el.css('display', 'inline-block'); + this.timeout = setTimeout(this.update.bind(this), Math.max(totalTime / 100, 500)); + } + return this; + }, + }); + + Whisper.ExpirationTimerUpdateView = Whisper.View.extend({ + tagName: 'li', + className: 'expirationTimerUpdate advisory', + templateName: 'expirationTimerUpdate', + id() { + return this.model.id; + }, + initialize() { + this.conversation = this.model.getExpirationTimerUpdateSource(); + this.listenTo(this.conversation, 'change', this.render); + this.listenTo(this.model, 'unload', this.remove); + }, + render_attributes() { + const seconds = this.model.get('expirationTimerUpdate').expireTimer; + let timerMessage; + + const timerUpdate = this.model.get('expirationTimerUpdate'); + const prettySeconds = Whisper.ExpirationTimerOptions.getName(seconds); + + if (timerUpdate && timerUpdate.fromSync) { + timerMessage = i18n('timerSetOnSync', prettySeconds); + } else if (this.conversation.id === textsecure.storage.user.getNumber()) { + timerMessage = i18n('youChangedTheTimer', prettySeconds); + } else { + timerMessage = i18n('theyChangedTheTimer', [ + this.conversation.getTitle(), + prettySeconds, + ]); + } + return { content: timerMessage }; + }, + }); + + Whisper.KeyChangeView = Whisper.View.extend({ + tagName: 'li', + className: 'keychange advisory', + templateName: 'keychange', + id() { + return this.model.id; + }, + initialize() { + this.conversation = this.model.getModelForKeyChange(); + this.listenTo(this.conversation, 'change', this.render); + this.listenTo(this.model, 'unload', this.remove); + }, + events: { + 'click .content': 'showIdentity', + }, + render_attributes() { + return { + content: this.model.getNotificationText(), + }; + }, + showIdentity() { + this.$el.trigger('show-identity', this.conversation); + }, + }); + + Whisper.VerifiedChangeView = Whisper.View.extend({ + tagName: 'li', + className: 'verified-change advisory', + templateName: 'verified-change', + id() { + return this.model.id; + }, + initialize() { + this.conversation = this.model.getModelForVerifiedChange(); + this.listenTo(this.conversation, 'change', this.render); + this.listenTo(this.model, 'unload', this.remove); + }, + events: { + 'click .content': 'showIdentity', + }, + render_attributes() { + let key; + + if (this.model.get('verified')) { + if (this.model.get('local')) { + key = 'youMarkedAsVerified'; + } else { + key = 'youMarkedAsVerifiedOtherDevice'; } - }); - var NetworkErrorView = Whisper.View.extend({ - tagName: 'span', - className: 'hasRetry', - templateName: 'hasRetry', - render_attributes: function() { - var messageNotSent; + return { + icon: 'verified', + content: i18n(key, this.conversation.getTitle()), + }; + } - if (!this.model.someRecipientsFailed()) { - messageNotSent = i18n('messageNotSent'); - } + if (this.model.get('local')) { + key = 'youMarkedAsNotVerified'; + } else { + key = 'youMarkedAsNotVerifiedOtherDevice'; + } - return { - messageNotSent: messageNotSent, - resend: i18n('resend') - }; + return { + icon: 'shield', + content: i18n(key, this.conversation.getTitle()), + }; + }, + showIdentity() { + this.$el.trigger('show-identity', this.conversation); + }, + }); + + Whisper.MessageView = Whisper.View.extend({ + tagName: 'li', + templateName: 'message', + id() { + return this.model.id; + }, + initialize() { + // loadedAttachmentViews :: Promise (Array AttachmentView) | null + this.loadedAttachmentViews = null; + + this.listenTo(this.model, 'change:errors', this.onErrorsChanged); + this.listenTo(this.model, 'change:body', this.render); + this.listenTo(this.model, 'change:delivered', this.renderDelivered); + this.listenTo(this.model, 'change:read_by', this.renderRead); + this.listenTo(this.model, 'change:expirationStartTimestamp', this.renderExpiring); + this.listenTo(this.model, 'change', this.onChange); + this.listenTo(this.model, 'change:flags change:group_update', this.renderControl); + this.listenTo(this.model, 'destroy', this.onDestroy); + this.listenTo(this.model, 'unload', this.onUnload); + this.listenTo(this.model, 'expired', this.onExpired); + this.listenTo(this.model, 'pending', this.renderPending); + this.listenTo(this.model, 'done', this.renderDone); + this.timeStampView = new Whisper.ExtendedTimestampView(); + + this.contact = this.model.isIncoming() ? this.model.getContact() : null; + if (this.contact) { + this.listenTo(this.contact, 'change:color', this.updateColor); + } + }, + events: { + 'click .retry': 'retryMessage', + 'click .error-icon': 'select', + 'click .timestamp': 'select', + 'click .status': 'select', + 'click .some-failed': 'select', + 'click .error-message': 'select', + }, + retryMessage() { + const retrys = _.filter( + this.model.get('errors'), + this.model.isReplayableError.bind(this.model) + ); + _.map(retrys, 'number').forEach((number) => { + this.model.resend(number); + }); + }, + onExpired() { + this.$el.addClass('expired'); + this.$el.find('.bubble').one('webkitAnimationEnd animationend', (e) => { + if (e.target === this.$('.bubble')[0]) { + this.remove(); } - }); - var SomeFailedView = Whisper.View.extend({ - tagName: 'span', - className: 'some-failed', - templateName: 'some-failed', - render_attributes: { - someFailed: i18n('someRecipientsFailed') + }); + + // Failsafe: if in the background, animation events don't fire + setTimeout(this.remove.bind(this), 1000); + }, + onUnload() { + if (this.avatarView) { + this.avatarView.remove(); + } + if (this.errorIconView) { + this.errorIconView.remove(); + } + if (this.networkErrorView) { + this.networkErrorView.remove(); + } + if (this.someFailedView) { + this.someFailedView.remove(); + } + if (this.timeStampView) { + this.timeStampView.remove(); + } + if (this.replyView) { + this.replyView.remove(); + } + + // NOTE: We have to do this in the background (`then` instead of `await`) + // as our tests rely on `onUnload` synchronously removing the view from + // the DOM. + // eslint-disable-next-line more/no-then + this.loadAttachmentViews() + .then(views => views.forEach(view => view.unload())); + + // No need to handle this one, since it listens to 'unload' itself: + // this.timerView + + this.remove(); + }, + onDestroy() { + if (this.$el.hasClass('expired')) { + return; + } + this.onUnload(); + }, + onChange() { + this.renderSent(); + this.renderQuote(); + }, + select(e) { + this.$el.trigger('select', { message: this.model }); + e.stopPropagation(); + }, + className() { + return ['entry', this.model.get('type')].join(' '); + }, + renderPending() { + this.$el.addClass('pending'); + }, + renderDone() { + this.$el.removeClass('pending'); + }, + renderSent() { + if (this.model.isOutgoing()) { + this.$el.toggleClass('sent', !!this.model.get('sent')); + } + }, + renderDelivered() { + if (this.model.get('delivered')) { this.$el.addClass('delivered'); } + }, + renderRead() { + if (!_.isEmpty(this.model.get('read_by'))) { + this.$el.addClass('read'); + } + }, + onErrorsChanged() { + if (this.model.isIncoming()) { + this.render(); + } else { + this.renderErrors(); + } + }, + renderErrors() { + const errors = this.model.get('errors'); + + + this.$('.error-icon-container').remove(); + if (this.errorIconView) { + this.errorIconView.remove(); + this.errorIconView = null; + } + if (_.size(errors) > 0) { + if (this.model.isIncoming()) { + this.$('.content').text(this.model.getDescription()).addClass('error-message'); } - }); - var TimerView = Whisper.View.extend({ - templateName: 'hourglass', - initialize: function() { - this.listenTo(this.model, 'unload', this.remove); - }, - update: function() { - if (this.timeout) { - clearTimeout(this.timeout); - this.timeout = null; - } - if (this.model.isExpired()) { - return this; - } - if (this.model.isExpiring()) { - this.render(); - var totalTime = this.model.get('expireTimer') * 1000; - var remainingTime = this.model.msTilExpire(); - var elapsed = (totalTime - remainingTime) / totalTime; - this.$('.sand').css('transform', 'translateY(' + elapsed*100 + '%)'); - this.$el.css('display', 'inline-block'); - this.timeout = setTimeout(this.update.bind(this), Math.max(totalTime / 100, 500)); - } - return this; + this.errorIconView = new ErrorIconView({ model: errors[0] }); + this.errorIconView.render().$el.appendTo(this.$('.bubble')); + } + + this.$('.meta .hasRetry').remove(); + if (this.networkErrorView) { + this.networkErrorView.remove(); + this.networkErrorView = null; + } + if (this.model.hasNetworkError()) { + this.networkErrorView = new NetworkErrorView({ model: this.model }); + this.$('.meta').prepend(this.networkErrorView.render().el); + } + + this.$('.meta .some-failed').remove(); + if (this.someFailedView) { + this.someFailedView.remove(); + this.someFailedView = null; + } + if (this.model.someRecipientsFailed()) { + this.someFailedView = new SomeFailedView(); + this.$('.meta').prepend(this.someFailedView.render().el); + } + }, + renderControl() { + if (this.model.isEndSession() || this.model.isGroupUpdate()) { + this.$el.addClass('control'); + const content = this.$('.content'); + content.text(this.model.getDescription()); + emoji_util.parse(content); + } else { + this.$el.removeClass('control'); + } + }, + renderExpiring() { + if (!this.timerView) { + this.timerView = new TimerView({ model: this.model }); + } + this.timerView.setElement(this.$('.timer')); + this.timerView.update(); + }, + getQuoteObjectUrl() { + const fromDB = this.model.quotedMessageFromDatabase; + if (fromDB && fromDB.imageUrl) { + return fromDB.imageUrl; + } + + const inMemory = this.model.quotedMessage; + if (inMemory && inMemory.imageUrl) { + return inMemory.imageUrl; + } + + const thumbnail = this.model.quoteThumbnail; + if (thumbnail && thumbnail.objectUrl) { + return thumbnail.objectUrl; + } + + return null; + }, + renderQuote() { + const quote = this.model.get('quote'); + if (!quote) { + return; + } + + const VOICE_FLAG = textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE; + const objectUrl = this.getQuoteObjectUrl(); + + + function processAttachment(attachment) { + const thumbnail = !objectUrl + ? null + : Object.assign({}, attachment.thumbnail || {}, { + objectUrl, + }); + + return Object.assign({}, attachment, { + // eslint-disable-next-line no-bitwise + isVoiceMessage: Boolean(attachment.flags & VOICE_FLAG), + thumbnail, + }); + } + + const OUR_NUMBER = textsecure.storage.user.getNumber(); + const { author } = quote; + const contact = ConversationController.get(author); + + const authorTitle = contact ? contact.getTitle() : author; + const authorProfileName = contact ? contact.getProfileName() : null; + const authorColor = contact ? contact.getColor() : 'grey'; + const isFromMe = contact ? contact.id === OUR_NUMBER : false; + const isIncoming = this.model.isIncoming(); + const onClick = () => { + const { quotedMessage } = this.model; + if (quotedMessage) { + this.model.trigger('scroll-to-message', { id: quotedMessage.id }); } - }); + }; - Whisper.ExpirationTimerUpdateView = Whisper.View.extend({ - tagName: 'li', - className: 'expirationTimerUpdate advisory', - templateName: 'expirationTimerUpdate', - id: function() { - return this.model.id; - }, - initialize: function() { - this.conversation = this.model.getExpirationTimerUpdateSource(); - this.listenTo(this.conversation, 'change', this.render); - this.listenTo(this.model, 'unload', this.remove); - }, - render_attributes: function() { - var seconds = this.model.get('expirationTimerUpdate').expireTimer; - var timerMessage; + const props = { + attachments: (quote.attachments || []).map(processAttachment), + authorColor, + authorProfileName, + authorTitle, + isFromMe, + isIncoming, + onClick: this.model.quotedMessage ? onClick : null, + text: quote.text, + }; - var timerUpdate = this.model.get('expirationTimerUpdate'); - var prettySeconds = Whisper.ExpirationTimerOptions.getName(seconds); + if (this.replyView) { + this.replyView = null; + } else if (contact) { + this.listenTo(contact, 'change:color', this.renderQuote); + } - if (timerUpdate && timerUpdate.fromSync) { - timerMessage = i18n('timerSetOnSync', prettySeconds); - } else if (this.conversation.id === textsecure.storage.user.getNumber()) { - timerMessage = i18n('youChangedTheTimer', prettySeconds); - } else { - timerMessage = i18n('theyChangedTheTimer', [ - this.conversation.getTitle(), - prettySeconds, - ]); - } - return { content: timerMessage }; - } - }); + this.replyView = new Whisper.ReactWrapperView({ + el: this.$('.quote-wrapper'), + Component: window.Signal.Components.Quote, + props, + }); + }, + isImageWithoutCaption() { + const attachments = this.model.get('attachments'); + const body = this.model.get('body'); + if (!attachments || attachments.length === 0) { + return false; + } - Whisper.KeyChangeView = Whisper.View.extend({ - tagName: 'li', - className: 'keychange advisory', - templateName: 'keychange', - id: function() { - return this.model.id; - }, - initialize: function() { - this.conversation = this.model.getModelForKeyChange(); - this.listenTo(this.conversation, 'change', this.render); - this.listenTo(this.model, 'unload', this.remove); - }, - events: { - 'click .content': 'showIdentity' - }, - render_attributes: function() { - return { - content: this.model.getNotificationText() - }; - }, - showIdentity: function() { - this.$el.trigger('show-identity', this.conversation); - } - }); + if (body && body.trim()) { + return false; + } - Whisper.VerifiedChangeView = Whisper.View.extend({ - tagName: 'li', - className: 'verified-change advisory', - templateName: 'verified-change', - id: function() { - return this.model.id; - }, - initialize: function() { - this.conversation = this.model.getModelForVerifiedChange(); - this.listenTo(this.conversation, 'change', this.render); - this.listenTo(this.model, 'unload', this.remove); - }, - events: { - 'click .content': 'showIdentity' - }, - render_attributes: function() { - var key; + const first = attachments[0]; + if (Signal.Types.MIME.isImage(first.contentType)) { + return true; + } - if (this.model.get('verified')) { - if (this.model.get('local')) { - key = 'youMarkedAsVerified'; - } else { - key = 'youMarkedAsVerifiedOtherDevice'; - } - return { - icon: 'verified', - content: i18n(key, this.conversation.getTitle()) - }; - } + return false; + }, + render() { + const contact = this.model.isIncoming() ? this.model.getContact() : null; + this.$el.html(Mustache.render(_.result(this, 'template', ''), { + message: this.model.get('body'), + timestamp: this.model.get('sent_at'), + sender: (contact && contact.getTitle()) || '', + avatar: (contact && contact.getAvatar()), + profileName: (contact && contact.getProfileName()), + innerBubbleClasses: this.isImageWithoutCaption() ? '' : 'with-tail', + }, this.render_partials())); + this.timeStampView.setElement(this.$('.timestamp')); + this.timeStampView.update(); - if (this.model.get('local')) { - key = 'youMarkedAsNotVerified'; - } else { - key = 'youMarkedAsNotVerifiedOtherDevice'; - } + this.renderControl(); - return { - icon: 'shield', - content: i18n(key, this.conversation.getTitle()) - }; - }, - showIdentity: function() { - this.$el.trigger('show-identity', this.conversation); - } - }); + const body = this.$('.body'); - Whisper.MessageView = Whisper.View.extend({ - tagName: 'li', - templateName: 'message', - id: function() { - return this.model.id; - }, - initialize: function() { - // loadedAttachmentViews :: Promise (Array AttachmentView) | null - this.loadedAttachmentViews = null; + emoji_util.parse(body); - this.listenTo(this.model, 'change:errors', this.onErrorsChanged); - this.listenTo(this.model, 'change:body', this.render); - this.listenTo(this.model, 'change:delivered', this.renderDelivered); - this.listenTo(this.model, 'change:read_by', this.renderRead); - this.listenTo(this.model, 'change:expirationStartTimestamp', this.renderExpiring); - this.listenTo(this.model, 'change', this.renderSent); - this.listenTo(this.model, 'change:flags change:group_update', this.renderControl); - this.listenTo(this.model, 'destroy', this.onDestroy); - this.listenTo(this.model, 'unload', this.onUnload); - this.listenTo(this.model, 'expired', this.onExpired); - this.listenTo(this.model, 'pending', this.renderPending); - this.listenTo(this.model, 'done', this.renderDone); - this.timeStampView = new Whisper.ExtendedTimestampView(); + if (body.length > 0) { + const escapedBody = body.html(); + body.html(Signal.HTML.render(escapedBody)); + } - this.contact = this.model.isIncoming() ? this.model.getContact() : null; - if (this.contact) { - this.listenTo(this.contact, 'change:color', this.updateColor); - } - }, - events: { - 'click .retry': 'retryMessage', - 'click .error-icon': 'select', - 'click .timestamp': 'select', - 'click .status': 'select', - 'click .some-failed': 'select', - 'click .error-message': 'select' - }, - retryMessage: function() { - var retrys = _.filter(this.model.get('errors'), - this.model.isReplayableError.bind(this.model)); - _.map(retrys, 'number').forEach(function(number) { - this.model.resend(number); - }.bind(this)); - }, - onExpired: function() { - this.$el.addClass('expired'); - this.$el.find('.bubble').one('webkitAnimationEnd animationend', function(e) { - if (e.target === this.$('.bubble')[0]) { - this.remove(); - } - }.bind(this)); + this.renderSent(); + this.renderDelivered(); + this.renderRead(); + this.renderErrors(); + this.renderExpiring(); + this.renderQuote(); - // Failsafe: if in the background, animation events don't fire - setTimeout(this.remove.bind(this), 1000); - }, - /* jshint ignore:start */ - onUnload: function() { - if (this.avatarView) { - this.avatarView.remove(); - } - if (this.errorIconView) { - this.errorIconView.remove(); - } - if (this.networkErrorView) { - this.networkErrorView.remove(); - } - if (this.someFailedView) { - this.someFailedView.remove(); - } - if (this.timeStampView) { - this.timeStampView.remove(); - } + // NOTE: We have to do this in the background (`then` instead of `await`) + // as our code / Backbone seems to rely on `render` synchronously returning + // `this` instead of `Promise MessageView` (this): + // eslint-disable-next-line more/no-then + this.loadAttachmentViews().then(views => this.renderAttachmentViews(views)); - // NOTE: We have to do this in the background (`then` instead of `await`) - // as our tests rely on `onUnload` synchronously removing the view from - // the DOM. - // eslint-disable-next-line more/no-then - this.loadAttachmentViews() - .then(views => views.forEach(view => view.unload())); + return this; + }, + updateColor() { + const bubble = this.$('.bubble'); - // No need to handle this one, since it listens to 'unload' itself: - // this.timerView - - this.remove(); - }, - /* jshint ignore:end */ - onDestroy: function() { - if (this.$el.hasClass('expired')) { - return; - } - this.onUnload(); - }, - select: function(e) { - this.$el.trigger('select', {message: this.model}); - e.stopPropagation(); - }, - className: function() { - return ['entry', this.model.get('type')].join(' '); - }, - renderPending: function() { - this.$el.addClass('pending'); - }, - renderDone: function() { - this.$el.removeClass('pending'); - }, - renderSent: function() { - if (this.model.isOutgoing()) { - this.$el.toggleClass('sent', !!this.model.get('sent')); - } - }, - renderDelivered: function() { - if (this.model.get('delivered')) { this.$el.addClass('delivered'); } - }, - renderRead: function() { - if (!_.isEmpty(this.model.get('read_by'))) { - this.$el.addClass('read'); - } - }, - onErrorsChanged: function() { - if (this.model.isIncoming()) { - this.render(); - } else { - this.renderErrors(); - } - }, - renderErrors: function() { - var errors = this.model.get('errors'); - - - this.$('.error-icon-container').remove(); - if (this.errorIconView) { - this.errorIconView.remove(); - this.errorIconView = null; - } - if (_.size(errors) > 0) { - if (this.model.isIncoming()) { - this.$('.content').text(this.model.getDescription()).addClass('error-message'); - } - this.errorIconView = new ErrorIconView({ model: errors[0] }); - this.errorIconView.render().$el.appendTo(this.$('.bubble')); - } - - this.$('.meta .hasRetry').remove(); - if (this.networkErrorView) { - this.networkErrorView.remove(); - this.networkErrorView = null; - } - if (this.model.hasNetworkError()) { - this.networkErrorView = new NetworkErrorView({model: this.model}); - this.$('.meta').prepend(this.networkErrorView.render().el); - } - - this.$('.meta .some-failed').remove(); - if (this.someFailedView) { - this.someFailedView.remove(); - this.someFailedView = null; - } - if (this.model.someRecipientsFailed()) { - this.someFailedView = new SomeFailedView(); - this.$('.meta').prepend(this.someFailedView.render().el); - } - }, - renderControl: function() { - if (this.model.isEndSession() || this.model.isGroupUpdate()) { - this.$el.addClass('control'); - var content = this.$('.content'); - content.text(this.model.getDescription()); - emoji_util.parse(content); - } else { - this.$el.removeClass('control'); - } - }, - renderExpiring: function() { - if (!this.timerView) { - this.timerView = new TimerView({ model: this.model }); - } - this.timerView.setElement(this.$('.timer')); - this.timerView.update(); - }, - render: function() { - var contact = this.model.isIncoming() ? this.model.getContact() : null; - this.$el.html( - Mustache.render(_.result(this, 'template', ''), { - message: this.model.get('body'), - timestamp: this.model.get('sent_at'), - sender: (contact && contact.getTitle()) || '', - avatar: (contact && contact.getAvatar()), - profileName: (contact && contact.getProfileName()), - }, this.render_partials()) - ); - this.timeStampView.setElement(this.$('.timestamp')); - this.timeStampView.update(); - - this.renderControl(); - - var body = this.$('.body'); - - emoji_util.parse(body); - - if (body.length > 0) { - const escapedBody = body.html(); - body.html(HTML.render(escapedBody)); - } - - this.renderSent(); - this.renderDelivered(); - this.renderRead(); - this.renderErrors(); - this.renderExpiring(); - - - // NOTE: We have to do this in the background (`then` instead of `await`) - // as our code / Backbone seems to rely on `render` synchronously returning - // `this` instead of `Promise MessageView` (this): - // eslint-disable-next-line more/no-then - this.loadAttachmentViews().then(views => this.renderAttachmentViews(views)); - - return this; - }, - updateColor: function() { - var bubble = this.$('.bubble'); - - // this.contact is known to be non-null if we're registered for color changes - var color = this.contact.getColor(); - if (color) { - bubble.removeClass(Whisper.Conversation.COLORS); - bubble.addClass(color); - } - this.avatarView = new (Whisper.View.extend({ - templateName: 'avatar', - render_attributes: { avatar: this.contact.getAvatar() } - }))(); - this.$('.avatar').replaceWith(this.avatarView.render().$('.avatar')); - }, - /* eslint-enable */ - /* jshint ignore:start */ + // this.contact is known to be non-null if we're registered for color changes + const color = this.contact.getColor(); + if (color) { + bubble.removeClass(Whisper.Conversation.COLORS); + bubble.addClass(color); + } + this.avatarView = new (Whisper.View.extend({ + templateName: 'avatar', + render_attributes: { avatar: this.contact.getAvatar() }, + }))(); + this.$('.avatar').replaceWith(this.avatarView.render().$('.avatar')); + }, loadAttachmentViews() { if (this.loadedAttachmentViews !== null) { return this.loadedAttachmentViews; @@ -464,7 +570,5 @@ view.setElement(view.el); this.trigger('afterChangeHeight'); }, - /* jshint ignore:end */ - /* eslint-disable */ - }); -})(); + }); +}()); diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index 582084ffd159..78148357dd91 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -1,965 +1,1070 @@ -/* - * vim: ts=4:sw=4:expandtab - */ +/* global window: false */ +/* global textsecure: false */ +/* global TextSecureServer: false */ +/* global libsignal: false */ +/* global WebSocketResource: false */ +/* global WebSocket: false */ +/* global Event: false */ +/* global dcodeIO: false */ +/* global _: false */ +/* global ContactBuffer: false */ +/* global GroupBuffer: false */ -function MessageReceiver(url, username, password, signalingKey, options) { - options = options || {}; +/* eslint-disable more/no-then */ - this.count = 0; +function MessageReceiver(url, username, password, signalingKey, options = {}) { + this.count = 0; - this.url = url; - this.signalingKey = signalingKey; - this.username = username; - this.password = password; - this.server = new TextSecureServer(url, username, password); + this.url = url; + this.signalingKey = signalingKey; + this.username = username; + this.password = password; + this.server = new TextSecureServer(url, username, password); - var address = libsignal.SignalProtocolAddress.fromString(username); - this.number = address.getName(); - this.deviceId = address.getDeviceId(); + const address = libsignal.SignalProtocolAddress.fromString(username); + this.number = address.getName(); + this.deviceId = address.getDeviceId(); - this.pending = Promise.resolve(); + this.pending = Promise.resolve(); - if (options.retryCached) { - this.pending = this.queueAllCached(); - } + if (options.retryCached) { + this.pending = this.queueAllCached(); + } } MessageReceiver.prototype = new textsecure.EventTarget(); MessageReceiver.prototype.extend({ - constructor: MessageReceiver, - connect: function() { - if (this.calledClose) { - return; - } - - this.hasConnected = true; - - if (this.socket && this.socket.readyState !== WebSocket.CLOSED) { - this.socket.close(); - this.wsr.close(); - } - // initialize the socket and start listening for messages - this.socket = this.server.getMessageSocket(); - this.socket.onclose = this.onclose.bind(this); - this.socket.onerror = this.onerror.bind(this); - this.socket.onopen = this.onopen.bind(this); - this.wsr = new WebSocketResource(this.socket, { - handleRequest: this.handleRequest.bind(this), - keepalive: { - path: '/v1/keepalive', - disconnect: true - } - }); - - // Because sometimes the socket doesn't properly emit its close event - this._onClose = this.onclose.bind(this) - this.wsr.addEventListener('close', this._onClose); - - // Ensures that an immediate 'empty' event from the websocket will fire only after - // all cached envelopes are processed. - this.incoming = [this.pending]; - }, - shutdown: function() { - if (this.socket) { - this.socket.onclose = null; - this.socket.onerror = null; - this.socket.onopen = null; - this.socket = null; - } - - if (this.wsr) { - this.wsr.removeEventListener('close', this._onClose); - this.wsr = null; - } - }, - close: function() { - console.log('MessageReceiver.close()'); - this.calledClose = true; - - // Our WebSocketResource instance will close the socket and emit a 'close' event - // if the socket doesn't emit one quickly enough. - if (this.wsr) { - this.wsr.close(3000, 'called close'); - } - - return this.drain(); - }, - onopen: function() { - console.log('websocket open'); - }, - onerror: function(error) { - console.log('websocket error'); - }, - dispatchAndWait: function(event) { - return Promise.all(this.dispatchEvent(event)); - }, - onclose: function(ev) { - console.log( - 'websocket closed', - ev.code, - ev.reason || '', - 'calledClose:', - this.calledClose - ); - - this.shutdown(); - - if (this.calledClose) { - return; - } - if (ev.code === 3000) { - return; - } - if (ev.code === 3001) { - this.onEmpty(); - } - // possible 403 or network issue. Make an request to confirm - return this.server.getDevices(this.number) - .then(this.connect.bind(this)) // No HTTP error? Reconnect - .catch(function(e) { - var ev = new Event('error'); - ev.error = e; - return this.dispatchAndWait(ev); - }.bind(this)); - }, - handleRequest: function(request) { - this.incoming = this.incoming || []; - // We do the message decryption here, instead of in the ordered pending queue, - // to avoid exposing the time it took us to process messages through the time-to-ack. - - // TODO: handle different types of requests. - if (request.path !== '/api/v1/message') { - console.log('got request', request.verb, request.path); - request.respond(200, 'OK'); - - if (request.verb === 'PUT' && request.path === '/api/v1/queue/empty') { - this.onEmpty(); - } - return; - } - - this.incoming.push(textsecure.crypto.decryptWebsocketMessage(request.body, this.signalingKey).then(function(plaintext) { - var envelope = textsecure.protobuf.Envelope.decode(plaintext); - // After this point, decoding errors are not the server's - // fault, and we should handle them gracefully and tell the - // user they received an invalid message - - if (this.isBlocked(envelope.source)) { - return request.respond(200, 'OK'); - } - - return this.addToCache(envelope, plaintext).then(function() { - request.respond(200, 'OK'); - this.queueEnvelope(envelope); - }.bind(this), function(error) { - console.log( - 'handleRequest error trying to add message to cache:', - error && error.stack ? error.stack : error - ); - }); - }.bind(this)).catch(function(e) { - request.respond(500, 'Bad encrypted websocket message'); - console.log("Error handling incoming message:", e && e.stack ? e.stack : e); - var ev = new Event('error'); - ev.error = e; - return this.dispatchAndWait(ev); - }.bind(this))); - }, - addToQueue: function(task) { - var count = this.count += 1; - var current = this.pending = this.pending.then(task, task); - - var cleanup = function() { - this.updateProgress(count); - // We want to clear out the promise chain whenever possible because it could - // lead to large memory usage over time: - // https://github.com/nodejs/node/issues/6673#issuecomment-244331609 - if (this.pending === current) { - this.pending = Promise.resolve(); - } - }.bind(this); - - current.then(cleanup, cleanup); - - return current; - }, - onEmpty: function() { - var incoming = this.incoming; - this.incoming = []; - - var dispatchEmpty = function() { - console.log('MessageReceiver: emitting \'empty\' event'); - var ev = new Event('empty'); - return this.dispatchAndWait(ev); - }.bind(this); - - var queueDispatch = function() { - // resetting count to zero so everything queued after this starts over again - this.count = 0; - - this.addToQueue(dispatchEmpty); - }.bind(this); - - // We first wait for all recently-received messages (this.incoming) to be queued, - // then we add a task to emit the 'empty' event to the queue, so all message - // processing is complete by the time it runs. - Promise.all(incoming).then(queueDispatch, queueDispatch); - }, - drain: function() { - var incoming = this.incoming; - this.incoming = []; - - var queueDispatch = function() { - return this.addToQueue(function() { - console.log('drained'); - }); - }.bind(this); - - // This promise will resolve when there are no more messages to be processed. - return Promise.all(incoming).then(queueDispatch, queueDispatch); - }, - updateProgress: function(count) { - // count by 10s - if (count % 10 !== 0) { - return; - } - var ev = new Event('progress'); - ev.count = count; - this.dispatchEvent(ev); - }, - queueAllCached: function() { - return this.getAllFromCache().then(function(items) { - for (var i = 0, max = items.length; i < max; i += 1) { - this.queueCached(items[i]); - } - }.bind(this)); - }, - queueCached: function(item) { - try { - var envelopePlaintext = item.envelope; - - // Up until 0.42.6 we stored envelope and decrypted as strings in IndexedDB, - // so we need to be ready for them. - if (typeof envelopePlaintext === 'string') { - envelopePlaintext = this.stringToArrayBuffer(envelopePlaintext); - } - var envelope = textsecure.protobuf.Envelope.decode(envelopePlaintext); - - var decrypted = item.decrypted; - if (decrypted) { - var payloadPlaintext = decrypted; - if (typeof payloadPlaintext === 'string') { - payloadPlaintext = this.stringToArrayBuffer(payloadPlaintext); - } - this.queueDecryptedEnvelope(envelope, payloadPlaintext); - } else { - this.queueEnvelope(envelope); - } - } - catch (error) { - console.log('queueCached error handling item', item.id); - } - }, - getEnvelopeId: function(envelope) { - return envelope.source + '.' + envelope.sourceDevice + ' ' + envelope.timestamp.toNumber(); - }, - stringToArrayBuffer: function(string) { - return new dcodeIO.ByteBuffer.wrap(string, 'binary').toArrayBuffer(); - }, - getAllFromCache: function() { - console.log('getAllFromCache'); - return textsecure.storage.unprocessed.getAll().then(function(items) { - console.log('getAllFromCache loaded', items.length, 'saved envelopes'); - - return Promise.all(_.map(items, function(item) { - var attempts = 1 + (item.attempts || 0); - if (attempts >= 5) { - console.log('getAllFromCache final attempt for envelope', item.id); - return textsecure.storage.unprocessed.remove(item.id); - } else { - return textsecure.storage.unprocessed.update(item.id, {attempts: attempts}); - } - }.bind(this))).then(function() { - return items; - }, function(error) { - console.log( - 'getAllFromCache error updating items after load:', - error && error.stack ? error.stack : error - ); - return items; - }); - }.bind(this)); - }, - addToCache: function(envelope, plaintext) { - var id = this.getEnvelopeId(envelope); - var data = { - id: id, - envelope: plaintext, - timestamp: Date.now(), - attempts: 1 - }; - return textsecure.storage.unprocessed.add(data); - }, - updateCache: function(envelope, plaintext) { - var id = this.getEnvelopeId(envelope); - var data = { - decrypted: plaintext - }; - return textsecure.storage.unprocessed.update(id, data); - }, - removeFromCache: function(envelope) { - var id = this.getEnvelopeId(envelope); - return textsecure.storage.unprocessed.remove(id); - }, - queueDecryptedEnvelope: function(envelope, plaintext) { - var id = this.getEnvelopeId(envelope); - console.log('queueing decrypted envelope', id); - - var task = this.handleDecryptedEnvelope.bind(this, envelope, plaintext); - var taskWithTimeout = textsecure.createTaskWithTimeout(task, 'queueEncryptedEnvelope ' + id); - var promise = this.addToQueue(taskWithTimeout); - - return promise.catch(function(error) { - console.log('queueDecryptedEnvelope error handling envelope', id, ':', error && error.stack ? error.stack : error); - }); - }, - queueEnvelope: function(envelope) { - var id = this.getEnvelopeId(envelope); - console.log('queueing envelope', id); - - var task = this.handleEnvelope.bind(this, envelope); - var taskWithTimeout = textsecure.createTaskWithTimeout(task, 'queueEnvelope ' + id); - var promise = this.addToQueue(taskWithTimeout); - - return promise.catch(function(error) { - console.log('queueEnvelope error handling envelope', id, ':', error && error.stack ? error.stack : error); - }); - }, - // Same as handleEnvelope, just without the decryption step. Necessary for handling - // messages which were successfully decrypted, but application logic didn't finish - // processing. - handleDecryptedEnvelope: function(envelope, plaintext) { - // No decryption is required for delivery receipts, so the decrypted field of - // the Unprocessed model will never be set - - if (envelope.content) { - return this.innerHandleContentMessage(envelope, plaintext); - } else if (envelope.legacyMessage) { - return this.innerHandleLegacyMessage(envelope, plaintext); - } else { - this.removeFromCache(envelope); - throw new Error('Received message with no content and no legacyMessage'); - } - }, - handleEnvelope: function(envelope) { - if (envelope.type === textsecure.protobuf.Envelope.Type.RECEIPT) { - return this.onDeliveryReceipt(envelope); - } - - if (envelope.content) { - return this.handleContentMessage(envelope); - } else if (envelope.legacyMessage) { - return this.handleLegacyMessage(envelope); - } else { - this.removeFromCache(envelope); - throw new Error('Received message with no content and no legacyMessage'); - } - }, - getStatus: function() { - if (this.socket) { - return this.socket.readyState; - } else if (this.hasConnected) { - return WebSocket.CLOSED; - } else { - return -1; - } - }, - onDeliveryReceipt: function (envelope) { - return new Promise(function(resolve, reject) { - var ev = new Event('delivery'); - ev.confirm = this.removeFromCache.bind(this, envelope); - ev.deliveryReceipt = { - timestamp : envelope.timestamp.toNumber(), - source : envelope.source, - sourceDevice : envelope.sourceDevice - }; - this.dispatchAndWait(ev).then(resolve, reject); - }.bind(this)); - }, - unpad: function(paddedPlaintext) { - paddedPlaintext = new Uint8Array(paddedPlaintext); - var plaintext; - for (var i = paddedPlaintext.length - 1; i >= 0; i--) { - if (paddedPlaintext[i] == 0x80) { - plaintext = new Uint8Array(i); - plaintext.set(paddedPlaintext.subarray(0, i)); - plaintext = plaintext.buffer; - break; - } else if (paddedPlaintext[i] !== 0x00) { - throw new Error('Invalid padding'); - } - } - - return plaintext; - }, - decrypt: function(envelope, ciphertext) { - var promise; - var address = new libsignal.SignalProtocolAddress(envelope.source, envelope.sourceDevice); - - var ourNumber = textsecure.storage.user.getNumber(); - var number = address.toString().split('.')[0]; - var options = {}; - - // No limit on message keys if we're communicating with our other devices - if (ourNumber === number) { - options.messageKeysLimit = false; - } - - var sessionCipher = new libsignal.SessionCipher(textsecure.storage.protocol, address, options); - switch(envelope.type) { - case textsecure.protobuf.Envelope.Type.CIPHERTEXT: - console.log('message from', this.getEnvelopeId(envelope)); - promise = sessionCipher.decryptWhisperMessage(ciphertext).then(this.unpad); - break; - case textsecure.protobuf.Envelope.Type.PREKEY_BUNDLE: - console.log('prekey message from', this.getEnvelopeId(envelope)); - promise = this.decryptPreKeyWhisperMessage(ciphertext, sessionCipher, address); - break; - default: - promise = Promise.reject(new Error("Unknown message type")); - } - return promise.then(function(plaintext) { - return this.updateCache(envelope, plaintext).then(function() { - return plaintext; - }, function(error) { - console.log( - 'decrypt failed to save decrypted message contents to cache:', - error && error.stack ? error.stack : error - ); - return plaintext; - }); - }.bind(this)).catch(function(error) { - if (error.message === 'Unknown identity key') { - // create an error that the UI will pick up and ask the - // user if they want to re-negotiate - var buffer = dcodeIO.ByteBuffer.wrap(ciphertext); - error = new textsecure.IncomingIdentityKeyError( - address.toString(), - buffer.toArrayBuffer(), - error.identityKey - ); - } - var ev = new Event('error'); - ev.error = error; - ev.proto = envelope; - ev.confirm = this.removeFromCache.bind(this, envelope); - - var returnError = function() { - return Promise.reject(error); - }; - return this.dispatchAndWait(ev).then(returnError, returnError); - }.bind(this)); - }, - decryptPreKeyWhisperMessage: function(ciphertext, sessionCipher, address) { - return sessionCipher.decryptPreKeyWhisperMessage(ciphertext).then(this.unpad).catch(function(e) { - if (e.message === 'Unknown identity key') { - // create an error that the UI will pick up and ask the - // user if they want to re-negotiate - var buffer = dcodeIO.ByteBuffer.wrap(ciphertext); - throw new textsecure.IncomingIdentityKeyError( - address.toString(), - buffer.toArrayBuffer(), - e.identityKey - ); - } - throw e; - }); - }, - handleSentMessage: function(envelope, destination, timestamp, message, expirationStartTimestamp) { - var p = Promise.resolve(); - if ((message.flags & textsecure.protobuf.DataMessage.Flags.END_SESSION) == - textsecure.protobuf.DataMessage.Flags.END_SESSION ) { - p = this.handleEndSession(destination); - } - return p.then(function() { - return this.processDecrypted(envelope, message, this.number).then(function(message) { - var ev = new Event('sent'); - ev.confirm = this.removeFromCache.bind(this, envelope); - ev.data = { - destination : destination, - timestamp : timestamp.toNumber(), - device : envelope.sourceDevice, - message : message - }; - if (expirationStartTimestamp) { - ev.data.expirationStartTimestamp = expirationStartTimestamp.toNumber(); - } - return this.dispatchAndWait(ev); - }.bind(this)); - }.bind(this)); - }, - handleDataMessage: function(envelope, message) { - console.log('data message from', this.getEnvelopeId(envelope)); - var p = Promise.resolve(); - if ((message.flags & textsecure.protobuf.DataMessage.Flags.END_SESSION) == - textsecure.protobuf.DataMessage.Flags.END_SESSION ) { - p = this.handleEndSession(envelope.source); - } - return p.then(function() { - return this.processDecrypted(envelope, message, envelope.source).then(function(message) { - var ev = new Event('message'); - ev.confirm = this.removeFromCache.bind(this, envelope); - ev.data = { - source : envelope.source, - sourceDevice : envelope.sourceDevice, - timestamp : envelope.timestamp.toNumber(), - receivedAt : envelope.receivedAt, - message : message - }; - return this.dispatchAndWait(ev); - }.bind(this)); - }.bind(this)); - }, - handleLegacyMessage: function (envelope) { - return this.decrypt(envelope, envelope.legacyMessage).then(function(plaintext) { - return this.innerHandleLegacyMessage(envelope, plaintext); - }.bind(this)); - }, - innerHandleLegacyMessage: function (envelope, plaintext) { - var message = textsecure.protobuf.DataMessage.decode(plaintext); - return this.handleDataMessage(envelope, message); - }, - handleContentMessage: function (envelope) { - return this.decrypt(envelope, envelope.content).then(function(plaintext) { - return this.innerHandleContentMessage(envelope, plaintext); - }.bind(this)); - }, - innerHandleContentMessage: function(envelope, plaintext) { - var content = textsecure.protobuf.Content.decode(plaintext); - if (content.syncMessage) { - return this.handleSyncMessage(envelope, content.syncMessage); - } else if (content.dataMessage) { - return this.handleDataMessage(envelope, content.dataMessage); - } else if (content.nullMessage) { - return this.handleNullMessage(envelope, content.nullMessage); - } else if (content.callMessage) { - return this.handleCallMessage(envelope, content.callMessage); - } else if (content.receiptMessage) { - return this.handleReceiptMessage(envelope, content.receiptMessage); - } else { - this.removeFromCache(envelope); - throw new Error('Unsupported content message'); - } - }, - handleCallMessage: function(envelope, nullMessage) { - console.log('call message from', this.getEnvelopeId(envelope)); - this.removeFromCache(envelope); - }, - handleReceiptMessage: function(envelope, receiptMessage) { - var results = []; - if (receiptMessage.type === textsecure.protobuf.ReceiptMessage.Type.DELIVERY) { - for (var i = 0; i < receiptMessage.timestamp.length; ++i) { - var ev = new Event('delivery'); - ev.confirm = this.removeFromCache.bind(this, envelope); - ev.deliveryReceipt = { - timestamp : receiptMessage.timestamp[i].toNumber(), - source : envelope.source, - sourceDevice : envelope.sourceDevice - }; - results.push(this.dispatchAndWait(ev)); - } - } else if (receiptMessage.type === textsecure.protobuf.ReceiptMessage.Type.READ) { - for (var i = 0; i < receiptMessage.timestamp.length; ++i) { - var ev = new Event('read'); - ev.confirm = this.removeFromCache.bind(this, envelope); - ev.timestamp = envelope.timestamp.toNumber(); - ev.read = { - timestamp : receiptMessage.timestamp[i].toNumber(), - reader : envelope.source - } - results.push(this.dispatchAndWait(ev)); - } - } - return Promise.all(results); - }, - handleNullMessage: function(envelope, nullMessage) { - console.log('null message from', this.getEnvelopeId(envelope)); - this.removeFromCache(envelope); - }, - handleSyncMessage: function(envelope, syncMessage) { - if (envelope.source !== this.number) { - throw new Error('Received sync message from another number'); - } - if (envelope.sourceDevice == this.deviceId) { - throw new Error('Received sync message from our own device'); - } - if (syncMessage.sent) { - var sentMessage = syncMessage.sent; - var to = sentMessage.message.group - ? 'group(' + sentMessage.message.group.id.toBinary() + ')' - : sentMessage.destination; - - console.log('sent message to', - to, - sentMessage.timestamp.toNumber(), - 'from', - this.getEnvelopeId(envelope) - ); - return this.handleSentMessage( - envelope, - sentMessage.destination, - sentMessage.timestamp, - sentMessage.message, - sentMessage.expirationStartTimestamp - ); - } else if (syncMessage.contacts) { - return this.handleContacts(envelope, syncMessage.contacts); - } else if (syncMessage.groups) { - return this.handleGroups(envelope, syncMessage.groups); - } else if (syncMessage.blocked) { - return this.handleBlocked(envelope, syncMessage.blocked); - } else if (syncMessage.request) { - console.log('Got SyncMessage Request'); - return this.removeFromCache(envelope); - } else if (syncMessage.read && syncMessage.read.length) { - console.log('read messages from', this.getEnvelopeId(envelope)); - return this.handleRead(envelope, syncMessage.read); - } else if (syncMessage.verified) { - return this.handleVerified(envelope, syncMessage.verified); - } else if (syncMessage.configuration) { - return this.handleConfiguration(envelope, syncMessage.configuration); - } else { - throw new Error('Got empty SyncMessage'); - } - }, - handleConfiguration: function(envelope, configuration) { - var ev = new Event('configuration'); - ev.confirm = this.removeFromCache.bind(this, envelope); - ev.configuration = { - readReceipts: configuration.readReceipts - }; - return this.dispatchAndWait(ev); - }, - handleVerified: function(envelope, verified) { - var ev = new Event('verified'); - ev.confirm = this.removeFromCache.bind(this, envelope); - ev.verified = { - state: verified.state, - destination: verified.destination, - identityKey: verified.identityKey.toArrayBuffer() - }; - return this.dispatchAndWait(ev); - }, - handleRead: function(envelope, read) { - var results = []; - for (var i = 0; i < read.length; ++i) { - var ev = new Event('readSync'); - ev.confirm = this.removeFromCache.bind(this, envelope); - ev.timestamp = envelope.timestamp.toNumber(); - ev.read = { - timestamp : read[i].timestamp.toNumber(), - sender : read[i].sender - } - results.push(this.dispatchAndWait(ev)); - } - return Promise.all(results); - }, - handleContacts: function(envelope, contacts) { - console.log('contact sync'); - var attachmentPointer = contacts.blob; - return this.handleAttachment(attachmentPointer).then(function() { - var results = []; - var contactBuffer = new ContactBuffer(attachmentPointer.data); - var contactDetails = contactBuffer.next(); - while (contactDetails !== undefined) { - var ev = new Event('contact'); - ev.confirm = this.removeFromCache.bind(this, envelope); - ev.contactDetails = contactDetails; - results.push(this.dispatchAndWait(ev)); - - contactDetails = contactBuffer.next(); - } - - var ev = new Event('contactsync'); - ev.confirm = this.removeFromCache.bind(this, envelope); - results.push(this.dispatchAndWait(ev)); - - return Promise.all(results); - }.bind(this)); - }, - handleGroups: function(envelope, groups) { - console.log('group sync'); - var attachmentPointer = groups.blob; - return this.handleAttachment(attachmentPointer).then(function() { - var groupBuffer = new GroupBuffer(attachmentPointer.data); - var groupDetails = groupBuffer.next(); - var promises = []; - while (groupDetails !== undefined) { - var promise = (function(groupDetails) { - groupDetails.id = groupDetails.id.toBinary(); - if (groupDetails.active) { - return textsecure.storage.groups.getGroup(groupDetails.id). - then(function(existingGroup) { - if (existingGroup === undefined) { - return textsecure.storage.groups.createNewGroup( - groupDetails.members, groupDetails.id - ); - } else { - return textsecure.storage.groups.updateNumbers( - groupDetails.id, groupDetails.members - ); - } - }).then(function() { return groupDetails }); - } else { - return Promise.resolve(groupDetails); - } - })(groupDetails).then(function(groupDetails) { - var ev = new Event('group'); - ev.confirm = this.removeFromCache.bind(this, envelope); - ev.groupDetails = groupDetails; - return this.dispatchAndWait(ev); - }.bind(this)).catch(function(e) { - console.log('error processing group', e); - }); - groupDetails = groupBuffer.next(); - promises.push(promise); - } - - Promise.all(promises).then(function() { - var ev = new Event('groupsync'); - ev.confirm = this.removeFromCache.bind(this, envelope); - return this.dispatchAndWait(ev); - }.bind(this)); - }.bind(this)); - }, - handleBlocked: function(envelope, blocked) { - console.log('Setting these numbers as blocked:', blocked.numbers); - textsecure.storage.put('blocked', blocked.numbers); - }, - isBlocked: function(number) { - return textsecure.storage.get('blocked', []).indexOf(number) >= 0; - }, - handleAttachment: function(attachment) { - attachment.id = attachment.id.toString(); - attachment.key = attachment.key.toArrayBuffer(); - if (attachment.digest) { - attachment.digest = attachment.digest.toArrayBuffer(); - } - function decryptAttachment(encrypted) { - return textsecure.crypto.decryptAttachment( - encrypted, - attachment.key, - attachment.digest - ); - } - - function updateAttachment(data) { - attachment.data = data; - } - - return this.server.getAttachment(attachment.id) - .then(decryptAttachment) - .then(updateAttachment); - }, - validateRetryContentMessage: function(content) { - // Today this is only called for incoming identity key errors. So it can't be a sync message. - if (content.syncMessage) { - return false; - } - - // We want at least one field set, but not more than one - var count = 0; - count += content.dataMessage ? 1 : 0; - count += content.callMessage ? 1 : 0; - count += content.nullMessage ? 1 : 0; - if (count !== 1) { - return false; - } - - // It's most likely that dataMessage will be populated, so we look at it in detail - var data = content.dataMessage; - if (data && !data.attachments.length && !data.body && !data.expireTimer && !data.flags && !data.group) { - return false; - } - - return true; - }, - tryMessageAgain: function(from, ciphertext, message) { - var address = libsignal.SignalProtocolAddress.fromString(from); - var sentAt = message.sent_at || Date.now(); - var receivedAt = message.received_at || Date.now(); - - var ourNumber = textsecure.storage.user.getNumber(); - var number = address.getName(); - var device = address.getDeviceId(); - var options = {}; - - // No limit on message keys if we're communicating with our other devices - if (ourNumber === number) { - options.messageKeysLimit = false; - } - - var sessionCipher = new libsignal.SessionCipher(textsecure.storage.protocol, address, options); - console.log('retrying prekey whisper message'); - return this.decryptPreKeyWhisperMessage(ciphertext, sessionCipher, address).then(function(plaintext) { - var envelope = { - source: number, - sourceDevice: device, - receivedAt: receivedAt, - timestamp: { - toNumber: function() { - return sentAt; - } - } - }; - - // Before June, all incoming messages were still DataMessage: - // - iOS: Michael Kirk says that they were sending Legacy messages until June - // - Desktop: https://github.com/signalapp/Signal-Desktop/commit/e8548879db405d9bcd78b82a456ad8d655592c0f - // - Android: https://github.com/signalapp/libsignal-service-java/commit/61a75d023fba950ff9b4c75a249d1a3408e12958 - // - // var d = new Date('2017-06-01T07:00:00.000Z'); - // d.getTime(); - var startOfJune = 1496300400000; - if (sentAt < startOfJune) { - return this.innerHandleLegacyMessage(envelope, plaintext); - } - - // This is ugly. But we don't know what kind of proto we need to decode... - try { - // Simply decoding as a Content message may throw - var content = textsecure.protobuf.Content.decode(plaintext); - - // But it might also result in an invalid object, so we try to detect that - if (this.validateRetryContentMessage(content)) { - return this.innerHandleContentMessage(envelope, plaintext); - } - } catch(e) { - return this.innerHandleLegacyMessage(envelope, plaintext); - } - - return this.innerHandleLegacyMessage(envelope, plaintext); - }.bind(this)); - }, - handleEndSession: function(number) { - console.log('got end session'); - return textsecure.storage.protocol.getDeviceIds(number).then(function(deviceIds) { - return Promise.all(deviceIds.map(function(deviceId) { - var address = new libsignal.SignalProtocolAddress(number, deviceId); - var sessionCipher = new libsignal.SessionCipher(textsecure.storage.protocol, address); - - console.log('deleting sessions for', address.toString()); - return sessionCipher.deleteAllSessionsForDevice(); - })); - }); - }, - processDecrypted: function(envelope, decrypted, source) { - // Now that its decrypted, validate the message and clean it up for consumer processing - // Note that messages may (generally) only perform one action and we ignore remaining fields - // after the first action. - - if (decrypted.flags == null) { - decrypted.flags = 0; - } - if (decrypted.expireTimer == null) { - decrypted.expireTimer = 0; - } - - if (decrypted.flags & textsecure.protobuf.DataMessage.Flags.END_SESSION) { - decrypted.body = null; - decrypted.attachments = []; - decrypted.group = null; - return Promise.resolve(decrypted); - } else if (decrypted.flags & textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE ) { - decrypted.body = null; - decrypted.attachments = []; - } else if (decrypted.flags & textsecure.protobuf.DataMessage.Flags.PROFILE_KEY_UPDATE) { - decrypted.body = null; - decrypted.attachments = []; - } else if (decrypted.flags != 0) { - throw new Error("Unknown flags in message"); - } - - var promises = []; - - if (decrypted.group !== null) { - decrypted.group.id = decrypted.group.id.toBinary(); - - if (decrypted.group.type == textsecure.protobuf.GroupContext.Type.UPDATE) { - if (decrypted.group.avatar !== null) { - promises.push(this.handleAttachment(decrypted.group.avatar)); - } - } - - promises.push(textsecure.storage.groups.getNumbers(decrypted.group.id).then(function(existingGroup) { - if (existingGroup === undefined) { - if (decrypted.group.type != textsecure.protobuf.GroupContext.Type.UPDATE) { - decrypted.group.members = [source]; - console.log("Got message for unknown group"); - } - return textsecure.storage.groups.createNewGroup(decrypted.group.members, decrypted.group.id); - } else { - var fromIndex = existingGroup.indexOf(source); - - if (fromIndex < 0) { - //TODO: This could be indication of a race... - console.log("Sender was not a member of the group they were sending from"); - } - - switch(decrypted.group.type) { - case textsecure.protobuf.GroupContext.Type.UPDATE: - decrypted.body = null; - decrypted.attachments = []; - return textsecure.storage.groups.updateNumbers( - decrypted.group.id, decrypted.group.members - ); - - break; - case textsecure.protobuf.GroupContext.Type.QUIT: - decrypted.body = null; - decrypted.attachments = []; - if (source === this.number) { - return textsecure.storage.groups.deleteGroup(decrypted.group.id); - } else { - return textsecure.storage.groups.removeNumber(decrypted.group.id, source); - } - case textsecure.protobuf.GroupContext.Type.DELIVER: - decrypted.group.name = null; - decrypted.group.members = []; - decrypted.group.avatar = null; - - break; - default: - this.removeFromCache(envelope); - throw new Error("Unknown group message type"); - } - } - }.bind(this))); - } - - for (var i in decrypted.attachments) { - promises.push(this.handleAttachment(decrypted.attachments[i])); - } - return Promise.all(promises).then(function() { - return decrypted; - }); + constructor: MessageReceiver, + connect() { + if (this.calledClose) { + return; } + + this.hasConnected = true; + + if (this.socket && this.socket.readyState !== WebSocket.CLOSED) { + this.socket.close(); + this.wsr.close(); + } + // initialize the socket and start listening for messages + this.socket = this.server.getMessageSocket(); + this.socket.onclose = this.onclose.bind(this); + this.socket.onerror = this.onerror.bind(this); + this.socket.onopen = this.onopen.bind(this); + this.wsr = new WebSocketResource(this.socket, { + handleRequest: this.handleRequest.bind(this), + keepalive: { + path: '/v1/keepalive', + disconnect: true, + }, + }); + + // Because sometimes the socket doesn't properly emit its close event + this._onClose = this.onclose.bind(this); + this.wsr.addEventListener('close', this._onClose); + + // Ensures that an immediate 'empty' event from the websocket will fire only after + // all cached envelopes are processed. + this.incoming = [this.pending]; + }, + shutdown() { + if (this.socket) { + this.socket.onclose = null; + this.socket.onerror = null; + this.socket.onopen = null; + this.socket = null; + } + + if (this.wsr) { + this.wsr.removeEventListener('close', this._onClose); + this.wsr = null; + } + }, + close() { + console.log('MessageReceiver.close()'); + this.calledClose = true; + + // Our WebSocketResource instance will close the socket and emit a 'close' event + // if the socket doesn't emit one quickly enough. + if (this.wsr) { + this.wsr.close(3000, 'called close'); + } + + return this.drain(); + }, + onopen() { + console.log('websocket open'); + }, + onerror() { + console.log('websocket error'); + }, + dispatchAndWait(event) { + return Promise.all(this.dispatchEvent(event)); + }, + onclose(ev) { + console.log( + 'websocket closed', + ev.code, + ev.reason || '', + 'calledClose:', + this.calledClose + ); + + this.shutdown(); + + if (this.calledClose) { + return Promise.resolve(); + } + if (ev.code === 3000) { + return Promise.resolve(); + } + if (ev.code === 3001) { + this.onEmpty(); + } + // possible 403 or network issue. Make an request to confirm + return this.server.getDevices(this.number) + .then(this.connect.bind(this)) // No HTTP error? Reconnect + .catch((e) => { + const event = new Event('error'); + event.error = e; + return this.dispatchAndWait(event); + }); + }, + handleRequest(request) { + this.incoming = this.incoming || []; + // We do the message decryption here, instead of in the ordered pending queue, + // to avoid exposing the time it took us to process messages through the time-to-ack. + + // TODO: handle different types of requests. + if (request.path !== '/api/v1/message') { + console.log('got request', request.verb, request.path); + request.respond(200, 'OK'); + + if (request.verb === 'PUT' && request.path === '/api/v1/queue/empty') { + this.onEmpty(); + } + return; + } + + const promise = textsecure.crypto.decryptWebsocketMessage( + request.body, + this.signalingKey + ).then((plaintext) => { + const envelope = textsecure.protobuf.Envelope.decode(plaintext); + // After this point, decoding errors are not the server's + // fault, and we should handle them gracefully and tell the + // user they received an invalid message + + if (this.isBlocked(envelope.source)) { + return request.respond(200, 'OK'); + } + + return this.addToCache(envelope, plaintext).then(() => { + request.respond(200, 'OK'); + this.queueEnvelope(envelope); + }, (error) => { + console.log( + 'handleRequest error trying to add message to cache:', + error && error.stack ? error.stack : error + ); + }); + }).catch((e) => { + request.respond(500, 'Bad encrypted websocket message'); + console.log('Error handling incoming message:', e && e.stack ? e.stack : e); + const ev = new Event('error'); + ev.error = e; + return this.dispatchAndWait(ev); + }); + + this.incoming.push(promise); + }, + addToQueue(task) { + this.count += 1; + this.pending = this.pending.then(task, task); + + const { count, pending } = this; + + const cleanup = () => { + this.updateProgress(count); + // We want to clear out the promise chain whenever possible because it could + // lead to large memory usage over time: + // https://github.com/nodejs/node/issues/6673#issuecomment-244331609 + if (this.pending === pending) { + this.pending = Promise.resolve(); + } + }; + + pending.then(cleanup, cleanup); + + return pending; + }, + onEmpty() { + const { incoming } = this; + this.incoming = []; + + const dispatchEmpty = () => { + console.log('MessageReceiver: emitting \'empty\' event'); + const ev = new Event('empty'); + return this.dispatchAndWait(ev); + }; + + const queueDispatch = () => { + // resetting count to zero so everything queued after this starts over again + this.count = 0; + + this.addToQueue(dispatchEmpty); + }; + + // We first wait for all recently-received messages (this.incoming) to be queued, + // then we add a task to emit the 'empty' event to the queue, so all message + // processing is complete by the time it runs. + Promise.all(incoming).then(queueDispatch, queueDispatch); + }, + drain() { + const { incoming } = this; + this.incoming = []; + + const queueDispatch = () => this.addToQueue(() => { + console.log('drained'); + }); + + // This promise will resolve when there are no more messages to be processed. + return Promise.all(incoming).then(queueDispatch, queueDispatch); + }, + updateProgress(count) { + // count by 10s + if (count % 10 !== 0) { + return; + } + const ev = new Event('progress'); + ev.count = count; + this.dispatchEvent(ev); + }, + queueAllCached() { + return this.getAllFromCache().then((items) => { + for (let i = 0, max = items.length; i < max; i += 1) { + this.queueCached(items[i]); + } + }); + }, + queueCached(item) { + try { + let envelopePlaintext = item.envelope; + + // Up until 0.42.6 we stored envelope and decrypted as strings in IndexedDB, + // so we need to be ready for them. + if (typeof envelopePlaintext === 'string') { + envelopePlaintext = this.stringToArrayBuffer(envelopePlaintext); + } + const envelope = textsecure.protobuf.Envelope.decode(envelopePlaintext); + + const { decrypted } = item; + if (decrypted) { + let payloadPlaintext = decrypted; + if (typeof payloadPlaintext === 'string') { + payloadPlaintext = this.stringToArrayBuffer(payloadPlaintext); + } + this.queueDecryptedEnvelope(envelope, payloadPlaintext); + } else { + this.queueEnvelope(envelope); + } + } catch (error) { + console.log('queueCached error handling item', item.id); + } + }, + getEnvelopeId(envelope) { + return `${envelope.source}.${envelope.sourceDevice} ${envelope.timestamp.toNumber()}`; + }, + stringToArrayBuffer(string) { + // eslint-disable-next-line new-cap + return new dcodeIO.ByteBuffer.wrap(string, 'binary').toArrayBuffer(); + }, + getAllFromCache() { + console.log('getAllFromCache'); + return textsecure.storage.unprocessed.getAll().then((items) => { + console.log('getAllFromCache loaded', items.length, 'saved envelopes'); + + return Promise.all(_.map(items, (item) => { + const attempts = 1 + (item.attempts || 0); + if (attempts >= 5) { + console.log('getAllFromCache final attempt for envelope', item.id); + return textsecure.storage.unprocessed.remove(item.id); + } + return textsecure.storage.unprocessed.update(item.id, { attempts }); + })).then(() => items, (error) => { + console.log( + 'getAllFromCache error updating items after load:', + error && error.stack ? error.stack : error + ); + return items; + }); + }); + }, + addToCache(envelope, plaintext) { + const id = this.getEnvelopeId(envelope); + const data = { + id, + envelope: plaintext, + timestamp: Date.now(), + attempts: 1, + }; + return textsecure.storage.unprocessed.add(data); + }, + updateCache(envelope, plaintext) { + const id = this.getEnvelopeId(envelope); + const data = { + decrypted: plaintext, + }; + return textsecure.storage.unprocessed.update(id, data); + }, + removeFromCache(envelope) { + const id = this.getEnvelopeId(envelope); + return textsecure.storage.unprocessed.remove(id); + }, + queueDecryptedEnvelope(envelope, plaintext) { + const id = this.getEnvelopeId(envelope); + console.log('queueing decrypted envelope', id); + + const task = this.handleDecryptedEnvelope.bind(this, envelope, plaintext); + const taskWithTimeout = textsecure.createTaskWithTimeout( + task, + `queueEncryptedEnvelope ${id}` + ); + const promise = this.addToQueue(taskWithTimeout); + + return promise.catch((error) => { + console.log( + 'queueDecryptedEnvelope error handling envelope', + id, + ':', + error && error.stack ? error.stack : error + ); + }); + }, + queueEnvelope(envelope) { + const id = this.getEnvelopeId(envelope); + console.log('queueing envelope', id); + + const task = this.handleEnvelope.bind(this, envelope); + const taskWithTimeout = textsecure.createTaskWithTimeout(task, `queueEnvelope ${id}`); + const promise = this.addToQueue(taskWithTimeout); + + return promise.catch((error) => { + console.log( + 'queueEnvelope error handling envelope', + id, + ':', + error && error.stack ? error.stack : error + ); + }); + }, + // Same as handleEnvelope, just without the decryption step. Necessary for handling + // messages which were successfully decrypted, but application logic didn't finish + // processing. + handleDecryptedEnvelope(envelope, plaintext) { + // No decryption is required for delivery receipts, so the decrypted field of + // the Unprocessed model will never be set + + if (envelope.content) { + return this.innerHandleContentMessage(envelope, plaintext); + } else if (envelope.legacyMessage) { + return this.innerHandleLegacyMessage(envelope, plaintext); + } + this.removeFromCache(envelope); + throw new Error('Received message with no content and no legacyMessage'); + }, + handleEnvelope(envelope) { + if (envelope.type === textsecure.protobuf.Envelope.Type.RECEIPT) { + return this.onDeliveryReceipt(envelope); + } + + if (envelope.content) { + return this.handleContentMessage(envelope); + } else if (envelope.legacyMessage) { + return this.handleLegacyMessage(envelope); + } + this.removeFromCache(envelope); + throw new Error('Received message with no content and no legacyMessage'); + }, + getStatus() { + if (this.socket) { + return this.socket.readyState; + } else if (this.hasConnected) { + return WebSocket.CLOSED; + } + return -1; + }, + onDeliveryReceipt(envelope) { + return new Promise((resolve, reject) => { + const ev = new Event('delivery'); + ev.confirm = this.removeFromCache.bind(this, envelope); + ev.deliveryReceipt = { + timestamp: envelope.timestamp.toNumber(), + source: envelope.source, + sourceDevice: envelope.sourceDevice, + }; + this.dispatchAndWait(ev).then(resolve, reject); + }); + }, + unpad(paddedData) { + const paddedPlaintext = new Uint8Array(paddedData); + let plaintext; + + for (let i = paddedPlaintext.length - 1; i >= 0; i -= 1) { + if (paddedPlaintext[i] === 0x80) { + plaintext = new Uint8Array(i); + plaintext.set(paddedPlaintext.subarray(0, i)); + plaintext = plaintext.buffer; + break; + } else if (paddedPlaintext[i] !== 0x00) { + throw new Error('Invalid padding'); + } + } + + return plaintext; + }, + decrypt(envelope, ciphertext) { + let promise; + const address = new libsignal.SignalProtocolAddress( + envelope.source, + envelope.sourceDevice + ); + + const ourNumber = textsecure.storage.user.getNumber(); + const number = address.toString().split('.')[0]; + const options = {}; + + // No limit on message keys if we're communicating with our other devices + if (ourNumber === number) { + options.messageKeysLimit = false; + } + + const sessionCipher = new libsignal.SessionCipher( + textsecure.storage.protocol, + address, + options + ); + + switch (envelope.type) { + case textsecure.protobuf.Envelope.Type.CIPHERTEXT: + console.log('message from', this.getEnvelopeId(envelope)); + promise = sessionCipher.decryptWhisperMessage(ciphertext).then(this.unpad); + break; + case textsecure.protobuf.Envelope.Type.PREKEY_BUNDLE: + console.log('prekey message from', this.getEnvelopeId(envelope)); + promise = this.decryptPreKeyWhisperMessage(ciphertext, sessionCipher, address); + break; + default: + promise = Promise.reject(new Error('Unknown message type')); + } + + return promise.then(plaintext => this.updateCache( + envelope, + plaintext + ).then(() => plaintext, (error) => { + console.log( + 'decrypt failed to save decrypted message contents to cache:', + error && error.stack ? error.stack : error + ); + return plaintext; + })).catch((error) => { + let errorToThrow = error; + + if (error.message === 'Unknown identity key') { + // create an error that the UI will pick up and ask the + // user if they want to re-negotiate + const buffer = dcodeIO.ByteBuffer.wrap(ciphertext); + errorToThrow = new textsecure.IncomingIdentityKeyError( + address.toString(), + buffer.toArrayBuffer(), + error.identityKey + ); + } + const ev = new Event('error'); + ev.error = errorToThrow; + ev.proto = envelope; + ev.confirm = this.removeFromCache.bind(this, envelope); + + const returnError = () => Promise.reject(errorToThrow); + return this.dispatchAndWait(ev).then(returnError, returnError); + }); + }, + async decryptPreKeyWhisperMessage(ciphertext, sessionCipher, address) { + const padded = await sessionCipher.decryptPreKeyWhisperMessage(ciphertext); + + try { + return this.unpad(padded); + } catch (e) { + if (e.message === 'Unknown identity key') { + // create an error that the UI will pick up and ask the + // user if they want to re-negotiate + const buffer = dcodeIO.ByteBuffer.wrap(ciphertext); + throw new textsecure.IncomingIdentityKeyError( + address.toString(), + buffer.toArrayBuffer(), + e.identityKey + ); + } + throw e; + } + }, + handleSentMessage(envelope, destination, timestamp, msg, expirationStartTimestamp) { + let p = Promise.resolve(); + // eslint-disable-next-line no-bitwise + if (msg.flags & textsecure.protobuf.DataMessage.Flags.END_SESSION) { + p = this.handleEndSession(destination); + } + return p.then(() => this.processDecrypted( + envelope, + msg, + this.number + ).then((message) => { + const ev = new Event('sent'); + ev.confirm = this.removeFromCache.bind(this, envelope); + ev.data = { + destination, + timestamp: timestamp.toNumber(), + device: envelope.sourceDevice, + message, + }; + if (expirationStartTimestamp) { + ev.data.expirationStartTimestamp = expirationStartTimestamp.toNumber(); + } + return this.dispatchAndWait(ev); + })); + }, + handleDataMessage(envelope, msg) { + console.log('data message from', this.getEnvelopeId(envelope)); + let p = Promise.resolve(); + // eslint-disable-next-line no-bitwise + if (msg.flags & textsecure.protobuf.DataMessage.Flags.END_SESSION) { + p = this.handleEndSession(envelope.source); + } + return p.then(() => this.processDecrypted( + envelope, + msg, + envelope.source + ).then((message) => { + const ev = new Event('message'); + ev.confirm = this.removeFromCache.bind(this, envelope); + ev.data = { + source: envelope.source, + sourceDevice: envelope.sourceDevice, + timestamp: envelope.timestamp.toNumber(), + receivedAt: envelope.receivedAt, + message, + }; + return this.dispatchAndWait(ev); + })); + }, + handleLegacyMessage(envelope) { + return this.decrypt( + envelope, + envelope.legacyMessage + ).then(plaintext => this.innerHandleLegacyMessage(envelope, plaintext)); + }, + innerHandleLegacyMessage(envelope, plaintext) { + const message = textsecure.protobuf.DataMessage.decode(plaintext); + return this.handleDataMessage(envelope, message); + }, + handleContentMessage(envelope) { + return this.decrypt( + envelope, + envelope.content + ).then(plaintext => this.innerHandleContentMessage(envelope, plaintext)); + }, + innerHandleContentMessage(envelope, plaintext) { + const content = textsecure.protobuf.Content.decode(plaintext); + if (content.syncMessage) { + return this.handleSyncMessage(envelope, content.syncMessage); + } else if (content.dataMessage) { + return this.handleDataMessage(envelope, content.dataMessage); + } else if (content.nullMessage) { + return this.handleNullMessage(envelope, content.nullMessage); + } else if (content.callMessage) { + return this.handleCallMessage(envelope, content.callMessage); + } else if (content.receiptMessage) { + return this.handleReceiptMessage(envelope, content.receiptMessage); + } + this.removeFromCache(envelope); + throw new Error('Unsupported content message'); + }, + handleCallMessage(envelope) { + console.log('call message from', this.getEnvelopeId(envelope)); + this.removeFromCache(envelope); + }, + handleReceiptMessage(envelope, receiptMessage) { + const results = []; + if (receiptMessage.type === textsecure.protobuf.ReceiptMessage.Type.DELIVERY) { + for (let i = 0; i < receiptMessage.timestamp.length; i += 1) { + const ev = new Event('delivery'); + ev.confirm = this.removeFromCache.bind(this, envelope); + ev.deliveryReceipt = { + timestamp: receiptMessage.timestamp[i].toNumber(), + source: envelope.source, + sourceDevice: envelope.sourceDevice, + }; + results.push(this.dispatchAndWait(ev)); + } + } else if (receiptMessage.type === textsecure.protobuf.ReceiptMessage.Type.READ) { + for (let i = 0; i < receiptMessage.timestamp.length; i += 1) { + const ev = new Event('read'); + ev.confirm = this.removeFromCache.bind(this, envelope); + ev.timestamp = envelope.timestamp.toNumber(); + ev.read = { + timestamp: receiptMessage.timestamp[i].toNumber(), + reader: envelope.source, + }; + results.push(this.dispatchAndWait(ev)); + } + } + return Promise.all(results); + }, + handleNullMessage(envelope) { + console.log('null message from', this.getEnvelopeId(envelope)); + this.removeFromCache(envelope); + }, + handleSyncMessage(envelope, syncMessage) { + if (envelope.source !== this.number) { + throw new Error('Received sync message from another number'); + } + // eslint-disable-next-line eqeqeq + if (envelope.sourceDevice == this.deviceId) { + throw new Error('Received sync message from our own device'); + } + if (syncMessage.sent) { + const sentMessage = syncMessage.sent; + const to = sentMessage.message.group + ? `group(${sentMessage.message.group.id.toBinary()})` + : sentMessage.destination; + + console.log( + 'sent message to', + to, + sentMessage.timestamp.toNumber(), + 'from', + this.getEnvelopeId(envelope) + ); + return this.handleSentMessage( + envelope, + sentMessage.destination, + sentMessage.timestamp, + sentMessage.message, + sentMessage.expirationStartTimestamp + ); + } else if (syncMessage.contacts) { + return this.handleContacts(envelope, syncMessage.contacts); + } else if (syncMessage.groups) { + return this.handleGroups(envelope, syncMessage.groups); + } else if (syncMessage.blocked) { + return this.handleBlocked(envelope, syncMessage.blocked); + } else if (syncMessage.request) { + console.log('Got SyncMessage Request'); + return this.removeFromCache(envelope); + } else if (syncMessage.read && syncMessage.read.length) { + console.log('read messages from', this.getEnvelopeId(envelope)); + return this.handleRead(envelope, syncMessage.read); + } else if (syncMessage.verified) { + return this.handleVerified(envelope, syncMessage.verified); + } else if (syncMessage.configuration) { + return this.handleConfiguration(envelope, syncMessage.configuration); + } + throw new Error('Got empty SyncMessage'); + }, + handleConfiguration(envelope, configuration) { + const ev = new Event('configuration'); + ev.confirm = this.removeFromCache.bind(this, envelope); + ev.configuration = { + readReceipts: configuration.readReceipts, + }; + return this.dispatchAndWait(ev); + }, + handleVerified(envelope, verified) { + const ev = new Event('verified'); + ev.confirm = this.removeFromCache.bind(this, envelope); + ev.verified = { + state: verified.state, + destination: verified.destination, + identityKey: verified.identityKey.toArrayBuffer(), + }; + return this.dispatchAndWait(ev); + }, + handleRead(envelope, read) { + const results = []; + for (let i = 0; i < read.length; i += 1) { + const ev = new Event('readSync'); + ev.confirm = this.removeFromCache.bind(this, envelope); + ev.timestamp = envelope.timestamp.toNumber(); + ev.read = { + timestamp: read[i].timestamp.toNumber(), + sender: read[i].sender, + }; + results.push(this.dispatchAndWait(ev)); + } + return Promise.all(results); + }, + handleContacts(envelope, contacts) { + console.log('contact sync'); + const attachmentPointer = contacts.blob; + return this.handleAttachment(attachmentPointer).then(() => { + const results = []; + const contactBuffer = new ContactBuffer(attachmentPointer.data); + let contactDetails = contactBuffer.next(); + while (contactDetails !== undefined) { + const ev = new Event('contact'); + ev.confirm = this.removeFromCache.bind(this, envelope); + ev.contactDetails = contactDetails; + results.push(this.dispatchAndWait(ev)); + + contactDetails = contactBuffer.next(); + } + + const ev = new Event('contactsync'); + ev.confirm = this.removeFromCache.bind(this, envelope); + results.push(this.dispatchAndWait(ev)); + + return Promise.all(results); + }); + }, + handleGroups(envelope, groups) { + console.log('group sync'); + const attachmentPointer = groups.blob; + return this.handleAttachment(attachmentPointer).then(() => { + const groupBuffer = new GroupBuffer(attachmentPointer.data); + let groupDetails = groupBuffer.next(); + const promises = []; + while (groupDetails !== undefined) { + const getGroupDetails = (details) => { + // eslint-disable-next-line no-param-reassign + details.id = details.id.toBinary(); + if (details.active) { + return textsecure.storage.groups.getGroup(details.id) + .then((existingGroup) => { + if (existingGroup === undefined) { + return textsecure.storage.groups.createNewGroup( + details.members, + details.id + ); + } + return textsecure.storage.groups.updateNumbers( + details.id, + details.members + ); + }).then(() => details); + } + return Promise.resolve(details); + }; + + const promise = getGroupDetails(groupDetails).then((details) => { + const ev = new Event('group'); + ev.confirm = this.removeFromCache.bind(this, envelope); + ev.groupDetails = details; + return this.dispatchAndWait(ev); + }).catch((e) => { + console.log('error processing group', e); + }); + groupDetails = groupBuffer.next(); + promises.push(promise); + } + + Promise.all(promises).then(() => { + const ev = new Event('groupsync'); + ev.confirm = this.removeFromCache.bind(this, envelope); + return this.dispatchAndWait(ev); + }); + }); + }, + handleBlocked(envelope, blocked) { + console.log('Setting these numbers as blocked:', blocked.numbers); + textsecure.storage.put('blocked', blocked.numbers); + }, + isBlocked(number) { + return textsecure.storage.get('blocked', []).indexOf(number) >= 0; + }, + handleAttachment(attachment) { + // eslint-disable-next-line no-param-reassign + attachment.id = attachment.id.toString(); + // eslint-disable-next-line no-param-reassign + attachment.key = attachment.key.toArrayBuffer(); + if (attachment.digest) { + // eslint-disable-next-line no-param-reassign + attachment.digest = attachment.digest.toArrayBuffer(); + } + function decryptAttachment(encrypted) { + return textsecure.crypto.decryptAttachment( + encrypted, + attachment.key, + attachment.digest + ); + } + + function updateAttachment(data) { + // eslint-disable-next-line no-param-reassign + attachment.data = data; + } + + return this.server.getAttachment(attachment.id) + .then(decryptAttachment) + .then(updateAttachment); + }, + validateRetryContentMessage(content) { + // Today this is only called for incoming identity key errors, so it can't be a sync + // message. + if (content.syncMessage) { + return false; + } + + // We want at least one field set, but not more than one + let count = 0; + count += content.dataMessage ? 1 : 0; + count += content.callMessage ? 1 : 0; + count += content.nullMessage ? 1 : 0; + if (count !== 1) { + return false; + } + + // It's most likely that dataMessage will be populated, so we look at it in detail + const data = content.dataMessage; + if (data && !data.attachments.length && !data.body && !data.expireTimer && + !data.flags && !data.group) { + return false; + } + + return true; + }, + tryMessageAgain(from, ciphertext, message) { + const address = libsignal.SignalProtocolAddress.fromString(from); + const sentAt = message.sent_at || Date.now(); + const receivedAt = message.received_at || Date.now(); + + const ourNumber = textsecure.storage.user.getNumber(); + const number = address.getName(); + const device = address.getDeviceId(); + const options = {}; + + // No limit on message keys if we're communicating with our other devices + if (ourNumber === number) { + options.messageKeysLimit = false; + } + + const sessionCipher = new libsignal.SessionCipher( + textsecure.storage.protocol, + address, + options + ); + console.log('retrying prekey whisper message'); + return this.decryptPreKeyWhisperMessage( + ciphertext, + sessionCipher, + address + ).then((plaintext) => { + const envelope = { + source: number, + sourceDevice: device, + receivedAt, + timestamp: { + toNumber() { + return sentAt; + }, + }, + }; + + // Before June, all incoming messages were still DataMessage: + // - iOS: Michael Kirk says that they were sending Legacy messages until June + // - Desktop: https://github.com/signalapp/Signal-Desktop/commit/e8548879db405d9bcd78b82a456ad8d655592c0f + // - Android: https://github.com/signalapp/libsignal-service-java/commit/61a75d023fba950ff9b4c75a249d1a3408e12958 + // + // var d = new Date('2017-06-01T07:00:00.000Z'); + // d.getTime(); + const startOfJune = 1496300400000; + if (sentAt < startOfJune) { + return this.innerHandleLegacyMessage(envelope, plaintext); + } + + // This is ugly. But we don't know what kind of proto we need to decode... + try { + // Simply decoding as a Content message may throw + const content = textsecure.protobuf.Content.decode(plaintext); + + // But it might also result in an invalid object, so we try to detect that + if (this.validateRetryContentMessage(content)) { + return this.innerHandleContentMessage(envelope, plaintext); + } + } catch (e) { + return this.innerHandleLegacyMessage(envelope, plaintext); + } + + return this.innerHandleLegacyMessage(envelope, plaintext); + }); + }, + async handleEndSession(number) { + console.log('got end session'); + const deviceIds = await textsecure.storage.protocol.getDeviceIds(number); + + return Promise.all(deviceIds.map((deviceId) => { + const address = new libsignal.SignalProtocolAddress(number, deviceId); + const sessionCipher = new libsignal.SessionCipher( + textsecure.storage.protocol, + address + ); + + console.log('deleting sessions for', address.toString()); + return sessionCipher.deleteAllSessionsForDevice(); + })); + }, + processDecrypted(envelope, decrypted, source) { + /* eslint-disable no-bitwise, no-param-reassign */ + const FLAGS = textsecure.protobuf.DataMessage.Flags; + + // Now that its decrypted, validate the message and clean it up for consumer + // processing + // Note that messages may (generally) only perform one action and we ignore remaining + // fields after the first action. + + if (decrypted.flags == null) { + decrypted.flags = 0; + } + if (decrypted.expireTimer == null) { + decrypted.expireTimer = 0; + } + + + if (decrypted.flags & FLAGS.END_SESSION) { + decrypted.body = null; + decrypted.attachments = []; + decrypted.group = null; + return Promise.resolve(decrypted); + } else if (decrypted.flags & FLAGS.EXPIRATION_TIMER_UPDATE) { + decrypted.body = null; + decrypted.attachments = []; + } else if (decrypted.flags & FLAGS.PROFILE_KEY_UPDATE) { + decrypted.body = null; + decrypted.attachments = []; + } else if (decrypted.flags !== 0) { + throw new Error('Unknown flags in message'); + } + + const promises = []; + + if (decrypted.group !== null) { + decrypted.group.id = decrypted.group.id.toBinary(); + + if (decrypted.group.type === textsecure.protobuf.GroupContext.Type.UPDATE) { + if (decrypted.group.avatar !== null) { + promises.push(this.handleAttachment(decrypted.group.avatar)); + } + } + + const storageGroups = textsecure.storage.groups; + + promises.push(storageGroups.getNumbers(decrypted.group.id).then((existingGroup) => { + if (existingGroup === undefined) { + if (decrypted.group.type !== textsecure.protobuf.GroupContext.Type.UPDATE) { + decrypted.group.members = [source]; + console.log('Got message for unknown group'); + } + return textsecure.storage.groups.createNewGroup( + decrypted.group.members, + decrypted.group.id + ); + } + const fromIndex = existingGroup.indexOf(source); + + if (fromIndex < 0) { + // TODO: This could be indication of a race... + console.log('Sender was not a member of the group they were sending from'); + } + + switch (decrypted.group.type) { + case textsecure.protobuf.GroupContext.Type.UPDATE: + decrypted.body = null; + decrypted.attachments = []; + return textsecure.storage.groups.updateNumbers( + decrypted.group.id, + decrypted.group.members + ); + case textsecure.protobuf.GroupContext.Type.QUIT: + decrypted.body = null; + decrypted.attachments = []; + if (source === this.number) { + return textsecure.storage.groups.deleteGroup(decrypted.group.id); + } + return textsecure.storage.groups.removeNumber(decrypted.group.id, source); + case textsecure.protobuf.GroupContext.Type.DELIVER: + decrypted.group.name = null; + decrypted.group.members = []; + decrypted.group.avatar = null; + return Promise.resolve(); + default: + this.removeFromCache(envelope); + throw new Error('Unknown group message type'); + } + })); + } + + for (let i = 0, max = decrypted.attachments.length; i < max; i += 1) { + const attachment = decrypted.attachments[i]; + promises.push(this.handleAttachment(attachment)); + } + + if (decrypted.quote && decrypted.quote.id) { + decrypted.quote.id = decrypted.quote.id.toNumber(); + } + + if (decrypted.quote && decrypted.quote.attachments) { + const { attachments } = decrypted.quote; + + for (let i = 0, max = attachments.length; i < max; i += 1) { + const attachment = attachments[i]; + const { thumbnail } = attachment; + + if (thumbnail) { + // We don't want the failure of a thumbnail download to fail the handling of + // this message entirely, like we do for full attachments. + promises.push(this.handleAttachment(thumbnail).catch((error) => { + console.log( + 'Problem loading thumbnail for quote', + error && error.stack ? error.stack : error + ); + })); + } + } + } + + return Promise.all(promises).then(() => decrypted); + /* eslint-enable no-bitwise, no-param-reassign */ + }, }); window.textsecure = window.textsecure || {}; -textsecure.MessageReceiver = function(url, username, password, signalingKey, options) { - var messageReceiver = new MessageReceiver(url, username, password, signalingKey, options); - this.addEventListener = messageReceiver.addEventListener.bind(messageReceiver); - this.removeEventListener = messageReceiver.removeEventListener.bind(messageReceiver); - this.getStatus = messageReceiver.getStatus.bind(messageReceiver); - this.close = messageReceiver.close.bind(messageReceiver); - messageReceiver.connect(); +textsecure.MessageReceiver = function MessageReceiverWrapper( + url, + username, + password, + signalingKey, + options +) { + const messageReceiver = new MessageReceiver( + url, + username, + password, + signalingKey, + options + ); + this.addEventListener = messageReceiver.addEventListener.bind(messageReceiver); + this.removeEventListener = messageReceiver.removeEventListener.bind(messageReceiver); + this.getStatus = messageReceiver.getStatus.bind(messageReceiver); + this.close = messageReceiver.close.bind(messageReceiver); + messageReceiver.connect(); - textsecure.replay.registerFunction(messageReceiver.tryMessageAgain.bind(messageReceiver), textsecure.replay.Type.INIT_SESSION); + textsecure.replay.registerFunction( + messageReceiver.tryMessageAgain.bind(messageReceiver), + textsecure.replay.Type.INIT_SESSION + ); }; textsecure.MessageReceiver.prototype = { - constructor: textsecure.MessageReceiver + constructor: textsecure.MessageReceiver, }; diff --git a/package.json b/package.json index 8a5396ecef4d..3d5226c7153f 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ }, "devDependencies": { "@types/chai": "^4.1.2", + "@types/classnames": "^2.2.3", "@types/lodash": "^4.14.106", "@types/mocha": "^5.0.0", "@types/qs": "^6.5.1", diff --git a/preload.js b/preload.js index a547ee69ab6c..bdce373ec4a9 100644 --- a/preload.js +++ b/preload.js @@ -161,7 +161,11 @@ window.Signal.Debug = require('./js/modules/debug'); window.Signal.HTML = require('./ts/html'); window.Signal.Logs = require('./js/modules/logs'); -window.Signal.Components = {}; +const { Quote } = require('./ts/components/conversation/Quote'); + +window.Signal.Components = { + Quote, +}; window.Signal.Migrations = {}; window.Signal.Migrations.deleteAttachmentData = diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 1110c36058ad..d0bb00e9584a 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -71,6 +71,19 @@ message DataMessage { PROFILE_KEY_UPDATE = 4; } + message Quote { + message QuotedAttachment { + optional string contentType = 1; + optional string fileName = 2; + optional AttachmentPointer thumbnail = 3; + } + + optional uint64 id = 1; + optional string author = 2; + optional string text = 3; + repeated QuotedAttachment attachments = 4; + } + optional string body = 1; repeated AttachmentPointer attachments = 2; optional GroupContext group = 3; @@ -78,6 +91,7 @@ message DataMessage { optional uint32 expireTimer = 5; optional bytes profileKey = 6; optional uint64 timestamp = 7; + optional Quote quote = 8; } message NullMessage { diff --git a/styleguide.config.js b/styleguide.config.js index 69a8b8612500..7c2eea7d6b13 100644 --- a/styleguide.config.js +++ b/styleguide.config.js @@ -27,6 +27,9 @@ module.exports = { // Exposes necessary utilities in the global scope for all readme code snippets util: 'ts/styleguide/StyleGuideUtil', }, + contextDependencies: [ + path.join(__dirname, 'ts/styleguide'), + ], // We don't want one long, single page pagePerSection: true, // Expose entire repository to the styleguidist server, primarily for stylesheets @@ -126,6 +129,9 @@ module.exports = { { src: 'js/views/timestamp_view.js', }, + { + src: 'js/views/attachment_view.js', + }, { src: 'js/views/message_view.js', }, diff --git a/stylesheets/_conversation.scss b/stylesheets/_conversation.scss index 6a0c6976b7cf..16a4dfd5b468 100644 --- a/stylesheets/_conversation.scss +++ b/stylesheets/_conversation.scss @@ -379,6 +379,10 @@ li.entry .error-icon-container { display: none; } +.message-list .outgoing .bubble .quote, .private .message-list .incoming .bubble .quote { + margin-top: $android-bubble-quote-padding - $android-bubble-padding-vertical; +} + .sender { font-size: smaller; opacity: 0.8; @@ -435,6 +439,8 @@ span.status { } } + + .bubble { position: relative; left: -2px; @@ -450,7 +456,142 @@ span.status { max-width: calc(100% - 45px - #{$error-icon-size}); // avatar size + padding + error-icon size } + .quote { + @include message-replies-colors; + @include twenty-percent-colors; + + &.no-click { + cursor: auto; + } + + cursor: pointer; + display: flex; + flex-direction: row; + align-items: stretch; + overflow: hidden; + + border-radius: 2px; + background-color: #eee; + position: relative; + + margin-right: $android-bubble-quote-padding - $android-bubble-padding-horizontal; + margin-left: $android-bubble-quote-padding - $android-bubble-padding-horizontal; + margin-bottom: 0.5em; + + // Accent color border: + border-left-width: 3px; + border-left-style: solid; + + .primary { + flex-grow: 1; + padding-left: 10px; + padding-right: 10px; + padding-top: 6px; + padding-bottom: 6px; + + // Will turn on in the iOS theme. This extra element is necessary because the iOS + // theme requires text that isn't used at all in the Android Theme + .ios-label { + display: none; + } + + .author { + font-weight: bold; + margin-bottom: 0.3em; + @include text-colors; + + .profile-name { + font-size: smaller; + } + } + + .text { + white-space: pre-wrap; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + + // Note: -webkit-line-clamp doesn't work for RTL text, and it forces you to use + // ... as the truncation indicator. That's not a solution that works well for + // all languages. More resources: + // - http://hackingui.com/front-end/a-pure-css-solution-for-multiline-text-truncation/ + // - https://medium.com/mofed/css-line-clamp-the-good-the-bad-and-the-straight-up-broken-865413f16e5 + } + + .type-label { + font-style: italic; + font-size: 12px; + } + + .filename-label { + font-size: 12px; + } + } + + .icon-container { + flex: initial; + min-width: 48px; + width: 48px; + height: 48px; + position: relative; + + .circle-background { + position: absolute; + left: 6px; + right: 6px; + top: 6px; + bottom: 6px; + + border-radius: 50%; + @include avatar-colors; + &.white { + background-color: white; + } + } + + .icon { + position: absolute; + left: 12px; + right: 12px; + top: 12px; + bottom: 12px; + + &.file { + @include color-svg('../images/file.svg', white); + } + &.image { + @include color-svg('../images/image.svg', white); + } + &.microphone { + @include color-svg('../images/microphone.svg', white); + } + &.play { + @include color-svg('../images/play.svg', white); + } + + @include avatar-colors; + } + + .inner { + position: relative; + + height: 48px; + text-align: center; + display: flex; + align-items: center; + justify-content: center; + + img { + max-width: 100%; + max-height: 100%; + } + } + } + } + .body { + margin-top: 0.5em; white-space: pre-wrap; a { @@ -509,6 +650,13 @@ span.status { .avatar, .bubble { float: left; } + + .bubble { + .quote { + background-color: rgba(white, 0.6); + border-left-color: white; + } + } } .outgoing { @@ -569,6 +717,7 @@ span.status { } img, audio, video { + display: block; max-width: 100%; max-height: 300px; } @@ -591,6 +740,7 @@ span.status { position: relative; padding: 5px; padding-right: 10px; + padding-bottom: 0px; cursor: pointer; diff --git a/stylesheets/_ios.scss b/stylesheets/_ios.scss index 341d4f38d516..df41d6050ce3 100644 --- a/stylesheets/_ios.scss +++ b/stylesheets/_ios.scss @@ -106,13 +106,158 @@ $ios-border-color: rgba(0,0,0,0.1); padding: 10px; } + .message-container, + .message-list { + .quote { + border-top-left-radius: 15px; + border-top-right-radius: 15px; + border-bottom-left-radius: 0px; + border-bottom-right-radius: 0px; + + // Not ideal, but necessary to override the specificity of the android theme color + // classes used in conversations.scss + background-color: white !important; + border: 1px solid $grey_l1_5 !important; + border-bottom: none !important; + + margin-top: 0px !important; + margin-bottom: 0px; + margin-left: 0px; + margin-right: 0px; + + .primary { + padding: 10px; + + .text, + .filename-label, + .type-label { + border-left: 2px solid $grey_l1; + padding: 5px; + padding-left: 7px; + // Without this smaller bottom padding, text beyond four lines still shows up! + padding-bottom: 2px; + color: black; + } + + .author { + display: none; + } + + .ios-label { + display: block; + color: $grey_l1; + font-size: smaller; + margin-bottom: 3px; + } + } + + .icon-container { + height: 61px; + width: 61px; + min-width: 61px; + + .circle-background { + left: 12px; + right: 12px; + top: 12px; + bottom: 12px; + + background-color: $blue !important; + } + + .icon { + left: 18px; + right: 18px; + top: 18px; + bottom: 18px; + + background-color: white !important; + } + + .inner { + padding: 12px; + height: 61px; + text-align: center; + display: flex; + align-items: center; + justify-content: center; + } + } + + .from-me { + .primary { + .text, + .filename-label, + .type-label { + border-left: 2px solid $blue; + } + } + } + } + + .incoming { + .bubble { + .quote { + border-left: none; + border: none !important; + border-bottom: 1px solid lightgray !important; + } + } + } + + .bubble { + .quote.from-me { + .primary { + .text, + .filename-label, + .type-label { + border-left: 2px solid $blue; + } + } + } + } + + .outgoing .bubble .quote, + .private .message-list .incoming .bubble .quote { + margin-top: 0px; + } + + .outgoing .bubble .quote .icon-container .circle-background { + background-color: lightgray !important; + } + } .attachments .bubbled { border-radius: 15px; - margin-bottom: 0.25em; padding: 10px; + padding-top: 0px; + padding-bottom: 5px; + video, audio { + margin-bottom: 5px; + } + + position: relative; + } + + .tail-wrapper { + margin-bottom: 5px; + } + .inner-bubble { + border-radius: 15px; + overflow: hidden; + + .body { + margin-top: 0; + display: inline-block; + padding: 10px; + position: relative; + word-break: break-word; + } + } + + .tail-wrapper.with-tail { position: relative; &:before, &:after { @@ -137,53 +282,29 @@ $ios-border-color: rgba(0,0,0,0.1); } } - .bubble { - .content { - margin-bottom: 5px; - .body { - display: inline-block; - padding: 10px; - position: relative; - word-break: break-word; + .meta { + clear: both; + } - &:before, &:after { - content: ''; - display: block; - border-radius: 20px; - position: absolute; - width: 10px; - } - &:before { - right: -1px; - bottom: -3px; - height: 10px; - border-radius: 20px; - background: $blue; - } - &:after { - height: 11px; - right: -6px; - bottom: -3px; - background: #eee; - } + .outgoing .with-tail.tail-wrapper { + float: right; + + .inner-bubble { + .attachments { + background-color: $blue; + } + .content { + background-color: $blue; + } + max-width: 100%; + &, .body, a { + @include invert-text-color; } - } - .content, .attachments img { - border-radius: 15px; - } - .attachments img { - background-color: white; - } - .meta { - clear: both; } } - .incoming .bubbled { - background-color: white; - color: black; + .incoming .with-tail.tail-wrapper { float: left; - max-width: 100%; &:before { left: -1px; @@ -192,30 +313,11 @@ $ios-border-color: rgba(0,0,0,0.1); &:after { left: -6px; } - } - .incoming .content { - background-color: white; - color: black; - float: left; - .body { - &:before { - left: -1px; - background-color: white; - } - &:after { - left: -6px; - } - } - } - .outgoing { - .content, .attachments .bubbled { - background-color: $blue; + .inner-bubble { + background-color: white; + color: black; max-width: 100%; - &, .body, a { - @include invert-text-color; - } - float: right; } } @@ -236,7 +338,6 @@ $ios-border-color: rgba(0,0,0,0.1); a { border-radius: 15px; } - margin-bottom: 1px; } .hourglass { @include hourglass(#999); diff --git a/stylesheets/_mixins.scss b/stylesheets/_mixins.scss index 1b81f195803a..cfd92b913c9d 100644 --- a/stylesheets/_mixins.scss +++ b/stylesheets/_mixins.scss @@ -50,9 +50,88 @@ &.deep_orange { background-color: $dark_material_deep_orange ; } &.amber { background-color: $dark_material_amber ; } &.blue_grey { background-color: $dark_material_blue_grey ; } - &.grey { background-color: #666666 ; } - &.default { background-color: $blue ; } + &.grey { background-color: #666666 ; } + &.default { background-color: $blue ; } } +@mixin twenty-percent-colors { + &.red { background-color: rgba($dark_material_red, 0.2) ; } + &.pink { background-color: rgba($dark_material_pink, 0.2) ; } + &.purple { background-color: rgba($dark_material_purple, 0.2) ; } + &.deep_purple { background-color: rgba($dark_material_deep_purple, 0.2) ; } + &.indigo { background-color: rgba($dark_material_indigo, 0.2) ; } + &.blue { background-color: rgba($dark_material_blue, 0.2) ; } + &.light_blue { background-color: rgba($dark_material_light_blue, 0.2) ; } + &.cyan { background-color: rgba($dark_material_cyan, 0.2) ; } + &.teal { background-color: rgba($dark_material_teal, 0.2) ; } + &.green { background-color: rgba($dark_material_green, 0.2) ; } + &.light_green { background-color: rgba($dark_material_light_green, 0.2) ; } + &.orange { background-color: rgba($dark_material_orange, 0.2) ; } + &.deep_orange { background-color: rgba($dark_material_deep_orange, 0.2) ; } + &.amber { background-color: rgba($dark_material_amber, 0.2) ; } + &.blue_grey { background-color: rgba($dark_material_blue_grey, 0.2) ; } + &.grey { background-color: rgba(#666666, 0.2) ; } + &.default { background-color: rgba($blue, 0.2) ; } +} +@mixin text-colors { + &.red { color: $material_red ; } + &.pink { color: $material_pink ; } + &.purple { color: $material_purple ; } + &.deep_purple { color: $material_deep_purple ; } + &.indigo { color: $material_indigo ; } + &.blue { color: $material_blue ; } + &.light_blue { color: $material_light_blue ; } + &.cyan { color: $material_cyan ; } + &.teal { color: $material_teal ; } + &.green { color: $material_green ; } + &.light_green { color: $material_light_green ; } + &.orange { color: $material_orange ; } + &.deep_orange { color: $material_deep_orange ; } + &.amber { color: $material_amber ; } + &.blue_grey { color: $material_blue_grey ; } + &.grey { color: #999999 ; } + &.default { color: $blue ; } +} + +// TODO: Deduplicate these! Can SASS functions generate property names? +@mixin message-replies-colors { + &.red { border-left-color: $material_red ; } + &.pink { border-left-color: $material_pink ; } + &.purple { border-left-color: $material_purple ; } + &.deep_purple { border-left-color: $material_deep_purple ; } + &.indigo { border-left-color: $material_indigo ; } + &.blue { border-left-color: $material_blue ; } + &.light_blue { border-left-color: $material_light_blue ; } + &.cyan { border-left-color: $material_cyan ; } + &.teal { border-left-color: $material_teal ; } + &.green { border-left-color: $material_green ; } + &.light_green { border-left-color: $material_light_green ; } + &.orange { border-left-color: $material_orange ; } + &.deep_orange { border-left-color: $material_deep_orange ; } + &.amber { border-left-color: $material_amber ; } + &.blue_grey { border-left-color: $material_blue_grey ; } + &.grey { border-left-color: #999999 ; } + &.default { border-left-color: $blue ; } +} +@mixin dark-message-replies-colors { + &.red { border-left-color: $dark_material_red ; } + &.pink { border-left-color: $dark_material_pink ; } + &.purple { border-left-color: $dark_material_purple ; } + &.deep_purple { border-left-color: $dark_material_deep_purple ; } + &.indigo { border-left-color: $dark_material_indigo ; } + &.blue { border-left-color: $dark_material_blue ; } + &.light_blue { border-left-color: $dark_material_light_blue ; } + &.cyan { border-left-color: $dark_material_cyan ; } + &.teal { border-left-color: $dark_material_teal ; } + &.green { border-left-color: $dark_material_green ; } + &.light_green { border-left-color: $dark_material_light_green ; } + &.orange { border-left-color: $dark_material_orange ; } + &.deep_orange { border-left-color: $dark_material_deep_orange ; } + &.amber { border-left-color: $dark_material_amber ; } + &.blue_grey { border-left-color: $dark_material_blue_grey ; } + &.grey { border-left-color: #666666 ; } + &.default { border-left-color: $blue ; } +} + @mixin invert-text-color { color: white; diff --git a/stylesheets/_variables.scss b/stylesheets/_variables.scss index 9c9e6420e979..0b44fef8c1f5 100644 --- a/stylesheets/_variables.scss +++ b/stylesheets/_variables.scss @@ -2,6 +2,8 @@ $blue_l: #a2d2f4; $blue: #2090ea; $grey_l: #f3f3f3; +$grey_l1: #bdbdbd; +$grey_l1_5: #e6e6e6; $grey_l2: #d9d9d9; // ~ Equivalent to darken($grey_l, 10%), unreliably compiles $grey_l3: darken($grey_l, 20%); $grey_l4: darken($grey_l, 40%); @@ -82,3 +84,8 @@ $dark_material_orange: #F57C00; $dark_material_deep_orange: #E64A19; $dark_material_amber: #FFA000; $dark_material_blue_grey: #455A64; + +// Android +$android-bubble-padding-horizontal: 12px; +$android-bubble-padding-vertical: 9px; +$android-bubble-quote-padding: 4px; diff --git a/stylesheets/android-dark.scss b/stylesheets/android-dark.scss index ea1440ed9f24..3bec579f90da 100644 --- a/stylesheets/android-dark.scss +++ b/stylesheets/android-dark.scss @@ -225,6 +225,26 @@ $text-dark_l2: darken($text-dark, 30%); } } + .outgoing .bubble .quote .icon-container .icon { + background-color: black; + &.play.with-image { + background-color: $text-dark; + } + } + .incoming .bubble .quote { + border-left-color: $text-dark; + background-color: rgba(0, 0, 0, 0.6); + + .icon-container { + .circle-background { + background-color: $text-dark; + } + .icon.play.with-image { + background-color: $text-dark; + } + } + } + button.clock { @include header-icon-white('../images/clock.svg'); } diff --git a/test/index.html b/test/index.html index b922f89557bd..2707825046d9 100644 --- a/test/index.html +++ b/test/index.html @@ -206,14 +206,25 @@ diff --git a/test/modules/types/message_test.js b/test/modules/types/message_test.js index 7a8af0223d49..7a40dfcd95b1 100644 --- a/test/modules/types/message_test.js +++ b/test/modules/types/message_test.js @@ -1,4 +1,5 @@ const { assert } = require('chai'); +const sinon = require('sinon'); const Message = require('../../../js/modules/types/message'); const { stringToArrayBuffer } = require('../../../js/modules/string_to_array_buffer'); @@ -308,4 +309,100 @@ describe('Message', () => { assert.deepEqual(actual, expected); }); }); + + describe('_mapQuotedAttachments', () => { + it('handles message with no quote', async () => { + const upgradeAttachment = sinon.stub().throws(new Error("Shouldn't be called")); + const upgradeVersion = Message._mapQuotedAttachments(upgradeAttachment); + + const message = { + body: 'hey there!', + }; + const result = await upgradeVersion(message); + assert.deepEqual(result, message); + }); + + it('handles quote with no attachments', async () => { + const upgradeAttachment = sinon.stub().throws(new Error("Shouldn't be called")); + const upgradeVersion = Message._mapQuotedAttachments(upgradeAttachment); + + const message = { + body: 'hey there!', + quote: { + text: 'hey!', + }, + }; + const expected = { + body: 'hey there!', + quote: { + text: 'hey!', + attachments: [], + }, + }; + const result = await upgradeVersion(message); + assert.deepEqual(result, expected); + }); + + it('handles zero attachments', async () => { + const upgradeAttachment = sinon.stub().throws(new Error("Shouldn't be called")); + const upgradeVersion = Message._mapQuotedAttachments(upgradeAttachment); + + const message = { + body: 'hey there!', + quote: { + text: 'hey!', + attachments: [], + }, + }; + const result = await upgradeVersion(message); + assert.deepEqual(result, message); + }); + + it('handles attachments with no thumbnail', async () => { + const upgradeAttachment = sinon.stub().throws(new Error("Shouldn't be called")); + const upgradeVersion = Message._mapQuotedAttachments(upgradeAttachment); + + const message = { + body: 'hey there!', + quote: { + text: 'hey!', + attachments: [], + }, + }; + const result = await upgradeVersion(message); + assert.deepEqual(result, message); + }); + + it('calls provided async function for each quoted attachment', async () => { + const upgradeAttachment = sinon.stub().resolves({ + path: '/new/path/on/disk', + }); + const upgradeVersion = Message._mapQuotedAttachments(upgradeAttachment); + + const message = { + body: 'hey there!', + quote: { + text: 'hey!', + attachments: [{ + thumbnail: { + data: 'data is here', + }, + }], + }, + }; + const expected = { + body: 'hey there!', + quote: { + text: 'hey!', + attachments: [{ + thumbnail: { + path: '/new/path/on/disk', + }, + }], + }, + }; + const result = await upgradeVersion(message); + assert.deepEqual(result, expected); + }); + }); }); diff --git a/test/styleguide/legacy_bridge.js b/test/styleguide/legacy_bridge.js index 5b2c58a97cc4..b23b045c8fc7 100644 --- a/test/styleguide/legacy_bridge.js +++ b/test/styleguide/legacy_bridge.js @@ -10,12 +10,33 @@ window.PROTO_ROOT = '/protos'; window.nodeSetImmediate = () => {}; +window.libphonenumber = { + parse: number => ({ + e164: number, + isValidNumber: true, + getCountryCode: () => '1', + getNationalNumber: () => number, + }), + isValidNumber: () => true, + getRegionCodeForNumber: () => '1', + format: number => number.e164, + PhoneNumberFormat: {}, +}; + window.Signal = {}; window.Signal.Backup = {}; window.Signal.Crypto = {}; window.Signal.Logs = {}; window.Signal.Migrations = { - getPlaceholderMigrations: () => {}, + getPlaceholderMigrations: () => [{ + migrate: (transaction, next) => { + console.log('migration version 1'); + transaction.db.createObjectStore('conversations'); + next(); + }, + version: 1, + }], + loadAttachmentData: attachment => Promise.resolve(attachment), }; window.Signal.Components = {}; @@ -30,6 +51,9 @@ window.EmojiConvertor.prototype.img_sets = { window.i18n = () => ''; +// Ideally we don't need to add things here. We want to add them in StyleGuideUtil, which +// means that references to these things can't be early-bound, not capturing the direct +// reference to the function on file load. window.Signal.Migrations.V17 = {}; window.Signal.OS = {}; window.Signal.Types = {}; diff --git a/test/styleguide/legacy_templates.js b/test/styleguide/legacy_templates.js index 090c2c085eed..92c651faced0 100644 --- a/test/styleguide/legacy_templates.js +++ b/test/styleguide/legacy_templates.js @@ -32,10 +32,15 @@ window.Whisper.View.Templates = { {{ profileName }} {{ /profileName }}
-
-

- {{ #message }}{{ message }}{{ /message }} -

+
+
+
+
+
+ {{ #message }}
{{ message }}
{{ /message }} +
+
+
@@ -49,4 +54,13 @@ window.Whisper.View.Templates = { expirationTimerUpdate: ` {{ content }} `, + 'file-view': ` +
+
+
+ {{ fileName }} +
+
{{ fileSize }}
+
+ `, }; diff --git a/ts/components/conversation/Message.md b/ts/components/conversation/Message.md index 3129936b6719..262ec7db4d13 100644 --- a/ts/components/conversation/Message.md +++ b/ts/components/conversation/Message.md @@ -1,6 +1,328 @@ +Placeholder component: + ```jsx ``` + +## MessageView (Backbone) + +### Plain messages + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + body: 'How are you doing this fine day?', + sent_at: Date.now() - 18000, +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550003', + type: 'incoming', +})); +const View = Whisper.MessageView; + + + + +``` + +### In a group conversation + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + body: 'How are you doing this fine day?', + sent_at: Date.now() - 18000, +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550003', + type: 'incoming', +})); +const View = Whisper.MessageView; + + + + +``` + +### With an attachment + +#### Image with caption + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + body: 'I am pretty confused about Pi.', + sent_at: Date.now() - 18000000, + attachments: [{ + data: util.gif, + fileName: 'pi.gif', + contentType: 'image/gif', + }], +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550003', + type: 'incoming', +})); +const View = Whisper.MessageView; + + + + +``` + +#### Image + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + sent_at: Date.now() - 18000000, + attachments: [{ + data: util.gif, + fileName: 'pi.gif', + contentType: 'image/gif', + }], +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550003', + type: 'incoming', +})); +const View = Whisper.MessageView; + + + + +``` + +#### Video with caption + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + body: "Beautiful, isn't it?", + sent_at: Date.now() - 10000, + attachments: [{ + data: util.mp4, + fileName: 'freezing_bubble.mp4', + contentType: 'video/mp4', + }], +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550003', + type: 'incoming', +})); +const View = Whisper.MessageView; + + + + +``` + +#### Video + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + sent_at: Date.now() - 10000, + attachments: [{ + data: util.mp4, + fileName: 'freezing_bubble.mp4', + contentType: 'video/mp4', + }], +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550003', + type: 'incoming', +})); +const View = Whisper.MessageView; + + + + +``` + +#### Audio with caption + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + body: 'This is a nice song', + sent_at: Date.now() - 15000, + attachments: [{ + data: util.mp3, + fileName: 'agnus_dei.mp3', + contentType: 'audio/mp3', + }], +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550003', + type: 'incoming', +})); +const View = Whisper.MessageView; + + + + +``` + +#### Audio + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + sent_at: Date.now() - 15000, + attachments: [{ + data: util.mp3, + fileName: 'agnus_dei.mp3', + contentType: 'audio/mp3', + }], +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550003', + type: 'incoming', +})); +const View = Whisper.MessageView; + + + + +``` + +#### Voice message + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + sent_at: Date.now() - 15000, + attachments: [{ + flags: textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE, + data: util.mp3, + fileName: 'agnus_dei.mp3', + contentType: 'audio/mp3', + }], +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550003', + type: 'incoming', +})); +const View = Whisper.MessageView; + + + + +``` + +#### Other file type with caption + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + body: 'My manifesto is now complete!', + sent_at: Date.now() - 15000, + attachments: [{ + data: util.txt, + fileName: 'lorum_ipsum.txt', + contentType: 'text/plain', + }], +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550003', + type: 'incoming', +})); +const View = Whisper.MessageView; + + + + +``` + +#### Other file type + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + sent_at: Date.now() - 15000, + attachments: [{ + data: util.txt, + fileName: 'lorum_ipsum.txt', + contentType: 'text/plain', + }], +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550003', + type: 'incoming', +})); +const View = Whisper.MessageView; + + + + +``` diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 8a349f47c45c..e53091cce2ba 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -2,8 +2,9 @@ import React from 'react'; /** - * A placeholder Message component, giving the structure of a plain message with none of - * the dynamic functionality. We can build off of this going forward. + * A placeholder Message component for now, giving the structure of a plain message with + * none of the dynamic functionality. This page will be used to build up our corpus of + * permutations before we start moving all message functionality to React. */ export class Message extends React.Component<{}, {}> { public render() { @@ -12,12 +13,16 @@ export class Message extends React.Component<{}, {}> {
-
-

- - Hi there. How are you doing? Feeling pretty good? Awesome. - -

+
+
+
+

+ + Hi there. How are you doing? Feeling pretty good? Awesome. + +

+
+
+ + + +``` + +#### Replies to you or yourself + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + body: 'About six', + sent_at: Date.now() - 18000000, + quote: { + text: 'How many ferrets do you have?', + author: util.ourNumber, + id: Date.now() - 1000, + }, +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550011', + type: 'incoming', + quote: Object.assign({}, outgoing.attributes.quote, { + author: util.ourNumber, + }), +})); +const View = Whisper.MessageView; + + + + +``` + +#### In a group conversation + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + body: 'About six', + sent_at: Date.now() - 18000000, + quote: { + text: 'How many ferrets do you have?', + author: '+12025550010', + id: Date.now() - 1000, + }, +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550007', + type: 'incoming', + quote: Object.assign({}, outgoing.attributes.quote, { + author: '+12025550002', + }), +})); +const View = Whisper.MessageView; + + + + +``` + +#### A lot of text in quotation + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + body: 'Woo, otters!', + sent_at: Date.now() - 18000000, + quote: { + text: + 'I have lots of things to say. First, I enjoy otters. Second best are cats. ' + + 'After that, probably dogs. And then, you know, reptiles of all types. ' + + 'Then birds. They are dinosaurs, after all. Then cephalapods, because they are ' + + 'really smart.', + author: '+12025550011', + id: Date.now() - 1000, + }, +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550011', + type: 'incoming', + quote: Object.assign({}, outgoing.attributes.quote, { + author: '+12025550005', + }), +})); +const View = Whisper.MessageView; + + + + +``` + +#### A lot of text in quotation, with icon + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + body: 'Woo, otters!', + sent_at: Date.now() - 18000000, + quote: { + text: + 'I have lots of things to say. First, I enjoy otters. Second best are cats. ' + + 'After that, probably dogs. And then, you know, reptiles of all types. ' + + 'Then birds. They are dinosaurs, after all. Then cephalapods, because they are ' + + 'really smart.', + author: '+12025550011', + id: Date.now() - 1000, + attachments: [ + { + contentType: 'text/plain', + fileName: 'lorum_ipsum.txt', + }, + ], + }, +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550011', + type: 'incoming', + quote: Object.assign({}, outgoing.attributes.quote, { + author: '+12025550005', + }), +})); +const View = Whisper.MessageView; + + + + +``` + +#### A lot of text in quotation, with image + +```jsx +const quotedMessage = { + imageUrl: util.gifObjectUrl, + id: '3234-23423-2342', +}; +const outgoing = new Whisper.Message({ + type: 'outgoing', + body: 'Woo, otters!', + sent_at: Date.now() - 18000000, + quote: { + text: + 'I have lots of things to say. First, I enjoy otters. Second best are cats. ' + + 'After that, probably dogs. And then, you know, reptiles of all types. ' + + 'Then birds. They are dinosaurs, after all. Then cephalapods, because they are ' + + 'really smart.', + author: '+12025550011', + id: Date.now() - 1000, + attachments: [ + { + contentType: 'image/gif', + fileName: 'pi.gif', + thumbnail: { + contentType: 'image/gif', + }, + }, + ], + }, +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550011', + type: 'incoming', + quote: Object.assign({}, outgoing.attributes.quote, { + author: '+12025550005', + }), +})); + +outgoing.quotedMessage = quotedMessage; +incoming.quotedMessage = quotedMessage; + +const View = Whisper.MessageView; + + + + +``` + +#### Image with caption + +```jsx +const quotedMessage = { + imageUrl: util.gifObjectUrl, + id: '3234-23423-2342', +}; +const outgoing = new Whisper.Message({ + type: 'outgoing', + body: "Totally, it's a pretty unintuitive concept.", + sent_at: Date.now() - 18000000, + quote: { + text: 'I am pretty confused about Pi.', + author: '+12025550011', + id: Date.now() - 1000, + attachments: [ + { + contentType: 'image/gif', + fileName: 'pi.gif', + thumbnail: { + contentType: 'image/gif', + }, + }, + ], + }, +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550011', + type: 'incoming', + quote: Object.assign({}, outgoing.attributes.quote, { + author: '+12025550005', + }), +})); + +outgoing.quotedMessage = quotedMessage; +incoming.quotedMessage = quotedMessage; + +const View = Whisper.MessageView; + + + + +``` + +#### Image + +```jsx +const quotedMessage = { + imageUrl: util.gifObjectUrl, +}; + +const outgoing = new Whisper.Message({ + type: 'outgoing', + body: "Yeah, pi. Tough to wrap your head around.", + sent_at: Date.now() - 18000000, + quote: { + author: '+12025550011', + id: Date.now() - 1000, + attachments: [ + { + contentType: 'image/gif', + fileName: 'pi.gif', + thumbnail: { + contentType: 'image/gif', + }, + }, + ], + }, +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550011', + type: 'incoming', + quote: Object.assign({}, outgoing.attributes.quote, { + author: '+12025550005', + }), +})); + +outgoing.quotedMessage = quotedMessage; +incoming.quotedMessage = quotedMessage; + +const View = Whisper.MessageView; + + + + +``` + +#### Image with no thumbnail + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + body: "Yeah, pi. Tough to wrap your head around.", + sent_at: Date.now() - 18000000, + quote: { + author: '+12025550011', + id: Date.now() - 1000, + attachments: [ + { + contentType: 'image/gif', + fileName: 'pi.gif', + }, + ], + }, +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550011', + type: 'incoming', + quote: Object.assign({}, outgoing.attributes.quote, { + author: '+12025550005', + }), +})); + +const View = Whisper.MessageView; + + + + +``` + +#### Video with caption + +```jsx +const quotedMessage = { + imageUrl: util.gifObjectUrl, +}; + +const outgoing = new Whisper.Message({ + type: 'outgoing', + body: "Sweet the way the video sneaks up on you!", + sent_at: Date.now() - 18000000, + quote: { + author: '+12025550011', + text: 'Check out this video I found!', + id: Date.now() - 1000, + attachments: [ + { + contentType: 'video/mp4', + fileName: 'freezing_bubble.mp4', + thumbnail: { + contentType: 'image/gif', + }, + }, + ], + }, +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550011', + type: 'incoming', + quote: Object.assign({}, outgoing.attributes.quote, { + author: '+12025550005', + }), +})); + +outgoing.quotedMessage = quotedMessage; +incoming.quotedMessage = quotedMessage; + +const View = Whisper.MessageView; + + + + +``` + +#### Video + +```jsx +const quotedMessage = { + imageUrl: util.gifObjectUrl, +}; + +const outgoing = new Whisper.Message({ + type: 'outgoing', + body: "Awesome!", + sent_at: Date.now() - 18000000, + quote: { + author: '+12025550011', + id: Date.now() - 1000, + attachments: [ + { + contentType: 'video/mp4', + fileName: 'freezing_bubble.mp4', + thumbnail: { + contentType: 'image/gif', + data: util.gif, + } + }, + ], + }, +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550011', + type: 'incoming', + quote: Object.assign({}, outgoing.attributes.quote, { + author: '+12025550005', + }), +})); + +outgoing.quotedMessage = quotedMessage; +incoming.quotedMessage = quotedMessage; + +const View = Whisper.MessageView; + + + + +``` + +#### Video with no thumbnail + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + body: "Awesome!", + sent_at: Date.now() - 18000000, + quote: { + author: '+12025550011', + id: Date.now() - 1000, + attachments: [ + { + contentType: 'video/mp4', + fileName: 'freezing_bubble.mp4', + }, + ], + }, +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550011', + type: 'incoming', + quote: Object.assign({}, outgoing.attributes.quote, { + author: '+12025550005', + }), +})); + +const View = Whisper.MessageView; + + + + +``` + +#### Audio with caption + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + body: 'I really like it!', + sent_at: Date.now() - 18000000, + quote: { + author: '+12025550011', + text: 'Check out this beautiful song!', + id: Date.now() - 1000, + attachments: [ + { + contentType: 'audio/mp3', + fileName: 'agnus_dei.mp4', + }, + ], + }, +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550011', + type: 'incoming', + quote: Object.assign({}, outgoing.attributes.quote, { + author: '+12025550005', + }), +})); +const View = Whisper.MessageView; + + + + +``` + +#### Audio + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + body: 'I really like it!', + sent_at: Date.now() - 18000000, + quote: { + author: '+12025550011', + id: Date.now() - 1000, + attachments: [ + { + contentType: 'audio/mp3', + fileName: 'agnus_dei.mp4', + }, + ], + }, +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550011', + type: 'incoming', + quote: Object.assign({}, outgoing.attributes.quote, { + author: '+12025550005', + }), +})); +const View = Whisper.MessageView; + + + + +``` + +#### Voice message + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + body: 'I really like it!', + sent_at: Date.now() - 18000000, + quote: { + author: '+12025550011', + id: Date.now() - 1000, + attachments: [ + { + // proposed as of afternoon of 4/6 in Quoted Replies group + flags: textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE, + contentType: 'audio/mp3', + fileName: 'agnus_dei.mp4', + }, + ], + }, +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550011', + type: 'incoming', + quote: Object.assign({}, outgoing.attributes.quote, { + author: '+12025550005', + }), +})); +const View = Whisper.MessageView; + + + + +``` + +#### Other file type with caption + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + body: "I can't read latin.", + sent_at: Date.now() - 18000000, + quote: { + author: '+12025550011', + text: 'This is my manifesto. Tell me what you think!', + id: Date.now() - 1000, + attachments: [ + { + contentType: 'text/plain', + fileName: 'lorum_ipsum.txt', + }, + ], + }, +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550011', + type: 'incoming', + quote: Object.assign({}, outgoing.attributes.quote, { + author: '+12025550005', + }), +})); +const View = Whisper.MessageView; + + + + +``` + +#### Other file type + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + body: "Sorry, I can't read latin!", + sent_at: Date.now() - 18000000, + quote: { + author: '+12025550011', + id: Date.now() - 1000, + attachments: [ + { + contentType: 'text/plain', + fileName: 'lorum_ipsum.txt', + }, + ], + }, +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550011', + type: 'incoming', + quote: Object.assign({}, outgoing.attributes.quote, { + author: '+12025550005', + }), +})); +const View = Whisper.MessageView; + + + + +``` + +### With a quotation, including attachment + +#### Quote, image attachment, and caption + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + body: 'Like pi or so?', + sent_at: Date.now() - 18000000, + quote: { + text: 'How many ferrets do you have?', + author: '+12025550011', + id: Date.now() - 1000, + }, + attachments: [{ + data: util.gif, + fileName: 'pi.gif', + contentType: 'image/gif', + }], +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550011', + type: 'incoming', + quote: Object.assign({}, outgoing.attributes.quote, { + author: '+12025550005', + }), +})); +const View = Whisper.MessageView; + + + + +``` + +#### Quote, image attachment + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + sent_at: Date.now() - 18000000, + quote: { + text: 'How many ferrets do you have?', + author: '+12025550011', + id: Date.now() - 1000, + }, + attachments: [{ + data: util.gif, + fileName: 'pi.gif', + contentType: 'image/gif', + }], +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550011', + type: 'incoming', + quote: Object.assign({}, outgoing.attributes.quote, { + author: '+12025550005', + }), +})); +const View = Whisper.MessageView; + + + + +``` + +#### Quote, video attachment + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + sent_at: Date.now() - 18000000, + quote: { + text: 'How many ferrets do you have?', + author: '+12025550011', + id: Date.now() - 1000, + }, + attachments: [{ + data: util.mp4, + fileName: 'freezing_bubble.mp4', + contentType: 'video/mp4', + }], +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550011', + type: 'incoming', + quote: Object.assign({}, outgoing.attributes.quote, { + author: '+12025550005', + }), +})); +const View = Whisper.MessageView; + + + + +``` + +#### Quote, audio attachment + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + sent_at: Date.now() - 18000000, + quote: { + text: 'How many ferrets do you have?', + author: '+12025550011', + id: Date.now() - 1000, + }, + attachments: [{ + data: util.mp3, + fileName: 'agnus_dei.mp3', + contentType: 'audio/mp3', + }], +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550011', + type: 'incoming', + quote: Object.assign({}, outgoing.attributes.quote, { + author: '+12025550005', + }), +})); +const View = Whisper.MessageView; + + + + +``` + +#### Quote, file attachment + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + sent_at: Date.now() - 18000000, + quote: { + text: 'How many ferrets do you have?', + author: '+12025550011', + id: Date.now() - 1000, + }, + attachments: [{ + data: util.txt, + fileName: 'lorum_ipsum.txt', + contentType: 'text/plain', + }], +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550011', + type: 'incoming', + quote: Object.assign({}, outgoing.attributes.quote, { + author: '+12025550005', + }), + +})); +const View = Whisper.MessageView; + + + + +``` diff --git a/ts/components/conversation/Quote.tsx b/ts/components/conversation/Quote.tsx new file mode 100644 index 000000000000..cd3be912ab27 --- /dev/null +++ b/ts/components/conversation/Quote.tsx @@ -0,0 +1,192 @@ +import React from 'react'; +import classnames from 'classnames'; + +// @ts-ignore +import Mime from '../../../js/modules/types/mime'; + + +interface Props { + attachments: Array; + authorColor: string; + authorProfileName?: string; + authorTitle: string; + i18n: (key: string, values?: Array) => string; + isFromMe: string; + isIncoming: boolean; + onClick?: () => void; + text: string; +} + +interface QuotedAttachment { + contentType: string; + fileName: string; + /* Not included in protobuf */ + isVoiceMessage: boolean; + thumbnail?: Attachment; +} + +interface Attachment { + contentType: string; + /* Not included in protobuf, and is loaded asynchronously */ + objectUrl?: string; +} + +function validateQuote(quote: Props): boolean { + if (quote.text) { + return true; + } + + if (quote.attachments && quote.attachments.length > 0) { + return true; + } + + return false; +} + +function getObjectUrl(thumbnail: Attachment | undefined): string | null { + if (thumbnail && thumbnail.objectUrl) { + return thumbnail.objectUrl; + } + + return null; +} + +export class Quote extends React.Component { + public renderImage(url: string, icon?: string) { + const iconElement = icon + ?
+ : null; + + return ( +
+
+ + {iconElement} +
+
+ ); + } + + public renderIcon(icon: string) { + const { authorColor, isIncoming } = this.props; + + const backgroundColor = isIncoming ? 'white' : authorColor; + const iconColor = isIncoming ? authorColor : 'white'; + + return ( +
+
+
+
+ ); + } + + public renderIconContainer() { + const { attachments } = this.props; + if (!attachments || attachments.length === 0) { + return null; + } + + const first = attachments[0]; + const { contentType, thumbnail } = first; + const objectUrl = getObjectUrl(thumbnail); + + if (Mime.isVideo(contentType)) { + return objectUrl + ? this.renderImage(objectUrl, 'play') + : this.renderIcon('play'); + } + if (Mime.isImage(contentType)) { + return objectUrl + ? this.renderImage(objectUrl) + : this.renderIcon('image'); + } + if (Mime.isAudio(contentType)) { + return this.renderIcon('microphone'); + } + + return this.renderIcon('file'); + } + + public renderText() { + const { i18n, text, attachments } = this.props; + + if (text) { + return
{text}
; + } + + if (!attachments || attachments.length === 0) { + return null; + } + + const first = attachments[0]; + const { contentType, fileName, isVoiceMessage } = first; + + if (Mime.isVideo(contentType)) { + return
{i18n('video')}
; + } + if (Mime.isImage(contentType)) { + return
{i18n('photo')}
; + } + if (Mime.isAudio(contentType) && isVoiceMessage) { + return
{i18n('voiceMessage')}
; + } + if (Mime.isAudio(contentType)) { + return
{i18n('audio')}
; + } + + return
{fileName}
; + } + + public renderIOSLabel() { + const { i18n, isIncoming, isFromMe, authorTitle, authorProfileName } = this.props; + + const profileString = authorProfileName ? ` ~${authorProfileName}` : ''; + const authorName = `${authorTitle}${profileString}`; + + const label = isFromMe + ? isIncoming + ? i18n('replyingToYou') + : i18n('replyingToYourself') + : i18n('replyingTo', [authorName]); + + return
{label}
; + } + + public render() { + const { + authorTitle, + authorProfileName, + authorColor, + onClick, + isFromMe, + } = this.props; + + if (!validateQuote(this.props)) { + return null; + } + + const authorProfileElement = authorProfileName + ? ~{authorProfileName} + : null; + const classes = classnames( + authorColor, + 'quote', + isFromMe ? 'from-me' : null, + !onClick ? 'no-click' : null, + ); + + return ( +
+
+ {this.renderIOSLabel()} +
+ {authorTitle}{' '}{authorProfileElement} +
+ {this.renderText()} +
+ {this.renderIconContainer()} +
+ ); + } +} diff --git a/ts/components/conversation/Reply.md b/ts/components/conversation/Reply.md deleted file mode 100644 index e3a5855c0ecf..000000000000 --- a/ts/components/conversation/Reply.md +++ /dev/null @@ -1,2 +0,0 @@ - -This is Reply.md. diff --git a/ts/components/conversation/Reply.tsx b/ts/components/conversation/Reply.tsx deleted file mode 100644 index 58ff7773e0f9..000000000000 --- a/ts/components/conversation/Reply.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; - - -interface Props { name: string; } - -interface State { count: number; } - -export class Reply extends React.Component { - public render() { - return ( -
Placeholder
- ); - } -} diff --git a/ts/styleguide/ConversationContext.tsx b/ts/styleguide/ConversationContext.tsx index d05f32e50231..139dd8a1e6bf 100644 --- a/ts/styleguide/ConversationContext.tsx +++ b/ts/styleguide/ConversationContext.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import classnames from 'classnames'; interface Props { @@ -6,6 +7,7 @@ interface Props { * Corresponds to the theme setting in the app, and the class added to the root element. */ theme: 'ios' | 'android' | 'android-dark'; + type: 'private' | 'group'; } /** @@ -14,11 +16,11 @@ interface Props { */ export class ConversationContext extends React.Component { public render() { - const { theme } = this.props; + const { theme, type } = this.props; return ( -
-
+
+
    {this.props.children} diff --git a/ts/styleguide/StyleGuideUtil.ts b/ts/styleguide/StyleGuideUtil.ts index 903adcc4b3e5..7b0dfd99d6d7 100644 --- a/ts/styleguide/StyleGuideUtil.ts +++ b/ts/styleguide/StyleGuideUtil.ts @@ -3,6 +3,10 @@ import qs from 'qs'; import React from 'react'; import ReactDOM from 'react-dom'; +import { + padStart, + sample, +} from 'lodash'; // Helper components used in the Style Guide, exposed at 'util' in the global scope via @@ -13,9 +17,11 @@ export { BackboneWrapper } from '../components/utility/BackboneWrapper'; // Here we can make things inside Webpack available to Backbone views like preload.js. -import { Message } from '../components/conversation/Message'; -import { Reply } from '../components/conversation/Reply'; +import { Quote } from '../components/conversation/Quote'; +import * as HTML from '../html'; +// @ts-ignore +import MIME from '../../js/modules/types/mime'; // TypeScript wants two things when you import: // 1) a normal typescript file @@ -24,18 +30,36 @@ import { Reply } from '../components/conversation/Reply'; // @ts-ignore import gif from '../../fixtures/giphy-GVNvOUpeYmI7e.gif'; +const gifObjectUrl = makeObjectUrl(gif, 'image/gif'); // @ts-ignore import mp3 from '../../fixtures/incompetech-com-Agnus-Dei-X.mp3'; +const mp3ObjectUrl = makeObjectUrl(mp3, 'audio/mp3'); // @ts-ignore import txt from '../../fixtures/lorem-ipsum.txt'; +const txtObjectUrl = makeObjectUrl(txt, 'text/plain'); // @ts-ignore import mp4 from '../../fixtures/pixabay-Soap-Bubble-7141.mp4'; +const mp4ObjectUrl = makeObjectUrl(mp4, 'video/mp4'); + +function makeObjectUrl(data: ArrayBuffer, contentType: string): string { + const blob = new Blob([data], { + type: contentType, + }); + return URL.createObjectURL(blob); +} + +const ourNumber = '+12025559999'; export { mp3, + mp3ObjectUrl, gif, + gifObjectUrl, mp4, + mp4ObjectUrl, txt, + txtObjectUrl, + ourNumber, }; @@ -77,8 +101,66 @@ parent.moment.locale(locale); parent.React = React; parent.ReactDOM = ReactDOM; +parent.Signal.HTML = HTML; +parent.Signal.Types.MIME = MIME; parent.Signal.Components = { - Message, - Reply, + Quote, }; +parent.ConversationController._initialFetchComplete = true; +parent.ConversationController._initialPromise = Promise.resolve(); + + +const COLORS = [ + 'red', + 'pink', + 'purple', + 'deep_purple', + 'indigo', + 'blue', + 'light_blue', + 'cyan', + 'teal', + 'green', + 'light_green', + 'orange', + 'deep_orange', + 'amber', + 'blue_grey', + 'grey', + 'default', +]; + +const CONTACTS = COLORS.map((color, index) => { + const title = `${sample(['Mr.', 'Mrs.', 'Ms.', 'Unknown'])} ${color}`; + const key = sample(['name', 'profileName']) as string; + const id = `+1202555${padStart(index.toString(), 4, '0')}`; + + const contact = { + color, + [key]: title, + id, + type: 'private', + }; + + return parent.ConversationController.dangerouslyCreateAndAdd(contact); +}); + +const me = parent.ConversationController.dangerouslyCreateAndAdd({ + id: ourNumber, + name: 'Me!', + type: 'private', + color: 'light_blue', +}); + +export { + COLORS, + CONTACTS, + me, +}; + +parent.textsecure.storage.user.getNumber = () => ourNumber; + +// Telling Lodash to relinquish _ for use by underscore +// @ts-ignore +_.noConflict(); diff --git a/yarn.lock b/yarn.lock index 9a9c20ed858b..3748ca6bbad2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -40,6 +40,10 @@ version "4.1.2" resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.1.2.tgz#f1af664769cfb50af805431c407425ed619daa21" +"@types/classnames@^2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.3.tgz#3f0ff6873da793870e20a260cada55982f38a9e5" + "@types/lodash@^4.14.106": version "4.14.106" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.106.tgz#6093e9a02aa567ddecfe9afadca89e53e5dce4dd"