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 }}
-
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 }}
-