/* * vim: ts=4:sw=4:expandtab */ (function () { 'use strict'; window.Whisper = window.Whisper || {}; var Message = window.Whisper.Message = Backbone.Model.extend({ database : Whisper.Database, storeName : 'messages', initialize: function() { this.on('change:attachments', this.updateImageUrl); this.on('destroy', this.revokeImageUrl); }, 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); }, isGroupUpdate: function() { return !!(this.get('group_update')); }, isIncoming: function() { return this.get('type') === 'incoming'; }, getDescription: function() { if (this.isGroupUpdate()) { var group_update = this.get('group_update'); if (group_update.left) { return group_update.left + ' left the group.'; } var messages = ['Updated the group.']; if (group_update.name) { messages.push("Title is now '" + group_update.name + "'."); } if (group_update.joined) { messages.push(group_update.joined.join(', ') + ' joined the group.'); } return messages.join(' '); } if (this.isEndSession()) { return 'Secure session ended.'; } if (this.isIncoming() && this.hasKeyConflicts()) { return 'Received message with unknown identity key.'; } if (this.isIncoming() && this.hasErrors()) { return 'Error handling incoming message.'; } return this.get('body'); }, getNotificationText: function() { var description = this.getDescription(); if (description) { return description; } if (this.get('attachments').length > 0) { return 'Media message'; } return ''; }, 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; }, getContact: function() { var conversationId = this.get('source'); if (!this.isIncoming()) { conversationId = textsecure.storage.user.getNumber(); } var c = ConversationController.get(conversationId); if (!c) { c = ConversationController.create({id: conversationId, type: 'private'}); c.fetch(); } return c; }, isOutgoing: function() { return this.get('type') === 'outgoing'; }, hasErrors: function() { return _.size(this.get('errors')) > 0; }, hasKeyConflicts: function() { return _.any(this.get('errors'), function(e) { return (e.name === 'IncomingIdentityKeyError' || e.name === 'OutgoingIdentityKeyError'); }); }, hasKeyConflict: function(number) { return _.any(this.get('errors'), function(e) { return (e.name === 'IncomingIdentityKeyError' || e.name === 'OutgoingIdentityKeyError') && e.number === number; }); }, getKeyConflict: function(number) { return _.find(this.get('errors'), function(e) { return (e.name === 'IncomingIdentityKeyError' || e.name === 'OutgoingIdentityKeyError') && e.number === number; }); }, send: function(promise) { this.trigger('pending'); return promise.then(function() { this.trigger('done'); this.save({sent: true}); }.bind(this)).catch(function(errors) { this.trigger('done'); this.set({sent: true}); this.saveErrors(errors); }.bind(this)); }, saveErrors: function(errors) { if (!(errors instanceof Array)) { errors = [errors]; } errors.forEach(function(e) { console.log(e); console.log(e.reason, e.stack); }); errors = errors.map(function(e) { if (e.constructor === Error || e.constructor === TypeError) { return _.pick(e, 'name', 'message', 'code', 'number', 'reason'); } return e; }); errors = errors.concat(this.get('errors') || []); return this.save({errors : errors}); }, removeConflictFor: function(number) { var errors = _.reject(this.get('errors'), function(e) { return e.number === number && (e.name === 'IncomingIdentityKeyError' || e.name === 'OutgoingIdentityKeyError'); }); this.set({errors: errors}); }, removeOutgoingErrors: function(number) { var errors = _.partition(this.get('errors'), function(e) { return e.number === number && (e.name === 'OutgoingMessageError' || e.name === 'SendMessageNetworkError'); }); this.set({errors: errors[1]}); return errors[0][0]; }, resend: function(number) { var error = this.removeOutgoingErrors(number); if (error) { var promise = new textsecure.ReplayableError(error).replay(); this.send(promise); } }, resolveConflict: function(number) { var error = this.getKeyConflict(number); if (error) { this.removeConflictFor(number); var promise = new textsecure.ReplayableError(error).replay(); if (this.isIncoming()) { promise = promise.then(function(dataMessage) { this.handleDataMessage(dataMessage); }.bind(this)); } else { promise = promise.then(function() { this.save(); }.bind(this)); } promise.catch(function(e) { this.saveErrors(e); }.bind(this)); return promise; } }, handleDataMessage: function(dataMessage) { // This function can be called from the background script on an // incoming message or from the frontend after the user accepts an // identity key change. 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; } var conversation = ConversationController.create({id: conversationId}); conversation.fetch().always(function() { var now = new Date().getTime(); var attributes = { type: 'private' }; if (dataMessage.group) { var group_update = {}; 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 : dataMessage.group.members, }; group_update = conversation.changedAttributes(_.pick(dataMessage.group, 'name', 'avatar')) || {}; var difference = _.difference(dataMessage.group.members, conversation.get('members')); if (difference.length > 0) { group_update.joined = difference; } } else if (dataMessage.group.type === textsecure.protobuf.GroupContext.Type.QUIT) { group_update = { left: source }; attributes.members = _.without(conversation.get('members'), source); } if (_.keys(group_update).length > 0) { message.set({group_update: group_update}); } } if (type === 'outgoing') { // lazy hack - check for receipts that arrived early. if (dataMessage.group && dataMessage.group.id) { // group sync var members = conversation.get('members') || []; var receipts = window.receipts.where({ timestamp: timestamp }); for (var i in receipts) { if (members.indexOf(receipts[i].get('source')) > -1) { window.receipts.remove(receipts[i]); message.set({ delivered: (message.get('delivered') || 0) + 1 }); } } } else { var receipt = window.receipts.findWhere({ timestamp: timestamp, source: conversationId }); if (receipt) { window.receipts.remove(receipt); message.set({ delivered: (message.get('delivered') || 0) + 1 }); } } } attributes.active_at = now; if (type === 'incoming') { attributes.unreadCount = conversation.get('unreadCount') + 1; } conversation.set(attributes); message.set({ body : dataMessage.body, conversationId : conversation.id, attachments : dataMessage.attachments, decrypted_at : now, flags : dataMessage.flags, errors : [] }); var conversation_timestamp = conversation.get('timestamp'); if (!conversation_timestamp || message.get('sent_at') > conversation_timestamp) { conversation.set({ timestamp: message.get('sent_at'), lastMessage: message.getNotificationText() }); } else if (!conversation.get('lastMessage')) { conversation.set({ lastMessage: message.getNotificationText() }); } conversation.save().then(function() { message.save().then(function() { if (message.isIncoming()) { notifyConversation(message); } else { conversation.trigger('newmessages'); } }); }); }); } }); Whisper.MessageCollection = Backbone.Collection.extend({ model : Message, database : Whisper.Database, storeName : 'messages', comparator : 'received_at', 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); }); })); }, fetchSentAt: function(timestamp) { return this.fetch({ index: { // 'receipt' index on sent_at name: 'receipt', only: timestamp } }); }, fetchConversation: function(conversationId) { var options = {remove: false}; options.index = { // 'conversation' index on [conversationId, received_at] name : 'conversation', lower : [conversationId], upper : [conversationId, Number.MAX_VALUE] // SELECT messages WHERE conversationId = this.id ORDER // received_at DESC }; // TODO pagination/infinite scroll // limit: 10, offset: page*10, return this.fetch(options); }, hasKeyConflicts: function() { return this.any(function(m) { return m.hasKeyConflicts(); }); } }); })();