From 243cbd81238f87710ebf231058f4de6477a2c0a0 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Tue, 13 Jun 2017 17:36:32 -0700 Subject: [PATCH] Confirmaton on send, banner when 'unverified' Not yet using the new APIs, but ready to. Still to do: - Send sync messages on trust decisions - Respond to received trust decision sync messages - Show trust decisions in the conversation history - In that rare situation where a sent message ends up with a key error make it easy to retry the send. FREEBIE --- _locales/en/messages.json | 46 ++++++++ background.html | 7 ++ js/models/conversations.js | 150 +++++++++++++++++++++---- js/views/banner_view.js | 36 ++++++ js/views/confirmation_dialog_view.js | 9 +- js/views/conversation_view.js | 159 ++++++++++++++++++++++++--- js/views/group_member_list_view.js | 4 +- js/views/key_verification_view.js | 1 - stylesheets/_global.scss | 29 +++++ stylesheets/_ios.scss | 4 + stylesheets/manifest.css | 20 ++++ test/index.html | 31 ++++-- 12 files changed, 438 insertions(+), 58 deletions(-) create mode 100644 js/views/banner_view.js diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 880c5f26f..f5e26266a 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -33,6 +33,52 @@ } } }, + "changedSinceVerifiedMultiple": { + "message": "Your safety numbers with multiple group members have changed since you last verified.", + "description": "Shown on confirmation dialog when user attempts to send a message" + }, + "changedSinceVerified": { + "message": "Your safety number with $name$ has changed since you last verified.", + "description": "Shown on confirmation dialog when user attempts to send a message", + "placeholders": { + "name": { + "content": "$1", + "example": "Bob" + } + } + }, + "changedRecentlyMultiple": { + "message": "Your safety numbers with multiple group members have changed in the last five seconds.", + "description": "Shown on confirmation dialog when user attempts to send a message" + }, + "changedRecently": { + "message": "Your safety number with $name$ has changed in the last five seconds.", + "description": "Shown on confirmation dialog when user attempts to send a message", + "placeholders": { + "name": { + "content": "$1", + "example": "Bob" + } + } + }, + "sendAnyway": { + "message": "Send Anyway", + "description": "Used on a warning dialog to specifiy " + }, + "noLongerVerified": { + "message": "$name$ is no longer verified. Click to verify.", + "description": "Shown in converation banner when user's safety number has changed, but they were previously verified.", + "placeholders": { + "name": { + "content": "$1", + "example": "Bob" + } + } + }, + "multipleNoLongerVerified": { + "message": "More than one member of this group is no longer verified. Click to verify.", + "description": "Shown in conversation banner when more than one group member's safety number has changed, but they were previously verified." + }, "debugLogExplanation": { "message": "This log will be posted publicly online for contributors to view. You may examine and edit it before submitting." }, diff --git a/background.html b/background.html index b6e4913a6..ba247fe97 100644 --- a/background.html +++ b/background.html @@ -60,6 +60,12 @@ {{ expiredWarning }} + @@ -656,6 +662,7 @@ + diff --git a/js/models/conversations.js b/js/models/conversations.js index fbc1f6edd..6fc0aab33 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -38,6 +38,12 @@ initialize: function() { this.ourNumber = textsecure.storage.user.getNumber(); + // this.verifiedEnum = textsecure.storage.protocol.VerifiedStatus; + this.verifiedEnum = { + DEFAULT: 0, + VERIFIED: 1, + UNVERIFIED: 2, + }; this.contactCollection = new Backbone.Collection(); this.messageCollection = new Whisper.MessageCollection([], { @@ -51,36 +57,71 @@ }, updateVerified: function() { - // TODO: replace this with the real call - function checkTrustStore() { - return Promise.resolve('default'); + function checkTrustStore(value) { + return Promise.resolve(value); } if (this.isPrivate()) { return Promise.all([ - checkTrustStore(this.id), + //textsecure.storage.protocol.getVerified(this.id), + checkTrustStore(this.verifiedEnum.UNVERIFIED), this.fetch() ]).then(function(results) { var trust = results[0]; return this.save({verified: trust}); - }); + }.bind(this)); } else { return this.fetchContacts().then(function() { return Promise.all(this.contactCollection.map(function(contact) { - if (contact.id !== this.myNumber) { + if (contact.id !== this.ourNumber) { return contact.updateVerified(); } }.bind(this))); - }.bind(this)); + }.bind(this)).then(this.onMemberVerifiedChange.bind(this)); + } + }, + setVerifiedDefault: function() { + function updateTrustStore() { + return Promise.resolve(); + } + + if (!this.isPrivate()) { + throw new Error('You cannot verify a group conversation. ' + + 'You must verify individual contacts.'); } + var DEFAULT = this.verifiedEnum.DEFAULT; + + // return textsecure.storage.protocol.setVerified(this.id, DEFAULT).then(function() { + return updateTrustStore(this.id, DEFAULT).then(function() { + return this.save({verified: DEFAULT}); + }.bind(this)); }, + setVerified: function() { + function updateTrustStore() { + return Promise.resolve(); + } + var VERIFIED = this.verifiedEnum.VERIFIED; + + if (!this.isPrivate()) { + throw new Error('You cannot verify a group conversation. ' + + 'You must verify individual contacts.'); + } + // return textsecure.storage.protocol.setVerified(this.id, VERIFIED).then(function() { + return updateTrustStore(this.id, VERIFIED).then(function() { + return this.save({verified: VERIFIED}); + }.bind(this)); + }, isVerified: function() { if (this.isPrivate()) { - return this.get('verified') === 'verified'; + return this.get('verified') === this.verifiedEnum.VERIFIED; } else { + if (!this.contactCollection.length) { + return false; + } + return this.contactCollection.every(function(contact) { - if (contact.id === this.myNumber) { + if (contact.id === this.ourNumber) { return true; } else { return contact.isVerified(); @@ -88,24 +129,90 @@ }.bind(this)); } }, - isConflict: function() { + isUnverified: function() { if (this.isPrivate()) { var verified = this.get('verified'); - return verified !== 'verified' && verified !== 'default'; + return verified !== this.verifiedEnum.VERIFIED && verified !== this.verifiedEnum.DEFAULT; + } else { + if (!this.contactCollection.length) { + return true; + } + + return this.contactCollection.any(function(contact) { + if (contact.id === this.ourNumber) { + return false; + } else { + return contact.isUnverified(); + } + }.bind(this)); + } + }, + getUnverified: function() { + if (this.isPrivate()) { + return this.isUnverified() ? new Backbone.Collection([this]) : new Backbone.Collection(); } else { - return Boolean(this.getConflicts().length); + return new Backbone.Collection(this.contactCollection.filter(function(contact) { + if (contact.id === this.ourNumber) { + return false; + } else { + return contact.isUnverified(); + } + }.bind(this))); } }, - getConflicts: function() { + isUntrusted: function() { + function getFromTrustStore() { + return Promise.resolve(true); + } + if (this.isPrivate()) { - return this.isConflict() ? [this] : []; + // return textsecure.storage.protocol.isUntrusted(this.id); + return getFromTrustStore(this.id); } else { - return this.contactCollection.filter(function(contact) { - if (contact.id === this.myNumber) { + if (!this.contactCollection.length) { + return Promise.resolve(false); + } + + return Promise.all(this.contactCollection.map(function(contact) { + if (contact.id === this.ourNumber) { return false; } else { - return contact.isConflict(); + return contact.isUntrusted(); + } + }.bind(this))).then(function(results) { + return _.any(results, function(result) { + return result; + }); + }); + } + }, + getUntrusted: function() { + // This is a bit ugly because isUntrusted() is async. Could do the work to cache + // it locally, but we really only need it for this call. + if (this.isPrivate()) { + return this.isUntrusted().then(function(untrusted) { + if (untrusted) { + return new Backbone.Collection([this]); + } + + return new Backbone.Collection(); + }.bind(this)); + } else { + return Promise.all(this.contactCollection.map(function(contact) { + if (contact.id === this.ourNumber) { + return [false, contact]; + } else { + return Promise.all([this.isUntrusted(), contact]); } + }.bind(this))).then(function(results) { + results = _.filter(results, function(result) { + var untrusted = result[0]; + return untrusted; + }); + return new Backbone.Collection(_.map(results, function(result) { + var contact = result[1]; + return contact; + })); }.bind(this)); } }, @@ -116,15 +223,10 @@ 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'}); + return this.setVerifiedDefault(); } else { - this.save({verified: 'verified'}); + return this.setVerified(); } }, diff --git a/js/views/banner_view.js b/js/views/banner_view.js new file mode 100644 index 000000000..f39613262 --- /dev/null +++ b/js/views/banner_view.js @@ -0,0 +1,36 @@ +/* + * vim: ts=4:sw=4:expandtab + */ +(function () { + 'use strict'; + window.Whisper = window.Whisper || {}; + + Whisper.BannerView = Whisper.View.extend({ + className: 'banner', + templateName: 'banner', + events: { + 'click .dismiss': 'onDismiss', + 'click .body': 'onClick', + }, + initialize: function(options) { + this.message = options.message; + this.callbacks = { + onDismiss: options.onDismiss, + onClick: options.onClick + }; + this.render(); + }, + render_attributes: function() { + return { + message: this.message + }; + }, + onDismiss: function(e) { + this.callbacks.onDismiss(); + e.stopPropagation(); + }, + onClick: function() { + this.callbacks.onClick(); + } + }); +})(); diff --git a/js/views/confirmation_dialog_view.js b/js/views/confirmation_dialog_view.js index 602c21aa0..a7709a2ac 100644 --- a/js/views/confirmation_dialog_view.js +++ b/js/views/confirmation_dialog_view.js @@ -10,8 +10,13 @@ templateName: 'confirmation-dialog', initialize: function(options) { this.message = options.message; + this.resolve = options.resolve; + this.okText = options.okText || i18n('ok'); + this.reject = options.reject; + this.cancelText = options.cancelText || i18n('cancel'); + this.render(); }, events: { @@ -21,8 +26,8 @@ render_attributes: function() { return { message: this.message, - cancel: i18n('cancel'), - ok: i18n('ok') + cancel: this.cancelText, + ok: this.okText }; }, ok: function() { diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index f0fa05ab0..9edbba3f9 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -59,6 +59,9 @@ Whisper.ConversationTitleView = Whisper.View.extend({ templateName: 'conversation-title', + initialize: function() { + this.listenTo(this.model, 'change', this.render); + }, render_attributes: function() { return { verified: this.model.isVerified(), @@ -93,9 +96,8 @@ }, initialize: function(options) { this.listenTo(this.model, 'destroy', this.stopListening); - this.listenTo(this.model, 'change', this.updateTitle); + this.listenTo(this.model, 'change:verified', this.onVerifiedChange); this.listenTo(this.model, 'change:color', this.updateColor); - this.listenTo(this.model, 'change:name', this.updateTitle); this.listenTo(this.model, 'newmessage', this.addMessage); this.listenTo(this.model, 'delivered', this.updateMessage); this.listenTo(this.model, 'opened', this.onOpened); @@ -154,14 +156,14 @@ }, events: { - 'submit .send': 'sendMessage', + 'submit .send': 'checkUnverifiedSendMessage', 'input .send-message': 'updateMessageFieldSize', 'keydown .send-message': 'updateMessageFieldSize', 'click .destroy': 'destroyMessages', 'click .end-session': 'endSession', 'click .leave-group': 'leaveGroup', 'click .update-group': 'newGroupUpdate', - 'click .show-identity': 'showIdentity', + 'click .show-identity': 'showSafetyNumber', 'click .show-members': 'showMembers', 'click .conversation-menu .hamburger': 'toggleMenu', 'click .openInbox' : 'openInbox', @@ -185,9 +187,59 @@ 'show-identity': 'showIdentity' }, - updateTitle: function() { - this.titleView.render(); + + markAllAsVerifiedDefault: function(unverified) { + return Promise.all(unverified.map(function(contact) { + return contact.setVerifiedDefault(); + })); + }, + + openSafetyNumberScreens: function(unverified) { + if (unverified.length === 1) { + this.showSafetyNumber(null, unverified.at(0)); + return; + } + + // TODO: need to be able to specify string to override group list header + this.showMembers(null, unverified); }, + + onVerifiedChange: function() { + if (this.model.isUnverified()) { + var unverified = this.model.getUnverified(); + var message; + if (unverified.length > 1) { + message = i18n('multipleNoLongerVerified'); + } else { + message = i18n('noLongerVerified', unverified.at(0).getTitle()); + } + + // Need to re-add, since unverified set may have changed + if (this.banner) { + this.banner.remove(); + this.banner = null; + } + + this.banner = new Whisper.BannerView({ + message: message, + onDismiss: function() { + this.markAllAsVerifiedDefault(unverified); + }.bind(this), + onClick: function() { + this.openSafetyNumberScreens(unverified); + }.bind(this) + }); + + var container = this.$('.discussion-container'); + container.append(this.banner.el); + } else if (this.banner) { + this.banner.remove(); + this.banner = null; + + // TODO: Is there anything else we should do here? make messages re-send-able? + } + }, + enableDisappearingMessages: function() { if (!this.model.get('expireTimer')) { this.model.updateExpirationTimer( @@ -257,6 +309,15 @@ this.$el.trigger('force-resize'); this.focusMessageField(); + // TODO: do a fetch of all profiles to get the latest identity keys, then: + // We have a number of async things happening here: + // 1. we need to get contacts before we do anything with groups + // 2. we need to get profile information for each contact + // 3. we need to get all messages for conversation + // 4. we need to get updated verified information for each contact + // 5. we perhaps need to throw up the banner if in unverified state + this.model.updateVerified().then(this.onVerifiedChange.bind(this)); + if (this.inProgressFetch) { this.inProgressFetch.then(this.updateUnread.bind(this)); } else { @@ -437,16 +498,6 @@ this.model.messageCollection.add(message, {merge: true}); }, - showMembers: function() { - return this.model.fetchContacts().then(function() { - var view = new Whisper.GroupMemberList({ - model: this.model, - listenBack: this.listenBack.bind(this) - }); - this.listenBack(view); - }.bind(this)); - }, - openInbox: function() { openInbox(); }, @@ -517,7 +568,19 @@ } }, - showIdentity: function(ev, model) { + showMembers: function(e, members) { + members = members || this.model.contactCollection; + + var view = new Whisper.GroupMemberList({ + model: members, + // we pass this in to allow nexted panels + listenBack: this.listenBack.bind(this) + }); + + this.listenBack(view); + }, + + showSafetyNumber: function(e, model) { if (!model && this.model.isPrivate()) { model = this.model; } @@ -601,6 +664,68 @@ this.$('.menu-list').hide(); }, + showSendConfirmationDialog: function(e, contacts) { + var message; + var isUnverified = this.model.isUnverified(); + + if (contacts.length > 1) { + if (isUnverified) { + message = i18n('changedSinceVerifiedMultiple'); + } else { + message = i18n('changedRecentlyMultiple'); + } + } else { + if (isUnverified) { + message = i18n('changedSinceVerified', this.model.getTitle()); + } else { + message = i18n('changedRecently', this.model.getTitle()); + } + } + + var dialog = new Whisper.ConfirmationDialogView({ + message: message, + okText: i18n('sendAnyway'), + resolve: function() { + this.checkUnverifiedSendMessage(e, {force: true}); + }.bind(this), + reject: function() { + // do nothing + } + }); + this.$el.prepend(dialog.el); + }, + + checkUnverifiedSendMessage: function(e, options) { + options = options || {}; + _.defaults(options, {force: false}); + + var contacts = this.model.getUnverified(); + if (!contacts.length) { + return this.checkUntrustedSendMessage(e, options); + } + + if (options.force) { + return this.markAllAsVerifiedDefault(contacts).then(function() { + this.checkUnverifiedSendMessage(e, options); + }.bind(this)); + } + + this.showSendConfirmationDialog(e, contacts); + }, + + checkUntrustedSendMessage: function(e, options) { + options = options || {}; + _.defaults(options, {force: false}); + + this.model.getUntrusted().then(function(contacts) { + if (!contacts.length || options.force) { + return this.sendMessage(e); + } + + this.showSendConfirmationDialog(e, contacts); + }.bind(this)); + }, + sendMessage: function(e) { this.removeLastSeenIndicator(); diff --git a/js/views/group_member_list_view.js b/js/views/group_member_list_view.js index 065f63cbf..901006883 100644 --- a/js/views/group_member_list_view.js +++ b/js/views/group_member_list_view.js @@ -15,16 +15,16 @@ templateName: 'group-member-list', initialize: function(options) { this.render(); - console.log('GroupMemberList', options); this.member_list_view = new Whisper.ContactListView({ - collection: this.model.contactCollection, + collection: this.model, className: 'members', toInclude: { listenBack: options.listenBack } }); this.member_list_view.render(); + this.$('.container').append(this.member_list_view.el); }, render_attributes: { diff --git a/js/views/key_verification_view.js b/js/views/key_verification_view.js index f950de1d7..9ea4cec3d 100644 --- a/js/views/key_verification_view.js +++ b/js/views/key_verification_view.js @@ -75,7 +75,6 @@ var yourSafetyNumberWith = i18n( 'yourSafetyNumberWith', this.model.getTitle() ); - console.log('this.model',this.model); var verifyButton = this.model.isVerified() ? i18n('markAsNotVerified') : i18n('verify'); return { diff --git a/stylesheets/_global.scss b/stylesheets/_global.scss index 507d54629..4982bce5e 100644 --- a/stylesheets/_global.scss +++ b/stylesheets/_global.scss @@ -334,6 +334,35 @@ $avatar-size: 44px; display: none; } } + +.banner { + // maybe make the top offset smaller here, or smaller in the iOS theme? + // what's the right color? + background-color: orange; + color: black; + + position: absolute; + top: 25px; + right: 30px; + left: 30px; + + padding: 5px 25px 5px 10px; + + text-align: center; + border-radius: 10px; + + cursor: pointer; + + .dismiss { + position: absolute; + right: 3px; + top: 3px; + + height: 20px; + width: 20px; + } +} + .contact-details { $left-margin: 8px; diff --git a/stylesheets/_ios.scss b/stylesheets/_ios.scss index 1cf57329d..3d9769410 100644 --- a/stylesheets/_ios.scss +++ b/stylesheets/_ios.scss @@ -29,6 +29,10 @@ $ios-border-color: rgba(0,0,0,0.1); } } } + .banner { + top: 15px; + } + .tool-bar { float: left; padding: 15px; diff --git a/stylesheets/manifest.css b/stylesheets/manifest.css index 02c8984e1..ad74642d2 100644 --- a/stylesheets/manifest.css +++ b/stylesheets/manifest.css @@ -327,6 +327,24 @@ button.hamburger { .contact:last-child::after { display: none; } +.banner { + background-color: orange; + color: black; + position: absolute; + top: 25px; + right: 30px; + left: 30px; + padding: 5px 25px 5px 10px; + text-align: center; + border-radius: 10px; + cursor: pointer; } + .banner .dismiss { + position: absolute; + right: 3px; + top: 3px; + height: 20px; + width: 20px; } + .contact-details { vertical-align: middle; display: inline-block; @@ -1570,6 +1588,8 @@ li.entry .error-icon-container { color: white; } .ios .gutter .contact.selected .last-timestamp { color: white; } +.ios .banner { + top: 15px; } .ios .tool-bar { float: left; padding: 15px; } diff --git a/test/index.html b/test/index.html index fb6b091e7..fc53fc586 100644 --- a/test/index.html +++ b/test/index.html @@ -16,18 +16,6 @@
- - + + + @@ -541,6 +547,7 @@ +