signal-desktop/js/models/messages.js
lilia 8453424ebd Fix stuck pending messages state
Refactor outgoing message error handling to use the same success and
error handlers. This creates a somewhat strange pattern, where we call
send and pass in the promise that resolves when sending is complete, but
there's enough variety in the libtextsecure syntax for different message
sending routines that it belongs at the conversation level and only the
post-processing stuff is really shared by all messages.

// FREEBIE
2015-09-28 13:33:26 -07:00

352 lines
14 KiB
JavaScript

/*
* 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.';
}
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});
c.fetch();
}
return c;
},
isOutgoing: function() {
return this.get('type') === 'outgoing';
},
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) {
return promise.then(function() {
this.save({sent: true});
}.bind(this)).catch(function(errors) {
this.save({sent: true});
if (errors instanceof Error) {
errors = [errors];
}
var keyErrors = [];
_.each(errors, function(e) {
console.log(e);
console.log(e.stack);
if (e.error.name === 'OutgoingIdentityKeyError') {
keyErrors.push(e.error);
}
});
if (keyErrors.length) {
message.save({ errors : keyErrors });
} else {
if (!(errors instanceof Array)) {
errors = [errors];
}
errors.map(function(e) {
if (e.error && e.error.stack) {
console.error(e.error.stack);
}
});
throw errors;
}
}.bind(this));
},
resolveConflict: function(number) {
var error = this.getKeyConflict(number);
if (error) {
var promise = new textsecure.ReplayableError(error).replay();
if (this.isIncoming()) {
promise.then(function(dataMessage) {
this.handleDataMessage(dataMessage);
this.save('errors', []);
}.bind(this)).catch(function(e) {
//this.save('errors', [_.pick(e, ['name', 'message'])]);
var errors = this.get('errors').concat(
_.pick(e, ['name', 'message'])
);
this.save('errors', errors);
}.bind(this));
} else {
promise.then(function() {
var errors = _.reject(this.get('errors'), function(e) {
return e.name === 'OutgoingIdentityKeyError' &&
e.number === number;
});
this.save({sent: true, errors: errors});
}.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.get('body')
});
}
else if (!conversation.get('lastMessage')) {
conversation.set({
lastMessage: message.get('body')
});
}
conversation.save().then(function() {
message.save().then(function() {
if (message.isIncoming()) {
notifyConversation(message);
}
});
});
});
}
});
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(); });
}
});
})();