/* * vim: ts=4:sw=4:expandtab */ (function () { 'use strict'; window.Whisper = window.Whisper || {}; // TODO: Factor out private and group subclasses of Conversation var COLORS = [ 'red', 'pink', 'purple', 'deep_purple', 'indigo', 'blue', 'light_blue', 'cyan', 'teal', 'green', 'light_green', 'orange', 'deep_orange', 'amber', 'blue_grey', ]; Whisper.Conversation = Backbone.Model.extend({ database: Whisper.Database, storeName: 'conversations', defaults: function() { return { unreadCount: 0, verified: textsecure.storage.protocol.VerifiedStatus.DEFAULT }; }, handleMessageError: function(message, errors) { this.trigger('messageError', message, errors); }, initialize: function() { this.ourNumber = textsecure.storage.user.getNumber(); this.verifiedEnum = textsecure.storage.protocol.VerifiedStatus; this.contactCollection = new Backbone.Collection(); 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('destroy', this.revokeAvatarUrl); }, isMe: function() { return this.id === this.ourNumber; }, onMessageError: function() { this.updateVerified(); }, updateVerified: function() { if (this.isPrivate()) { return Promise.all([ textsecure.storage.protocol.getVerified(this.id), this.safeFetch() ]).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)); } }, safeFetch: function() { // new Promise necessary because a fetch will fail if convo not in db yet return new Promise(function(resolve) { this.fetch().always(resolve); }.bind(this)); }, setVerifiedDefault: function(options) { var DEFAULT = this.verifiedEnum.DEFAULT; return this.queueJob(function() { return this._setVerified(DEFAULT, options); }.bind(this)); }, setVerified: function(options) { var VERIFIED = this.verifiedEnum.VERIFIED; return this.queueJob(function() { return this._setVerified(VERIFIED, options); }.bind(this)); }, setUnverified: function(options) { var UNVERIFIED = this.verifiedEnum.UNVERIFIED; return this.queueJob(function() { return this._setVerified(UNVERIFIED, options); }.bind(this)); }, _setVerified: function(verified, options) { options = options || {}; _.defaults(options, {viaSyncMessage: false, viaContactSync: false, key: null}); var DEFAULT = this.verifiedEnum.DEFAULT; var VERIFIED = this.verifiedEnum.VERIFIED; var UNVERIFIED = this.verifiedEnum.UNVERIFIED; if (!this.isPrivate()) { throw new Error('You cannot verify a group conversation. ' + 'You must verify individual contacts.'); } 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 ); } 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)); }, sendVerifySyncMessage: function(number, state) { return textsecure.storage.protocol.loadIdentityKey(number).then(function(key) { return textsecure.messaging.syncVerification(number, state, key); }); }, 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)); } }, 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)); } }, 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))); } }, setApproved: function() { 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); }, isUntrusted: function() { if (this.isPrivate()) { return textsecure.storage.protocol.isUntrusted(this.id); } 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.isUntrusted(); } }.bind(this))).then(function(results) { return _.any(results, function(result) { return result; }); }); } }, 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([this.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: 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(); } }, addKeyChange: function(id) { console.log('adding key change advisory for', this.id, id, this.get('timestamp')); 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)); }, addVerifiedChange: function(id, verified, options) { options = options || {}; _.defaults(options, {local: true}); var lastMessage = this.get('timestamp') || Date.now(); console.log('adding verified change advisory for', this.id, 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); }); }); } }, 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 receipts, we can very // easily end up with messages never marked as read (our previous early read // receipt handling, read receipts never sent because app was offline) // We queue it because we often get a whole lot of read receipts at once, and // their markRead calls could very easily overlap given the async pull from DB. // Lastly, we don't send read receipts for any message marked read due to a read // receipt. That's a notification explosion we don't need. return this.queueJob(function() { return this.markRead(message.get('received_at'), {sendReadReceipts: false}); }.bind(this)); }, 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.id); var current = this.pending = previous.then(taskWithTimeout, taskWithTimeout); current.then(function() { if (this.pending === current) { delete this.pending; } }.bind(this)); return current; }, sendMessage: function(body, attachments) { this.queueJob(function() { var now = Date.now(); var message = this.messageCollection.add({ body : body, conversationId : this.id, type : 'outgoing', attachments : attachments, sent_at : now, received_at : now, expireTimer : this.get('expireTimer') }); if (this.isPrivate()) { message.set({destination: this.id}); } message.save(); this.save({ active_at : now, timestamp : now, lastMessage : message.getNotificationText() }); var sendFunc; if (this.get('type') == 'private') { sendFunc = textsecure.messaging.sendMessageToNumber; } else { sendFunc = textsecure.messaging.sendMessageToGroup; } message.send(sendFunc(this.get('id'), body, attachments, now, this.get('expireTimer'))); }.bind(this)); }, updateLastMessage: function() { var collection = new Whisper.MessageCollection(); return collection.fetchConversation(this.id, 1).then(function() { var lastMessage = collection.at(0); if (lastMessage) { if (lastMessage.get('type') === 'verified-change') { return; } this.set({ lastMessage : lastMessage.getNotificationText(), timestamp : lastMessage.get('sent_at') }); } else { this.set({ lastMessage: '', timestamp: null }); } if (this.hasChanged('lastMessage') || this.hasChanged('timestamp')) { this.save(); } }.bind(this)); }, updateExpirationTimer: function(expireTimer, source, received_at) { if (!expireTimer) { expireTimer = null; } source = source || textsecure.storage.user.getNumber(); var timestamp = received_at || Date.now(); this.save({ expireTimer: expireTimer }); 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 } }); if (this.isPrivate()) { message.set({destination: this.id}); } message.save(); if (message.isOutgoing()) { // outgoing update, send it to the number/group var sendFunc; if (this.get('type') == 'private') { sendFunc = textsecure.messaging.sendExpirationTimerUpdateToNumber; } else { sendFunc = textsecure.messaging.sendExpirationTimerUpdateToGroup; } message.send(sendFunc(this.get('id'), this.get('expireTimer'), message.get('sent_at'))); } return message; }, 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, flags : textsecure.protobuf.DataMessage.Flags.END_SESSION }); message.send(textsecure.messaging.closeSession(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()); return { sender : m.get('source'), timestamp : m.get('sent_at') }; }.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 (read.length && options.sendReadReceipts) { console.log('Sending', read.length, 'read receipts'); promises.push(textsecure.messaging.syncReadMessages(read)); } return Promise.all(promises); }.bind(this)); }, getProfiles: function() { // request all conversation members' keys var ids = []; if (this.isPrivate()) { ids = [this.id]; } else { ids = this.get('members'); } return Promise.all(_.map(ids, this.getProfile)); }, getProfile: function(id) { 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(); } }); }); }, fetchMessages: function() { if (!this.id) { return Promise.reject('This conversation has no id!'); } return this.messageCollection.fetchConversation(this.id, null, this.get('unreadCount')); }, hasMember: function(number) { return _.contains(this.get('members'), number); }, fetchContacts: function(options) { return new Promise(function(resolve) { if (this.isPrivate()) { this.contactCollection.reset([this]); resolve(); } else { var promises = []; var members = this.get('members') || []; this.contactCollection.reset( members.map(function(number) { var c = ConversationController.create({ id : number, type : 'private' }); this.listenTo(c, 'change:verified', this.onMemberVerifiedChange); promises.push(c.safeFetch()); return c; }.bind(this)) ); resolve(Promise.all(promises)); } }.bind(this)); }, 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}); // archive }.bind(this)); }, getName: function() { if (this.isPrivate()) { return this.get('name'); } else { return this.get('name') || 'Unknown group'; } }, getTitle: function() { if (this.isPrivate()) { return this.get('name') || this.getNumber(); } else { return this.get('name') || 'Unknown group'; } }, 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'); 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; } if (window.isOpen() && window.isFocused()) { return; } window.drawAttention(); var sender = ConversationController.create({ id: message.get('source'), type: 'private' }); var conversationId = this.id; sender.fetch().then(function() { 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({ database: Whisper.Database, storeName: 'conversations', model: Whisper.Conversation, comparator: function(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); }); })); }, 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)); } }, fetchAlphabetical: function() { return new Promise(function(resolve) { this.fetch({ index: { name: 'search', // 'search' index on tokens array }, limit: 100 }).always(resolve); }.bind(this)); }, fetchGroups: function(number) { return new Promise(function(resolve) { this.fetch({ index: { name: 'group', only: number } }).always(resolve); }.bind(this)); }, fetchActive: function() { // Ensures all active conversations are included in this collection, // and updates their attributes, but removes nothing. return this.fetch({ index: { name: 'inbox', // 'inbox' index on active_at order: 'desc' // ORDER timestamp DESC // TODO pagination/infinite scroll // limit: 10, offset: page*10, }, remove: false }); } }); Whisper.Conversation.COLORS = COLORS.concat(['grey', 'default']).join(' '); // Special collection for fetching all the groups a certain number appears in Whisper.GroupCollection = Backbone.Collection.extend({ 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)); } }); })();