From bedf10056b0e7d0e532ed314e0cd734aeb051fe0 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Sat, 10 Jun 2017 12:18:24 -0700 Subject: [PATCH] Support for group-member verifications via second-level panel Also: - All the necessary wire-up to update things in real time. If you have a safety number page up via a group member view as well as via a 1:1 conversation with that contact, they'll both be updated as the underlying model changes. Similarly, the overall group will update in real-time as members change. - A bit of special-casing for yourself in a group conversation - you're shown as 'me' and are not clickable, where normally that would take you to the Safety Number screen for that contact. You are also not included in the trust calculations for a given group. FREEBIE --- _locales/en/messages.json | 35 +++++++++++-- background.html | 36 ++++++++----- js/models/conversations.js | 82 ++++++++++++++++++++++++++++++ js/views/contact_list_view.js | 31 ++++++++++- js/views/conversation_view.js | 67 +++++++++++++++++------- js/views/group_member_list_view.js | 11 ++-- js/views/key_verification_view.js | 19 +++++-- js/views/list_view.js | 4 +- stylesheets/_conversation.scss | 16 ++++++ stylesheets/_global.scss | 5 ++ stylesheets/manifest.css | 16 +++++- test/index.html | 5 +- 12 files changed, 281 insertions(+), 46 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 0eadf0ba3..880c5f26f 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1,4 +1,8 @@ { + "me": { + "message": "Me", + "description": "The label for yourself when shown in a group member list" + }, "youLeftTheGroup": { "message": "You left the group", "description": "Displayed when a user can't send a message because they have left the group" @@ -172,15 +176,40 @@ "message": "Send a message", "description": "Placeholder text in the message entry field" }, - "members": { - "message": "Members" + "groupMembers": { + "message": "Group members" + }, + "showMembers": { + "message": "Show members" }, "resetSession": { "message": "Reset session", "description": "This is a menu item for resetting the session, using the imperative case, as in a command." }, "showSafetyNumber": { - "message": "Show safety number" + "message": "Show safety number" + }, + "markAsNotVerified": { + "message": "Mark as not verified" + }, + "verifyHelp": { + "message": "If you wish to verify the security of your end-to-end encryption with $name$, compare the numbers above with the numbers on their device.", + "placeholders": { + "name": { + "content": "$1", + "example": "John" + } + } + }, + "isVerified": { + "message": "$name$ is verified", + "description": "If the user has manually marked a contact's safety number as verified, this string is shown on the 'Show Safety Number' screen", + "placeholders": { + "name": { + "content": "$1", + "example": "John" + } + } }, "theirIdentityUnknown": { "message": "You haven't exchanged any messages with this contact yet. Your safety number with them will be available after the first message." diff --git a/background.html b/background.html index 7b01094f4..b6e4913a6 100644 --- a/background.html +++ b/background.html @@ -66,6 +66,17 @@ + diff --git a/js/models/conversations.js b/js/models/conversations.js index a6b9a5417..fbc1f6edd 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -37,6 +37,8 @@ }, initialize: function() { + this.ourNumber = textsecure.storage.user.getNumber(); + this.contactCollection = new Backbone.Collection(); this.messageCollection = new Whisper.MessageCollection([], { conversation: this @@ -48,6 +50,84 @@ this.on('destroy', this.revokeAvatarUrl); }, + updateVerified: function() { + // TODO: replace this with the real call + function checkTrustStore() { + return Promise.resolve('default'); + } + + if (this.isPrivate()) { + return Promise.all([ + checkTrustStore(this.id), + this.fetch() + ]).then(function(results) { + var trust = results[0]; + return this.save({verified: trust}); + }); + } else { + return this.fetchContacts().then(function() { + return Promise.all(this.contactCollection.map(function(contact) { + if (contact.id !== this.myNumber) { + return contact.updateVerified(); + } + }.bind(this))); + }.bind(this)); + } + }, + + isVerified: function() { + if (this.isPrivate()) { + return this.get('verified') === 'verified'; + } else { + return this.contactCollection.every(function(contact) { + if (contact.id === this.myNumber) { + return true; + } else { + return contact.isVerified(); + } + }.bind(this)); + } + }, + isConflict: function() { + if (this.isPrivate()) { + var verified = this.get('verified'); + return verified !== 'verified' && verified !== 'default'; + } else { + return Boolean(this.getConflicts().length); + } + }, + getConflicts: function() { + if (this.isPrivate()) { + return this.isConflict() ? [this] : []; + } else { + return this.contactCollection.filter(function(contact) { + if (contact.id === this.myNumber) { + return false; + } else { + return contact.isConflict(); + } + }.bind(this)); + } + }, + onMemberVerifiedChange: function() { + // If the verified state of a member changes, our aggregate state changes. + // We trigger both events to replicate the behavior of Backbone.Model.set() + this.trigger('change:verified'); + this.trigger('change'); + }, + toggleVerified: function() { + if (!this.isPrivate()) { + throw new Error('You cannot verify a group conversation. ' + + 'You must verify individual contacts.'); + } + + if (this.isVerified()) { + this.save({verified: 'default'}); + } else { + this.save({verified: 'verified'}); + } + }, + addKeyChange: function(id) { console.log('adding key change advisory for', this.id, this.get('timestamp')); var timestamp = Date.now(); @@ -373,12 +453,14 @@ } else { var promises = []; var members = this.get('members') || []; + this.contactCollection.reset( members.map(function(number) { var c = ConversationController.create({ id : number, type : 'private' }); + this.listenTo(c, 'change:verified', this.onMemberVerifiedChange); promises.push(new Promise(function(resolve) { c.fetch().always(resolve); })); diff --git a/js/views/contact_list_view.js b/js/views/contact_list_view.js index 4e76d9037..e23b8114b 100644 --- a/js/views/contact_list_view.js +++ b/js/views/contact_list_view.js @@ -12,12 +12,41 @@ tagName: 'div', className: 'contact', templateName: 'contact', + events: { + 'click': 'showIdentity' + }, + initialize: function(options) { + this.ourNumber = textsecure.storage.user.getNumber(); + this.listenBack = options.listenBack; + + this.listenTo(this.model, 'change', this.render); + }, render_attributes: function() { + if (this.model.id === this.ourNumber) { + return { + class: 'not-clickable', + title: i18n('me'), + number: this.model.getNumber(), + avatar: this.model.getAvatar() + }; + } + return { + class: '', title: this.model.getTitle(), number: this.model.getNumber(), - avatar: this.model.getAvatar() + avatar: this.model.getAvatar(), + verified: this.model.isVerified() }; + }, + showIdentity: function() { + if (this.model.id === this.ourNumber) { + return; + } + var view = new Whisper.KeyVerificationPanelView({ + model: this.model + }); + this.listenBack(view); } }) }); diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index c50c553d5..f0fa05ab0 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -57,6 +57,17 @@ } }); + Whisper.ConversationTitleView = Whisper.View.extend({ + templateName: 'conversation-title', + render_attributes: function() { + return { + verified: this.model.isVerified(), + name: this.model.getName(), + number: this.model.getNumber(), + }; + } + }); + Whisper.ConversationView = Whisper.View.extend({ className: function() { return [ 'conversation', this.model.get('type') ].join(' '); @@ -68,13 +79,11 @@ render_attributes: function() { return { group: this.model.get('type') === 'group', - name: this.model.getName(), - number: this.model.getNumber(), avatar: this.model.getAvatar(), expireTimer: this.model.get('expireTimer'), - 'view-members' : i18n('members'), + 'show-members' : i18n('showMembers'), 'end-session' : i18n('resetSession'), - 'show-identity' : i18n('showSafetyNumber'), + 'show-identity' : i18n('showSafetyNumber'), 'destroy' : i18n('deleteMessages'), 'send-message' : i18n('sendMessage'), 'disappearing-messages': i18n('disappearingMessages'), @@ -84,6 +93,7 @@ }, initialize: function(options) { this.listenTo(this.model, 'destroy', this.stopListening); + this.listenTo(this.model, 'change', this.updateTitle); this.listenTo(this.model, 'change:color', this.updateColor); this.listenTo(this.model, 'change:name', this.updateTitle); this.listenTo(this.model, 'newmessage', this.addMessage); @@ -103,6 +113,13 @@ window: this.window }); + this.titleView = new Whisper.ConversationTitleView({ + el: this.$('.conversation-title'), + model: this.model + }); + this.titleView.render(); + this.titleView.render(); + this.view = new Whisper.MessageListView({ collection: this.model.messageCollection, window: this.window @@ -145,7 +162,7 @@ 'click .leave-group': 'leaveGroup', 'click .update-group': 'newGroupUpdate', 'click .show-identity': 'showIdentity', - 'click .view-members': 'viewMembers', + 'click .show-members': 'showMembers', 'click .conversation-menu .hamburger': 'toggleMenu', 'click .openInbox' : 'openInbox', 'click' : 'onClick', @@ -167,6 +184,10 @@ 'force-resize': 'forceUpdateMessageFieldSize', 'show-identity': 'showIdentity' }, + + updateTitle: function() { + this.titleView.render(); + }, enableDisappearingMessages: function() { if (!this.model.get('expireTimer')) { this.model.updateExpirationTimer( @@ -416,9 +437,12 @@ this.model.messageCollection.add(message, {merge: true}); }, - viewMembers: function() { + showMembers: function() { return this.model.fetchContacts().then(function() { - var view = new Whisper.GroupMemberList({ model: this.model }); + var view = new Whisper.GroupMemberList({ + model: this.model, + listenBack: this.listenBack.bind(this) + }); this.listenBack(view); }.bind(this)); }, @@ -515,16 +539,25 @@ }, listenBack: function(view) { - this.panel = view; - this.$('.main.panel, .header-buttons.right').hide(); - this.$('.back').show(); - view.$el.insertBefore(this.$('.panel')); + this.panels = this.panels || []; + this.panels.unshift(view); + + if (this.panels.length === 1) { + this.$('.main.panel, .header-buttons.right').hide(); + this.$('.back').show(); + } + + view.$el.insertBefore(this.$('.panel').first()); }, resetPanel: function() { - this.panel.remove(); - this.$('.main.panel, .header-buttons.right').show(); - this.$('.back').hide(); - this.$el.trigger('force-resize'); + var view = this.panels.shift(); + view.remove(); + + if (this.panels.length === 0) { + this.$('.main.panel, .header-buttons.right').show(); + this.$('.back').hide(); + this.$el.trigger('force-resize'); + } }, closeMenu: function(e) { @@ -614,10 +647,6 @@ }); }, - updateTitle: function() { - this.$('.conversation-title').text(this.model.getTitle()); - }, - updateColor: function(model, color) { var header = this.$('.conversation-header'); header.removeClass(Whisper.Conversation.COLORS); diff --git a/js/views/group_member_list_view.js b/js/views/group_member_list_view.js index 02432b17d..065f63cbf 100644 --- a/js/views/group_member_list_view.js +++ b/js/views/group_member_list_view.js @@ -13,17 +13,22 @@ Whisper.GroupMemberList = Whisper.View.extend({ className: 'group-member-list panel', templateName: 'group-member-list', - initialize: function() { + initialize: function(options) { this.render(); + console.log('GroupMemberList', options); + this.member_list_view = new Whisper.ContactListView({ collection: this.model.contactCollection, - className: 'members' + className: 'members', + toInclude: { + listenBack: options.listenBack + } }); this.member_list_view.render(); this.$('.container').append(this.member_list_view.el); }, render_attributes: { - members: i18n('members') + members: i18n('groupMembers') } }); })(); diff --git a/js/views/key_verification_view.js b/js/views/key_verification_view.js index e10da5f43..f950de1d7 100644 --- a/js/views/key_verification_view.js +++ b/js/views/key_verification_view.js @@ -5,25 +5,30 @@ 'use strict'; window.Whisper = window.Whisper || {}; - // TODO; find all uses of that removed panel - // Add the Verify functionality to this view Whisper.KeyVerificationPanelView = Whisper.View.extend({ className: 'key-verification panel', templateName: 'key-verification', + events: { + 'click button.verify': 'toggleVerified', + }, initialize: function(options) { this.our_number = textsecure.storage.user.getNumber(); if (options.newKey) { this.their_key = options.newKey; } + Promise.all([ this.loadTheirKey(), this.loadOurKey(), ]).then(this.generateSecurityNumber.bind(this)) + .then(function() { + this.listenTo(this.model, 'change', this.render); + }.bind(this)) .then(this.render.bind(this)); //.then(this.makeQRCode.bind(this)); }, makeQRCode: function() { - // Per Lilia: We can't turn this on until it geneates a Latin1 string, as is + // Per Lilia: We can't turn this on until it generates a Latin1 string, as is // required by the mobile clients. new QRCode(this.$('.qr')[0]).makeCode( dcodeIO.ByteBuffer.wrap(this.our_key).toString('base64') @@ -58,6 +63,9 @@ this.securityNumber = securityNumber; }.bind(this)); }, + toggleVerified: function() { + this.model.toggleVerified(); + }, render_attributes: function() { var s = this.securityNumber; var chunks = []; @@ -67,10 +75,15 @@ var yourSafetyNumberWith = i18n( 'yourSafetyNumberWith', this.model.getTitle() ); + console.log('this.model',this.model); + var verifyButton = this.model.isVerified() ? i18n('markAsNotVerified') : i18n('verify'); + return { learnMore : i18n('learnMore'), their_key_unknown : i18n('theirIdentityUnknown'), yourSafetyNumberWith : i18n('yourSafetyNumberWith', this.model.getTitle()), + verifyHelp : i18n('verifyHelp', this.model.getTitle()), + verifyButton : verifyButton, has_their_key : this.their_key !== undefined, chunks : chunks, }; diff --git a/js/views/list_view.js b/js/views/list_view.js index 1de9d5c7c..b80204f38 100644 --- a/js/views/list_view.js +++ b/js/views/list_view.js @@ -13,13 +13,15 @@ tagName: 'ul', itemView: Backbone.View, initialize: function(options) { + this.options = options || {}; this.listenTo(this.collection, 'add', this.addOne); this.listenTo(this.collection, 'reset', this.addAll); }, addOne: function(model) { if (this.itemView) { - var view = new this.itemView({model: model}); + var options = Object.assign({}, this.options.toInclude, {model: model}); + var view = new this.itemView(options); this.$el.append(view.render().el); this.$el.trigger('add'); } diff --git a/stylesheets/_conversation.scss b/stylesheets/_conversation.scss index 27be2fbf7..8b4dd6d39 100644 --- a/stylesheets/_conversation.scss +++ b/stylesheets/_conversation.scss @@ -14,6 +14,13 @@ padding: 0 5px 0 4px; } } +.conversation-title .verified { + &:before { + content:"\00b7"; // · + font-weight: bold; + padding: 0 5px 0 4px; + } +} .conversation { background-color: white; @@ -101,6 +108,15 @@ max-width: 100%; } } + + div.verify { + text-align: center; + } + button.verify { + border-radius: 5px; + font-weight: bold; + padding: 10px; + } } .message-detail { diff --git a/stylesheets/_global.scss b/stylesheets/_global.scss index 0414f0c3f..507d54629 100644 --- a/stylesheets/_global.scss +++ b/stylesheets/_global.scss @@ -342,6 +342,7 @@ $avatar-size: 44px; margin: 0 0 0 $left-margin; width: calc(100% - #{$avatar-size} - #{$left-margin} - #{(4/14) + em}); text-align: left; + cursor: pointer; p { overflow-x: hidden; @@ -361,6 +362,10 @@ $avatar-size: 44px; color: $grey; font-size: $font-size-small; } + + &.not-clickable { + cursor: default; + } } diff --git a/stylesheets/manifest.css b/stylesheets/manifest.css index 6b178efad..02c8984e1 100644 --- a/stylesheets/manifest.css +++ b/stylesheets/manifest.css @@ -332,7 +332,8 @@ button.hamburger { display: inline-block; margin: 0 0 0 8px; width: calc(100% - 44px - 8px - 0.28571em); - text-align: left; } + text-align: left; + cursor: pointer; } .contact-details p { overflow-x: hidden; text-overflow: ellipsis; } @@ -346,6 +347,8 @@ button.hamburger { .contact-details .number { color: #616161; font-size: 0.92857em; } + .contact-details.not-clickable { + cursor: default; } .recipients-input { position: relative; } @@ -988,6 +991,11 @@ input.search { font-weight: bold; padding: 0 5px 0 4px; } +.conversation-title .verified:before { + content: "\00b7"; + font-weight: bold; + padding: 0 5px 0 4px; } + .conversation { background-color: white; height: 100%; } @@ -1052,6 +1060,12 @@ input.search { .key-verification .qr img { display: inline-block; max-width: 100%; } +.key-verification div.verify { + text-align: center; } +.key-verification button.verify { + border-radius: 5px; + font-weight: bold; + padding: 10px; } .message-detail { background-color: #eee; } diff --git a/test/index.html b/test/index.html index 14f35e60d..fb6b091e7 100644 --- a/test/index.html +++ b/test/index.html @@ -92,14 +92,15 @@