Skip to content

Commit

Permalink
Improve experience when discovering identity key error on send
Browse files Browse the repository at this point in the history
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
  • Loading branch information
scottnonnenberg committed Aug 4, 2017
1 parent b6cca41 commit 1291430
Show file tree
Hide file tree
Showing 9 changed files with 270 additions and 64 deletions.
16 changes: 13 additions & 3 deletions _locales/en/messages.json
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
37 changes: 26 additions & 11 deletions background.html
Expand Up @@ -297,14 +297,6 @@ <h3 class='name' dir='auto'> {{ title }} </h3>
<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 }}
Expand All @@ -330,6 +322,20 @@ <h3 class='name' dir='auto'> {{ title }} </h3>
</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 }}
Expand Down Expand Up @@ -415,7 +421,15 @@ <h3 class='name' dir='auto'> {{ title }} </h3>
<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>
Expand Down Expand Up @@ -680,8 +694,9 @@ <h2 class='number'></h2>
<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>
Expand Down
8 changes: 8 additions & 0 deletions js/models/conversations.js
Expand Up @@ -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);
Expand Down
21 changes: 19 additions & 2 deletions js/views/conversation_view.js
Expand Up @@ -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));
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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));
},
Expand Down
51 changes: 51 additions & 0 deletions js/views/identity_key_send_error_view.js
@@ -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')
};
}
});
})();
120 changes: 80 additions & 40 deletions js/views/message_detail_view.js
Expand Up @@ -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')
};
}
});
Expand All @@ -29,39 +79,22 @@
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 {
number: number,
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');
Expand All @@ -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'),
Expand All @@ -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);
}
}
});
Expand Down

0 comments on commit 1291430

Please sign in to comment.