Improve identity key conflict ux

Clicking on a key conflict message opens the message detail view,
which displays the contact(s) in this conversation. If the message
contains a key conflict with any of these contacts, a button is
displayed which attempts to resolve that conflict and any other
conflicts in the conversation that are related to that contact.
This commit is contained in:
lilia 2015-02-17 18:03:05 -08:00
parent 857eee5003
commit 897d391817
11 changed files with 415 additions and 81 deletions

View File

@ -119,28 +119,18 @@
<button class='back'></button>
<span class='title-text'>Message Detail</span>
</div>
<div class='message-container'></div>
<div class='info'>
<table>
<tr><td class='label'>Sent</td><td> {{ sent_at }}</td></tr>
<tr><td class='label'>Received</td><td> {{ received_at }}</td></tr>
<tr>
<td class='tofrom label'>{{ tofrom }}</td>
<td>
<div class='contacts'>
{{ #contacts }}
<div>
<span class='avatar'><img src='{{ avatar_url }}' /></span>
<span class='name'>{{ name }}</div>
{{ #conflict }}
<button class='resolve'>Key Conflict</button>
{{ /conflict }}
</div>
{{ /contacts }}
</div>
</td>
</tr>
</table>
<div class='container'>
<div class='message-container'></div>
<div class='info'>
<table>
<tr><td class='label'>Sent</td><td> {{ sent_at }}</td></tr>
<tr><td class='label'>Received</td><td> {{ received_at }}</td></tr>
<tr>
<td class='tofrom label'>{{ tofrom }}</td>
<td> <div class='contacts'></div> </td>
</tr>
</table>
</div>
</div>
</script>
<script type='text/x-tmpl-mustache' id='key-verification'>
@ -200,6 +190,42 @@
<div class='contacts'></div>
</div>
</script>
<script type='text/x-tmpl-mustache' id='incoming-key-conflict'>
<p>
Received message with unknown identity key.
Click to process and display.
</p>
</script>
<script type='text/x-tmpl-mustache' id='outgoing-key-conflict'>
<p>
This contact's identity key.has changed.
Click to process and display.
</p>
</script>
<script type='text/x-tmpl-mustache' id='generic-error'>
<p>{{ message }}</p>
</script>
<script type='text/x-tmpl-mustache' id='contact-detail'>
<div>
<span class='avatar'><img src='{{ avatar_url }}' /></span>
<span class='name'>{{ name }}</span>
{{ #conflict }}
<button class='conflict'><span>Verify</span></button>
{{ /conflict }}
</div>
</script>
<script type='text/x-tmpl-mustache' id='key-conflict-dialogue'>
<div class='content'>
<p> {{ message }} </p>
<p>
You may wish to <span class='verify'>verify</span> this contact.
</p>
<p>
<button class='cancel'>Cancel</button>
<button class='resolve'>Accept new key</button>
</p>
</div>
</script>
<script type="text/javascript" src="js/components.js"></script>
<script type="text/javascript" src="js/libtextsecure.js"></script>
@ -223,6 +249,8 @@
<script type="text/javascript" src="js/views/end_session_view.js"></script>
<script type="text/javascript" src="js/views/group_update_view.js"></script>
<script type="text/javascript" src="js/views/attachment_view.js"></script>
<script type="text/javascript" src="js/views/key_conflict_dialogue_view.js"></script>
<script type="text/javascript" src="js/views/error_view.js"></script>
<script type="text/javascript" src="js/views/message_view.js"></script>
<script type="text/javascript" src="js/views/key_verification_view.js"></script>
<script type="text/javascript" src="js/views/message_detail_view.js"></script>

View File

@ -221,33 +221,24 @@
return this.avatarUrl || '/images/default.png';
},
resolveConflicts: function() {
if (!this.isPrivate()) {
throw "Can't call resolveConflicts on non-private conversation";
resolveConflicts: function(number) {
if (this.isPrivate()) {
number = this.id;
} else if (!_.find(this.get('members'), number)) {
throw 'Tried to resolve conflicts for a unknown group member';
}
if (!this.messageCollection.find(function(m) { return m.getKeyConflict(); })) {
throw "No conflicts to resolve";
if (!this.messageCollection.hasKeyConflicts()) {
throw 'No conflicts to resolve';
}
textsecure.storage.devices.removeIdentityKeyForNumber(this.get('id'));
textsecure.storage.devices.removeIdentityKeyForNumber(number);
this.messageCollection.each(function(message) {
var conflict = message.getKeyConflict();
if (conflict) {
new textsecure.ReplayableError(conflict).replay().
then(function(pushMessageContent) {
message.save('errors', []);
if (message.isIncoming()) {
extension.trigger('message:decrypted', {
message_id: message.id,
data: pushMessageContent
});
}
});
if (message.hasKeyConflict(number)) {
message.resolveConflict(number);
}
});
}
});
Whisper.ConversationCollection = Backbone.Collection.extend({

View File

@ -51,11 +51,53 @@
isOutgoing: function() {
return this.get('type') === 'outgoing';
},
getKeyConflict: function() {
return _.find(this.get('errors'), function(e) {
return ( e.name === 'IncomingIdentityKeyError' ||
e.name === 'OutgoingIdentityKeyError');
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;
});
},
resolveConflict: function(number) {
var error = this.getKeyConflict(number);
if (error) {
var promise = new textsecure.ReplayableError(error).replay();
if (this.isIncoming()) {
promise.then(function(pushMessageContent) {
extension.trigger('message:decrypted', {
message_id: this.id,
data: pushMessageContent
});
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() {
this.save('errors', _.reject(this.get('errors'), function(e) {
return e.name === 'OutgoingIdentityKeyError' &&
e.number === number;
}));
}.bind(this));
}
}
}
});
@ -100,6 +142,10 @@
// TODO pagination/infinite scroll
// limit: 10, offset: page*10,
return this.fetch(options);
},
hasKeyConflicts: function() {
return this.any(function(m) { return m.hasKeyConflicts(); });
}
});
})();

View File

@ -103,7 +103,7 @@
this.listenTo(view, 'back', function() {
view.remove();
this.$el.show();
}.bind(this));
});
},
closeMenu: function(e) {

72
js/views/error_view.js Normal file
View File

@ -0,0 +1,72 @@
/* vim: ts=4:sw=4:expandtab
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
(function () {
'use strict';
window.Whisper = window.Whisper || {};
var ErrorView = Backbone.View.extend({
className: 'error',
initialize: function() {
this.template = $('#generic-error').html();
Mustache.parse(this.template);
},
render: function() {
this.$el.html(Mustache.render(this.template, this.model));
return this;
}
});
var KeyConflictView = ErrorView.extend({
className: 'key-conflict',
initialize: function(options) {
this.message = options.message;
if (this.message.isIncoming()) {
this.template = $('#incoming-key-conflict').html();
} else if (this.message.isOutgoing()) {
this.template = $('#outgoing-key-conflict').html();
}
Mustache.parse(this.template);
},
events: {
'click': 'select'
},
select: function() {
this.$el.trigger('select', {message: this.message});
},
});
Whisper.MessageErrorView = Backbone.View.extend({
className: 'error',
initialize: function(options) {
if (this.model.name === 'IncomingIdentityKeyError' ||
this.model.name === 'OutgoingIdentityKeyError') {
this.view = new KeyConflictView({
model : this.model,
message : options.message
});
} else {
this.view = new ErrorView({ model: this.model });
}
this.$el.append(this.view.el);
this.view.render();
},
render: function() {
this.view.render();
return this;
}
});
})();

View File

@ -0,0 +1,54 @@
/* vim: ts=4:sw=4:expandtab
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
(function () {
'use strict';
window.Whisper = window.Whisper || {};
Whisper.KeyConflictDialogueView = Backbone.View.extend({
className: 'key-conflict-dialogue',
initialize: function(options) {
this.template = $('#key-conflict-dialogue').html();
Mustache.parse(this.template);
this.conversation = options.conversation;
},
events: {
'click .verify': 'triggerVerify',
'click .resolve': 'resolve',
'click .cancel': 'remove',
'click': 'clickOut'
},
triggerVerify: function() {
this.trigger('verify', {number: this.model.number});
},
clickOut: function(e) {
if (!$(e.target).closest('.content').length) {
this.remove();
}
},
resolve: function() {
new Promise(function() {
this.conversation.resolveConflicts(this.model.number);
}.bind(this));
this.trigger('resolve');
this.remove();
},
render: function() {
this.$el.html(Mustache.render(this.template, this.model));
return this;
}
});
})();

View File

@ -17,7 +17,30 @@
'use strict';
window.Whisper = window.Whisper || {};
Whisper.MessageDetailView = Whisper.View.extend({
var ContactView = Backbone.View.extend({
className: 'contact-detail',
initialize: function(options) {
this.template = $('#contact-detail').html();
Mustache.parse(this.template);
this.conflict = options.conflict;
},
events: {
'click .conflict': 'triggerConflict'
},
triggerConflict: function() {
this.$el.trigger('conflict', {conflict: this.conflict});
},
render: function() {
this.$el.html(Mustache.render(this.template, {
name : this.model.getTitle(),
avatar_url : this.model.getAvatarUrl(),
conflict : this.conflict
}));
return this;
}
});
Whisper.MessageDetailView = Backbone.View.extend({
className: 'message-detail',
template: $('#message-detail').html(),
initialize: function(options) {
@ -26,7 +49,7 @@
},
events: {
'click .back': 'goBack',
'verify': 'verify'
'conflict': 'conflictDialogue'
},
goBack: function() {
this.trigger('back');
@ -55,19 +78,33 @@
return this.conversation.contactCollection.models;
}
},
conflictDialogue: function(e, data) {
var view = new Whisper.KeyConflictDialogueView({
model: data.conflict,
conversation: this.conversation
});
view.render().$el.appendTo(this.$el);
this.listenTo(view, 'verify', function(data) {
this.verify(data.number);
});
this.listenTo(view, 'resolve', function() {
this.render();
});
},
render: function() {
this.$el.html(Mustache.render(this.template, {
sent_at: moment(this.model.get('sent_at')).toString(),
received_at: moment(this.model.get('received_at')).toString(),
tofrom: this.model.isIncoming() ? 'From' : 'To',
contacts: this.contacts().map(function(contact) {
return {
name : contact.getTitle(),
avatar_url : contact.getAvatarUrl()
};
}.bind(this))
sent_at : moment(this.model.get('sent_at')).toString(),
received_at : moment(this.model.get('received_at')).toString(),
tofrom : this.model.isIncoming() ? 'From' : 'To',
}));
this.view.render().$el.prependTo(this.$el.find('.message-container'));
this.conversation.contactCollection.each(function(contact) {
var v = new ContactView({
model: contact,
conflict: this.model.getKeyConflict(contact.id)
}).render().$el.appendTo(this.$el.find('.contacts'));
}.bind(this));
}
});

View File

@ -17,20 +17,6 @@
'use strict';
window.Whisper = window.Whisper || {};
var ErrorView = Backbone.View.extend({
className: 'error',
events: {
'click' : 'replay'
},
replay: function() {
new window.textsecure.ReplayableError(this.model).replay();
},
render: function() {
this.$el.text(this.model.message);
return this;
}
});
var ContentMessageView = Whisper.View.extend({
tagName: 'div',
template: $('#message').html(),
@ -75,10 +61,13 @@
var errors = this.model.get('errors');
if (errors && errors.length) {
this.$el.find('.bubble').append(
this.$el.find('.bubble').prepend(
errors.map(function(error) {
return new ErrorView({model: error}).render().el;
})
return new Whisper.MessageErrorView({
model: error,
message: this.model
}).render().el;
}.bind(this))
);
}
}

View File

@ -29,6 +29,11 @@
padding: $header-height 0 0;
background: $grey_l;
.container {
height: 100%;
overflow: auto;
}
.message-container {
background: white;
padding: 1em 0;
@ -47,6 +52,34 @@
padding-right: 1em;
}
}
.avatar img {
vertical-align: middle;
}
.conflict {
border: none;
border-radius: 5px;
color: white;
padding: 0.5em;
font-weight: bold;
background: #d00;
&:before {
content: '';
display: inline-block;
vertical-align: middle;
width: 18px;
height: 18px;
background: url('/images/error.png') no-repeat center center;
background-size: 100%;
}
span {
vertical-align: middle;
padding-left: 5px;
}
}
}
.group-update {
@ -112,10 +145,6 @@
}
}
p {
margin: 0;
}
.bubble {
position: relative;
left: -2px;
@ -148,6 +177,9 @@
.content a {
word-break: break-all
}
p {
margin: 0;
}
}
.incoming {
@ -225,6 +257,40 @@
font-style: italic;
opacity: 0.8;
}
.error {
font-style: italic;
}
.key-conflict {
padding: 15px 10px;
button {
margin-top: 5px;
}
}
}
.key-conflict-dialogue {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
padding: $header-height;
.content {
padding: 1em;
background: white;
color: black;
box-shadow: 0 0 5px 0 black;
}
.verify {
color: $blue;
text-decoration: underline;
cursor: pointer;
}
}
.bottom-bar {

View File

@ -7,6 +7,7 @@ html {
}
body {
position: relative;
height: 100%;
width: 100%;
margin: 0;

View File

@ -19,6 +19,7 @@ html {
height: 100%; }
body {
position: relative;
height: 100%;
width: 100%;
margin: 0;
@ -379,6 +380,9 @@ input.search {
.message-detail {
padding: 36px 0 0;
background: #f3f3f3; }
.message-detail .container {
height: 100%;
overflow: auto; }
.message-detail .message-container {
background: white;
padding: 1em 0; }
@ -390,6 +394,26 @@ input.search {
text-align: right;
font-weight: bold;
padding-right: 1em; }
.message-detail .avatar img {
vertical-align: middle; }
.message-detail .conflict {
border: none;
border-radius: 5px;
color: white;
padding: 0.5em;
font-weight: bold;
background: #d00; }
.message-detail .conflict:before {
content: '';
display: inline-block;
vertical-align: middle;
width: 18px;
height: 18px;
background: url("/images/error.png") no-repeat center center;
background-size: 100%; }
.message-detail .conflict span {
vertical-align: middle;
padding-left: 5px; }
.group-update {
font-size: smaller; }
@ -437,9 +461,6 @@ input.search {
content: " ";
clear: both;
height: 0; }
.message-detail p,
.message-list p {
margin: 0; }
.message-detail .bubble,
.message-list .bubble {
position: relative;
@ -470,6 +491,9 @@ input.search {
.message-detail .bubble .content a,
.message-list .bubble .content a {
word-break: break-all; }
.message-detail .bubble p,
.message-list .bubble p {
margin: 0; }
.message-detail .incoming .bubble,
.message-list .incoming .bubble {
color: #454545;
@ -528,6 +552,32 @@ input.search {
font: small;
font-style: italic;
opacity: 0.8; }
.message-detail .error,
.message-list .error {
font-style: italic; }
.message-detail .key-conflict,
.message-list .key-conflict {
padding: 15px 10px; }
.message-detail .key-conflict button,
.message-list .key-conflict button {
margin-top: 5px; }
.key-conflict-dialogue {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
padding: 36px; }
.key-conflict-dialogue .content {
padding: 1em;
background: white;
color: black;
box-shadow: 0 0 5px 0 black; }
.key-conflict-dialogue .verify {
color: #2a92e7;
text-decoration: underline;
cursor: pointer; }
.bottom-bar {
position: fixed;