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

@ -3,6 +3,10 @@
"message": "Me",
"description": "The label for yourself when shown in a group member list"
},
"view": {
"message": "View",
"description": "Used as a label on a button allowing user to see more information"
},
"youLeftTheGroup": {
"message": "You left the group",
"description": "Displayed when a user can't send a message because they have left the group"
@ -105,9 +109,15 @@
}
}
},
"retryDescription": {
"message": "You can retry sending this message to each of the failed recipients with these buttons:",
"description": "Shows on the message details view when it's a message error which can be retried."
"identityKeyErrorOnSend": {
"message": "Your safety number with $name$ has changed. This could either mean that someone is trying to intercept your communication or that $name$ has simply reinstalled Signal. You may wish to verify your saftey number with this contact.",
"description": "Shown when user clicks on a failed recipient in the message detail view after an identity key change",
"placeholders": {
"name": {
"content": "$1",
"example": "Bob"
}
}
},
"sendAnyway": {
"message": "Send Anyway",

View File

@ -297,14 +297,6 @@
<script type='text/x-tmpl-mustache' id='message-detail'>
<div class='container'>
<div class='message-container'></div>
{{ #allowRetry }}
<div class='retries'>
<div>{{ retryDescription }}</div>
{{ #retryTargets }}
<button class='retry gray' data-number='{{ number }}'>{{ title }}</button>
{{ /retryTargets }}
</div>
{{ /allowRetry }}
<div class='info'>
<table>
{{ #errors }}
@ -330,6 +322,20 @@
</div>
</div>
</script>
<script type='text/x-tmpl-mustache' id='identity-key-send-error'>
<div class='container'>
<div class='explanation'>
{{ errorExplanation }}
</div>
<div class='safety-number'>
<button class='show-safety-number grey'>{{ showSafetyNumber }}</button>
</div>
<div class='actions'>
<button class='send-anyway grey'>{{ sendAnyway }}</button>
<button class='cancel grey'>{{ cancel }}</button>
</div>
</div>
</script>
<script type='text/x-tmpl-mustache' id='group-member-list'>
<div class='container'>
{{ #summary }} <div class='summary'>{{ summary }}</div>{{ /summary }}
@ -415,7 +421,15 @@
<div class='contact-details'>
{{ #errors }}
<div class='error-icon-container'>
<span class='error-icon'></span>
{{ #showErrorButton }}
<button class='error'>
<span class='icon error'></span>
{{ errorButtonLabel }}
</button>
{{ /showErrorButton }}
{{ ^showErrorButton }}
<span class='error-icon'></span>
{{ /showErrorButton }}
</div>
{{ /errors }}
<span class='name' dir='auto'>{{ name }}</span>
@ -680,8 +694,9 @@
<script type='text/javascript' src='js/views/confirmation_dialog_view.js'></script>
<script type='text/javascript' src='js/views/identicon_svg_view.js'></script>
<script type='text/javascript' src='js/views/settings_view.js'></script>
<script type="text/javascript" src="js/views/install_view.js"></script>
<script type="text/javascript" src="js/views/banner_view.js"></script>
<script type='text/javascript' src='js/views/install_view.js'></script>
<script type='text/javascript' src='js/views/banner_view.js'></script>
<script type='text/javascript' src='js/views/identity_key_send_error_view.js'></script>
<script type='text/javascript' src='js/wall_clock_listener.js'></script>
<script type='text/javascript' src='js/rotate_signed_prekey_listener.js'></script>

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

View File

@ -39,7 +39,7 @@
.container {
padding-top: 20px;
max-width: 950px;
max-width: 750px;
margin: 0 auto;
padding: 20px;
}
@ -77,10 +77,6 @@
}
.key-verification {
.container {
max-width: 750px;
}
label {
display: block;
margin: 10px 0;
@ -150,6 +146,24 @@
}
}
.identity-key-send-error {
button {
margin-top: 0px;
margin-bottom: 0px;
}
.explanation {
margin-top: 20px;
}
.safety-number {
margin-top: 30px;
text-align: center;
}
.actions {
margin-top: 30px;
text-align: center;
}
}
.message-detail {
background-color: #eee;
@ -198,6 +212,20 @@
float: right;
}
button.error {
background-color: red;
color: white;
span.icon.error {
display: inline-block;
width: 1.25em;
height: 1.25em;
position: relative;
vertical-align: middle;
@include color-svg('/images/warning.svg', white);
}
}
.error-message {
margin: 6px 0 0;
font-size: $font-size-small;

View File

@ -1057,7 +1057,7 @@ input.search {
overflow-y: scroll; }
.conversation .panel .container {
padding-top: 20px;
max-width: 950px;
max-width: 750px;
margin: 0 auto;
padding: 20px; }
.conversation .main.panel {
@ -1083,8 +1083,6 @@ input.search {
.discussion-container {
background-color: #eee; }
.key-verification .container {
max-width: 750px; }
.key-verification label {
display: block;
margin: 10px 0;
@ -1139,6 +1137,18 @@ input.search {
padding: 10px;
margin: 0; }
.identity-key-send-error button {
margin-top: 0px;
margin-bottom: 0px; }
.identity-key-send-error .explanation {
margin-top: 20px; }
.identity-key-send-error .safety-number {
margin-top: 30px;
text-align: center; }
.identity-key-send-error .actions {
margin-top: 30px;
text-align: center; }
.message-detail {
background-color: #eee; }
.message-detail .message-container {
@ -1168,6 +1178,18 @@ input.search {
margin-bottom: 5px; }
.message-detail .contacts .contact-detail .error-icon-container {
float: right; }
.message-detail .contacts .contact-detail button.error {
background-color: red;
color: white; }
.message-detail .contacts .contact-detail button.error span.icon.error {
display: inline-block;
width: 1.25em;
height: 1.25em;
position: relative;
vertical-align: middle;
-webkit-mask: url("/images/warning.svg") no-repeat center;
-webkit-mask-size: 100%;
background-color: white; }
.message-detail .contacts .contact-detail .error-message {
margin: 6px 0 0;
font-size: 0.92857em;

View File

@ -316,6 +316,20 @@
</div>
</div>
</script>
<script type='text/x-tmpl-mustache' id='identity-key-send-error'>
<div class='container'>
<div class='explanation'>
{{ errorExplanation }}
</div>
<div class='safety-number'>
<button class='show-safety-number grey'>{{ showSafetyNumber }}</button>
</div>
<div class='actions'>
<button class='send-anyway grey'>{{ sendAnyway }}</button>
<button class='cancel grey'>{{ cancel }}</button>
</div>
</div>
</script>
<script type='text/x-tmpl-mustache' id='group-member-list'>
<div class='container'>
{{ #summary }} <div class='summary'>{{ summary }}</div>{{ /summary }}
@ -588,6 +602,7 @@
<script type='text/javascript' src='../js/views/last_seen_indicator_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/scroll_down_button_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/banner_view.js' data-cover></script>
<script type="text/javascript" src='../js/views/identity_key_send_error_view.js' data-cover></script>
<script type="text/javascript" src="views/whisper_view_test.js"></script>
<script type="text/javascript" src="views/group_update_view_test.js"></script>