Improve experience when discovering identity key error on send

New experience in the Message Detail view when outgoing identity key
errors happen, matching the Android View.

'View' button is only shown on outgoing key errors right now.

When a contact with an outgoing identity key error is clicked, they are
taken to a view like the popup that comes up on Android: an explanation
of what happened and three options: 'Show Safety Number', 'Send Anyway',
and 'Cancel'

Contacts are now sorted alphabetically, with the set of contacts with
errors coming before the rest.

FREEBIE
This commit is contained in:
Scott Nonnenberg 2017-07-03 11:52:12 -07:00
parent b6cca41a0c
commit 12914307f1
9 changed files with 271 additions and 65 deletions

View file

@ -202,6 +202,14 @@
}.bind(this)));
}
},
setTrusted: 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);

View file

@ -202,6 +202,13 @@
}.bind(this)));
},
markAllAsApproved: function(untrusted) {
return Promise.all(untrusted.map(function(contact) {
return contact.setApproved();
}.bind(this)));
},
openSafetyNumberScreens: function(unverified) {
if (unverified.length === 1) {
this.showSafetyNumber(null, unverified.at(0));
@ -620,7 +627,10 @@
messageDetail: function(e, data) {
var view = new Whisper.MessageDetailView({
model: data.message,
conversation: this.model
conversation: this.model,
// we pass these in to allow nested panels
listenBack: this.listenBack.bind(this),
resetPanel: this.resetPanel.bind(this)
});
this.listenBack(view);
view.render();
@ -749,10 +759,17 @@
_.defaults(options, {force: false});
this.model.getUntrusted().then(function(contacts) {
if (!contacts.length || options.force) {
if (!contacts.length) {
return this.sendMessage(e);
}
if (options.force) {
return this.markAllAsApproved(contacts).then(function() {
this.sendMessage(e);
}.bind(this));
}
this.showSendConfirmationDialog(e, contacts);
}.bind(this));
},

View file

@ -0,0 +1,51 @@
/*
* vim: ts=4:sw=4:expandtab
*/
(function () {
'use strict';
window.Whisper = window.Whisper || {};
Whisper.IdentityKeySendErrorPanelView = Whisper.View.extend({
className: 'identity-key-send-error panel',
templateName: 'identity-key-send-error',
initialize: function(options) {
this.listenBack = options.listenBack;
this.resetPanel = options.resetPanel;
this.wasUnverified = this.model.isUnverified();
this.listenTo(this.model, 'change', this.render);
},
events: {
'click .show-safety-number': 'showSafetyNumber',
'click .send-anyway': 'sendAnyway',
'click .cancel': 'cancel'
},
showSafetyNumber: function() {
var view = new Whisper.KeyVerificationPanelView({
model: this.model
});
this.listenBack(view);
},
sendAnyway: function() {
this.resetPanel();
this.trigger('send-anyway');
},
cancel: function() {
this.resetPanel();
},
render_attributes: function() {
var send = i18n('sendAnyway');
if (this.wasUnverified && !this.model.isUnverified()) {
send = i18n('resend');
}
var errorExplanation = i18n('identityKeyErrorOnSend', this.model.getTitle(), this.model.getTitle());
return {
errorExplanation : errorExplanation,
showSafetyNumber : i18n('showSafetyNumber'),
sendAnyway : send,
cancel : i18n('cancel')
};
}
});
})();

View file

@ -9,18 +9,68 @@
className: 'contact-detail',
templateName: 'contact-detail',
initialize: function(options) {
this.errors = _.reject(options.errors, function(e) {
return (e.name === 'OutgoingIdentityKeyError' ||
e.name === 'OutgoingMessageError' ||
e.name === 'SendMessageNetworkError');
});
this.listenBack = options.listenBack;
this.resetPanel = options.resetPanel;
this.message = options.message;
var newIdentity = i18n('newIdentity');
this.errors = _.map(options.errors, function(error) {
if (error.name === 'OutgoingIdentityKeyError') {
error.message = newIdentity;
}
return error;
});
this.outgoingKeyError = _.find(this.errors, function(error) {
return error.name === 'OutgoingIdentityKeyError';
});
},
events: {
'click': 'onClick'
},
onClick: function() {
if (this.outgoingKeyError) {
var view = new Whisper.IdentityKeySendErrorPanelView({
model: this.model,
listenBack: this.listenBack,
resetPanel: this.resetPanel
});
this.listenTo(view, 'send-anyway', this.onSendAnyway);
view.render();
this.listenBack(view);
}
// TODO: is there anything we might want to do here? Pop a confirmation dialog? Ideally it would always have error-specific help.
},
forceSend: function() {
this.model.updateVerified().then(function() {
if (this.model.isUnverified()) {
return this.model.setVerifiedDefault();
}
}.bind(this)).then(function() {
return this.model.isUntrusted();
}.bind(this)).then(function(untrusted) {
if (untrusted) {
return this.model.setTrusted();
}
}.bind(this)).then(function() {
this.message.resend(this.outgoingKeyError.number);
}.bind(this));
},
onSendAnyway: function() {
if (this.outgoingKeyError) {
this.forceSend();
}
},
render_attributes: function() {
var showButton = Boolean(this.outgoingKeyError);
return {
name : this.model.getTitle(),
avatar : this.model.getAvatar(),
errors : this.errors
name : this.model.getTitle(),
avatar : this.model.getAvatar(),
errors : this.errors,
showErrorButton : showButton,
errorButtonLabel : i18n('view')
};
}
});
@ -29,23 +79,15 @@
className: 'message-detail panel',
templateName: 'message-detail',
initialize: function(options) {
this.listenBack = options.listenBack;
this.resetPanel = options.resetPanel;
this.view = new Whisper.MessageView({model: this.model});
this.view.render();
this.conversation = options.conversation;
this.listenTo(this.model, 'change', this.render);
},
events: {
'click button.retry': 'onRetry'
},
onRetry: function(e) {
var number = _.find(e.target.attributes, function(attribute) {
return attribute.name === 'data-number';
});
if (number) {
this.model.resend(number.value);
}
},
getContact: function(number) {
var c = ConversationController.get(number);
return {
@ -53,15 +95,6 @@
title: c ? c.getTitle() : number
};
},
buildRetryTargetList: function() {
var targets = _.filter(this.model.get('errors'), function(e) {
return e.number && e.name === 'OutgoingIdentityKeyError';
});
return _.map(targets, function(e) {
return this.getContact(e.number);
}.bind(this));
},
contacts: function() {
if (this.model.isIncoming()) {
var number = this.model.get('source');
@ -71,25 +104,25 @@
}
},
renderContact: function(contact) {
var grouped = _.groupBy(this.model.get('errors'), 'number');
var view = new ContactView({
model: contact,
errors: grouped[contact.id]
errors: this.grouped[contact.id],
listenBack: this.listenBack,
resetPanel: this.resetPanel,
message: this.model
}).render();
this.$('.contacts').append(view.el);
},
render: function() {
var retryTargets = this.buildRetryTargetList();
var allowRetry = retryTargets.length > 0;
var errorsWithoutNumber = _.reject(this.model.get('errors'), function(error) {
return Boolean(error.number);
});
this.$el.html(Mustache.render(_.result(this, 'template', ''), {
sent_at : moment(this.model.get('sent_at')).format('LLLL'),
received_at : this.model.isIncoming() ? moment(this.model.get('received_at')).format('LLLL') : null,
tofrom : this.model.isIncoming() ? i18n('from') : i18n('to'),
errors : this.model.get('errors'),
allowRetry : allowRetry,
retryTargets : retryTargets,
errors : errorsWithoutNumber,
title : i18n('messageDetail'),
sent : i18n('sent'),
received : i18n('received'),
@ -98,14 +131,21 @@
}));
this.view.$el.prependTo(this.$('.message-container'));
this.grouped = _.groupBy(this.model.get('errors'), 'number');
if (this.model.isOutgoing()) {
this.conversation.contactCollection.reject(function(c) {
var contacts = this.conversation.contactCollection.reject(function(c) {
return c.isMe();
}).forEach(this.renderContact.bind(this));
});
_.sortBy(contacts, function(c) {
var prefix = this.grouped[c.id] ? '0' : '1';
// this prefix ensures that contacts with errors are listed first;
// otherwise it's alphabetical
return prefix + c.getTitle();
}.bind(this)).forEach(this.renderContact.bind(this));
} else {
this.renderContact(
this.conversation.contactCollection.get(this.model.get('source'))
);
var c = this.conversation.contactCollection.get(this.model.get('source'));
this.renderContact(c);
}
}
});