From 5b83485c89200cdfc182ef8e7c15dc0be2e43944 Mon Sep 17 00:00:00 2001
From: Josh Perez <60019601+josh-signal@users.noreply.github.com>
Date: Thu, 25 Jun 2020 20:08:58 -0400
Subject: [PATCH] Update safety number change warning dialog
---
.storybook/config.js | 22 ++
_locales/en/messages.json | 32 +-
background.html | 34 +--
js/models/conversations.js | 3 +
js/modules/signal.js | 8 +
js/views/conversation_view.js | 32 +-
js/views/key_verification_view.js | 148 +---------
js/views/safety_number_change_dialog_view.js | 39 +++
stylesheets/_conversation.scss | 99 -------
stylesheets/_modules.scss | 275 +++++++++++++++++-
test/index.html | 29 +-
ts/RemoteConfig.ts | 1 -
ts/components/ConfirmationDialog.tsx | 50 ++--
.../SafetyNumberChangeDialog.stories.tsx | 76 +++++
ts/components/SafetyNumberChangeDialog.tsx | 135 +++++++++
ts/components/SafetyNumberViewer.stories.tsx | 85 ++++++
ts/components/SafetyNumberViewer.tsx | 82 ++++++
ts/components/SearchResults.stories.tsx | 4 -
ts/shims/contactVerification.ts | 13 +
ts/sql/Server.ts | 2 +-
ts/state/actions.ts | 2 +
ts/state/ducks/conversations.ts | 4 +
ts/state/ducks/safetyNumber.ts | 214 ++++++++++++++
ts/state/reducer.ts | 8 +
ts/state/roots/createSafetyNumberViewer.tsx | 21 ++
ts/state/selectors/safetyNumber.ts | 24 ++
ts/state/smart/SafetyNumberViewer.tsx | 25 ++
ts/test/state/selectors/conversations_test.ts | 10 +
ts/textsecure.d.ts | 17 +-
ts/textsecure/AccountManager.ts | 2 +-
ts/textsecure/SendMessage.ts | 2 +-
ts/textsecure/WebAPI.ts | 3 -
ts/util/index.ts | 6 +
ts/util/lint/exceptions.json | 69 +++--
ts/util/registration.ts | 2 -
ts/util/safetyNumber.ts | 58 ++++
ts/window.d.ts | 7 +
tslint.json | 3 +
38 files changed, 1221 insertions(+), 425 deletions(-)
create mode 100644 js/views/safety_number_change_dialog_view.js
create mode 100644 ts/components/SafetyNumberChangeDialog.stories.tsx
create mode 100644 ts/components/SafetyNumberChangeDialog.tsx
create mode 100644 ts/components/SafetyNumberViewer.stories.tsx
create mode 100644 ts/components/SafetyNumberViewer.tsx
create mode 100644 ts/shims/contactVerification.ts
create mode 100644 ts/state/ducks/safetyNumber.ts
create mode 100644 ts/state/roots/createSafetyNumberViewer.tsx
create mode 100644 ts/state/selectors/safetyNumber.ts
create mode 100644 ts/state/smart/SafetyNumberViewer.tsx
create mode 100644 ts/util/safetyNumber.ts
diff --git a/.storybook/config.js b/.storybook/config.js
index 05965ae38d..1f80829417 100644
--- a/.storybook/config.js
+++ b/.storybook/config.js
@@ -52,6 +52,28 @@ addDecorator((storyFn /* , context */) => {
const secondPaneDeviceTheme = makeDeviceThemeKnob('Second');
const secondPaneMode = makeModeKnob('Second');
+ // Adding it to the body as well so that we can cover modals and other
+ // components that are rendered outside of this decorator container
+ if (firstPaneTheme === '') {
+ document.body.classList.remove('dark-theme');
+ } else {
+ document.body.classList.add('dark-theme');
+ }
+
+ if (firstPaneDeviceTheme === '') {
+ document.body.classList.remove('ios-theme');
+ } else {
+ document.body.classList.add('ios-theme');
+ }
+
+ if (firstPaneMode === 'mouse-mode') {
+ document.body.classList.remove('keyboard-mode');
+ document.body.classList.add('mouse-mode');
+ } else {
+ document.body.classList.remove('mouse-mode');
+ document.body.classList.add('keyboard-mode');
+ }
+
return (
diff --git a/_locales/en/messages.json b/_locales/en/messages.json
index df98b17d9c..f93661e3f9 100644
--- a/_locales/en/messages.json
+++ b/_locales/en/messages.json
@@ -345,20 +345,6 @@
"message": "Your safety numbers with these group members have changed since you last verified. Click a group member to see your new safety number with them.",
"description": "When there are multiple previously-verified group members with safety number changes, a banner will be shown. The list of contacts with safety number changes is shown, and this text introduces that list."
},
- "changedSinceVerifiedMultiple": {
- "message": "Your safety numbers with multiple group members have changed since you last verified. This could mean that someone is trying to intercept your communication or that they have simply reinstalled Signal.",
- "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. This could mean that someone is trying to intercept your communication or that $name$ has simply reinstalled Signal.",
- "description": "Shown on confirmation dialog when user attempts to send a message",
- "placeholders": {
- "name": {
- "content": "$1",
- "example": "Bob"
- }
- }
- },
"changedRightAfterVerify": {
"message": "The safety number you are trying to verify has changed. Please review your new safety number with $name$. Remember, this change could mean that someone is trying to intercept your communication or that $name$ has simply reinstalled Signal.",
"description": "Shown on the safety number screen when the user has selected to verify/unverify a contact's safety number, and we immediately discover a safety number change",
@@ -369,20 +355,10 @@
}
}
},
- "changedRecentlyMultiple": {
- "message": "Your safety numbers with multiple group members have changed recently. This could mean that someone is trying to intercept your communication or that they have simply reinstalled Signal.",
+ "changedVerificationWarning": {
+ "message": "The following people may have reinstalled or changed devices. Verify your safety number with them to ensure privacy.",
"description": "Shown on confirmation dialog when user attempts to send a message"
},
- "changedRecently": {
- "message": "Your safety number with $name$ has changed recently. This could mean that someone is trying to intercept your communication or that $name$ has simply reinstalled Signal.",
- "description": "Shown on confirmation dialog when user attempts to send a message",
- "placeholders": {
- "name": {
- "content": "$1",
- "example": "Bob"
- }
- }
- },
"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 safety number with this contact.",
"description": "Shown when user clicks on a failed recipient in the message detail view after an identity key change",
@@ -1579,6 +1555,10 @@
"message": "Safety Number has changed",
"description": "A notification shown in the conversation when a contact reinstalls"
},
+ "safetyNumberChanges": {
+ "message": "Safety Number Changes",
+ "description": "Title for safety number changed modal"
+ },
"safetyNumberChangedGroup": {
"message": "Safety Number with $name$ has changed",
"description": "A notification shown in a group conversation when a contact reinstalls, showing the contact name",
diff --git a/background.html b/background.html
index 9588693274..ada17196fb 100644
--- a/background.html
+++ b/background.html
@@ -123,6 +123,10 @@
+
+
@@ -387,6 +366,7 @@
+
diff --git a/js/models/conversations.js b/js/models/conversations.js
index 2f321f2bdf..639f365c72 100644
--- a/js/models/conversations.js
+++ b/js/models/conversations.js
@@ -451,9 +451,12 @@
const result = {
id: this.id,
+ uuid: this.get('uuid'),
+ e164: this.get('e164'),
isArchived: this.get('isArchived'),
isBlocked: this.isBlocked(),
+ isVerified: this.isVerified(),
activeAt: this.get('active_at'),
avatarPath: this.getAvatarPath(),
color,
diff --git a/js/modules/signal.js b/js/modules/signal.js
index 251567fc85..94aac80df5 100644
--- a/js/modules/signal.js
+++ b/js/modules/signal.js
@@ -40,6 +40,9 @@ const {
MessageDetail,
} = require('../../ts/components/conversation/MessageDetail');
const { Quote } = require('../../ts/components/conversation/Quote');
+const {
+ SafetyNumberChangeDialog,
+} = require('../../ts/components/SafetyNumberChangeDialog');
const {
StagedLinkPreview,
} = require('../../ts/components/conversation/StagedLinkPreview');
@@ -51,6 +54,9 @@ const {
} = require('../../ts/state/roots/createCompositionArea');
const { createCallManager } = require('../../ts/state/roots/createCallManager');
const { createLeftPane } = require('../../ts/state/roots/createLeftPane');
+const {
+ createSafetyNumberViewer,
+} = require('../../ts/state/roots/createSafetyNumberViewer');
const {
createStickerManager,
} = require('../../ts/state/roots/createStickerManager');
@@ -274,6 +280,7 @@ exports.setup = (options = {}) => {
MediaGallery,
MessageDetail,
Quote,
+ SafetyNumberChangeDialog,
StagedLinkPreview,
Types: {
Message: MediaGalleryMessage,
@@ -284,6 +291,7 @@ exports.setup = (options = {}) => {
createCallManager,
createCompositionArea,
createLeftPane,
+ createSafetyNumberViewer,
createShortcutGuideModal,
createStickerManager,
createStickerPreviewModal,
diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js
index da4cae42fd..7436cadb3c 100644
--- a/js/views/conversation_view.js
+++ b/js/views/conversation_view.js
@@ -2502,33 +2502,17 @@
showSendAnywayDialog(contacts) {
return new Promise(resolve => {
- let message;
- const isUnverified = this.model.isUnverified();
-
- if (contacts.length > 1) {
- if (isUnverified) {
- message = i18n('changedSinceVerifiedMultiple');
- } else {
- message = i18n('changedRecentlyMultiple');
- }
- } else {
- const contactName = contacts.at(0).getTitle();
- if (isUnverified) {
- message = i18n('changedSinceVerified', [contactName, contactName]);
- } else {
- message = i18n('changedRecently', [contactName, contactName]);
- }
- }
-
- const dialog = new Whisper.ConfirmationDialogView({
- message,
- okText: i18n('sendAnyway'),
- resolve: () => resolve(true),
- reject: () => resolve(false),
+ const dialog = new Whisper.SafetyNumberChangeDialogView({
+ contacts,
+ reject: () => {
+ resolve(false);
+ },
+ resolve: () => {
+ resolve(true);
+ },
});
this.$el.prepend(dialog.el);
- dialog.focusCancel();
});
},
diff --git a/js/views/key_verification_view.js b/js/views/key_verification_view.js
index 9280588160..378e678e44 100644
--- a/js/views/key_verification_view.js
+++ b/js/views/key_verification_view.js
@@ -1,4 +1,4 @@
-/* global Whisper, textsecure, QRCode, dcodeIO, libsignal, i18n, _ */
+/* global Whisper, Signal */
/* eslint-disable more/no-then */
@@ -9,152 +9,18 @@
window.Whisper = window.Whisper || {};
Whisper.KeyVerificationPanelView = Whisper.View.extend({
- className: 'key-verification panel',
+ className: 'panel',
templateName: 'key-verification',
- events: {
- 'click button.verify': 'toggleVerified',
- },
initialize(options) {
- this.ourNumber = textsecure.storage.user.getNumber();
- this.ourUuid = textsecure.storage.user.getUuid();
- if (options.newKey) {
- this.theirKey = options.newKey;
- }
-
- this.loadTheirKey();
- this.loadOurKey();
-
this.render();
- if (options.onLoad) {
- options.onLoad(this);
- }
- this.loadKeys().then(() => {
- this.listenTo(this.model, 'change', this.render);
- });
- },
- async loadKeys() {
- await this.generateSecurityNumber();
- this.render();
- },
- makeQRCode() {
- // 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.ourKey).toString('base64')
- );
- },
- loadTheirKey() {
- const item = textsecure.storage.protocol.getIdentityRecord(
- this.model.get('id')
- );
- this.theirKey = item ? item.publicKey : null;
- },
- loadOurKey() {
- const item = textsecure.storage.protocol.getIdentityRecord(
- this.ourUuid || this.ourNumber
- );
- this.ourKey = item ? item.publicKey : null;
- },
- generateSecurityNumber() {
- return new libsignal.FingerprintGenerator(5200)
- .createFor(
- // TODO: we cannot use UUIDs for safety numbers yet
- // this.ourUuid || this.ourNumber,
- this.ourNumber,
- this.ourKey,
- // TODO: we cannot use UUIDs for safety numbers yet
- // this.model.get('uuid') || this.model.get('e164'),
- this.model.get('e164'),
- this.theirKey
- )
- .then(securityNumber => {
- this.securityNumber = securityNumber;
- });
- },
- onSafetyNumberChanged() {
- this.model.getProfiles().then(this.loadKeys.bind(this));
-
- const dialog = new Whisper.ConfirmationDialogView({
- message: i18n('changedRightAfterVerify', [
- this.model.getTitle(),
- this.model.getTitle(),
- ]),
- hideCancel: true,
+ const view = new Whisper.ReactWrapperView({
+ JSX: Signal.State.Roots.createSafetyNumberViewer(window.reduxStore, {
+ contactID: options.model.get('id'),
+ }),
});
- dialog.$el.insertBefore(this.el);
- dialog.focusCancel();
- },
- toggleVerified() {
- this.$('button.verify').attr('disabled', true);
- this.model
- .toggleVerified()
- .catch(result => {
- if (result instanceof Error) {
- if (result.name === 'OutgoingIdentityKeyError') {
- this.onSafetyNumberChanged();
- } else {
- window.log.error(
- 'failed to toggle verified:',
- result && result.stack ? result.stack : result
- );
- }
- } else {
- const keyError = _.some(
- result.errors,
- error => error.name === 'OutgoingIdentityKeyError'
- );
- if (keyError) {
- this.onSafetyNumberChanged();
- } else {
- _.forEach(result.errors, error => {
- window.log.error(
- 'failed to toggle verified:',
- error && error.stack ? error.stack : error
- );
- });
- }
- }
- })
- .then(() => {
- this.$('button.verify').removeAttr('disabled');
- });
- },
- render_attributes() {
- const s = this.securityNumber;
- const chunks = [];
- if (s) {
- for (let i = 0; i < s.length; i += 5) {
- chunks.push(s.substring(i, i + 5));
- }
- } else {
- for (let i = 0; i < 12; i += 1) {
- chunks.push('XXXXX');
- }
- }
- const name = this.model.getTitle();
- const yourSafetyNumberWith = i18n(
- 'yourSafetyNumberWith',
- this.model.getTitle()
- );
- const isVerified = this.model.isVerified();
- const verifyButton = isVerified ? i18n('unverify') : i18n('verify');
- const verifiedStatus = isVerified
- ? i18n('isVerified', name)
- : i18n('isNotVerified', name);
-
- return {
- learnMore: i18n('learnMore'),
- theirKeyUnknown: i18n('theirIdentityUnknown'),
- yourSafetyNumberWith,
- verifyHelp: i18n('verifyHelp', this.model.getTitle()),
- verifyButton,
- hasTheirKey: this.theirKey !== undefined,
- chunks,
- isVerified,
- verifiedStatus,
- };
+ this.$('.key-verification-wrapper').append(view.el);
},
});
})();
diff --git a/js/views/safety_number_change_dialog_view.js b/js/views/safety_number_change_dialog_view.js
new file mode 100644
index 0000000000..a6cfcbb14b
--- /dev/null
+++ b/js/views/safety_number_change_dialog_view.js
@@ -0,0 +1,39 @@
+/* global Whisper, Signal */
+
+// eslint-disable-next-line func-names
+(function() {
+ 'use strict';
+
+ window.Whisper = window.Whisper || {};
+
+ Whisper.SafetyNumberChangeDialogView = Whisper.View.extend({
+ templateName: 'safety-number-change-dialog',
+ initialize(options) {
+ const dialog = new Whisper.ReactWrapperView({
+ Component: window.Signal.Components.SafetyNumberChangeDialog,
+ props: {
+ contacts: options.contacts.map(contact => contact.cachedProps),
+ i18n: window.i18n,
+ onCancel: () => {
+ dialog.remove();
+ this.remove();
+ options.reject();
+ },
+ onConfirm: () => {
+ dialog.remove();
+ this.remove();
+ options.resolve();
+ },
+ renderSafetyNumber(props) {
+ return Signal.State.Roots.createSafetyNumberViewer(
+ window.reduxStore,
+ props
+ );
+ },
+ },
+ });
+
+ this.$('.safety-number-change-dialog-wrapper').append(dialog.el);
+ },
+ });
+})();
diff --git a/stylesheets/_conversation.scss b/stylesheets/_conversation.scss
index 74cc270977..a12455e1bd 100644
--- a/stylesheets/_conversation.scss
+++ b/stylesheets/_conversation.scss
@@ -118,105 +118,6 @@
}
}
-.key-verification {
- .container {
- outline: none;
- }
-
- label {
- display: block;
- margin: 10px 0;
- }
-
- .icon {
- height: 1.25em;
- width: 1.25em;
- vertical-align: text-bottom;
- display: inline-block;
-
- &.verified {
- @include light-theme {
- @include color-svg('../images/icons/v2/check-24.svg', $color-gray-95);
- }
- @include dark-theme {
- @include color-svg('../images/icons/v2/check-24.svg', $color-gray-02);
- }
- }
- &.shield {
- @include light-theme {
- @include color-svg(
- '../images/icons/v2/safety-number-outline-24.svg',
- $color-gray-95
- );
- }
- @include dark-theme {
- @include color-svg(
- '../images/icons/v2/safety-number-solid-24.svg',
- $color-gray-02
- );
- }
- }
- }
-
- .key,
- .placeholder {
- padding: 0 1em;
- -webkit-user-select: text;
- }
- .key {
- font-family: monospace;
- padding: 10px;
- margin: 20px auto 20px auto;
- width: 16em;
- border-radius: 5px;
-
- @include light-theme {
- background: $color-gray-02;
- border: solid 1px $color-gray-15;
- }
-
- @include dark-theme {
- color: $color-gray-02;
- background: $color-gray-90;
- border: solid 1px $color-gray-45;
- }
- }
- .placeholder {
- font-weight: bold;
- }
- .qr {
- border-radius: 200px;
- border: solid 1px $color-gray-15;
- width: 150px;
- height: 150px;
- text-align: center;
- padding: 25px;
- margin: 10px auto;
- canvas {
- display: none;
- }
- img {
- display: inline-block;
- max-width: 100%;
- }
- }
-
- .summary {
- margin: 30px 0 10px;
- text-align: center;
- }
-
- div.verify {
- text-align: center;
- }
- button.verify {
- border-radius: 5px;
- font-weight: bold;
- padding: 10px;
- margin: 0;
- }
-}
-
.typing-bubble-wrapper {
margin-bottom: 20px;
}
diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss
index f0caa64bbc..6bb5b880ac 100644
--- a/stylesheets/_modules.scss
+++ b/stylesheets/_modules.scss
@@ -6139,6 +6139,279 @@ button.module-image__border-overlay:focus {
}
}
+// Module: SafetyNumberChangeDialog
+
+.module-sfn-dialog__title {
+ @include font-body-1-bold;
+ text-align: center;
+
+ @include dark-theme {
+ color: $color-white;
+ }
+}
+
+.module-sfn-dialog__message {
+ @include font-body-2;
+ text-align: center;
+
+ @include light-theme {
+ color: $color-gray-60;
+ }
+
+ @include dark-theme {
+ color: $color-gray-25;
+ }
+}
+
+.module-sfn-dialog__contacts {
+ list-style-type: none;
+ max-height: 300px;
+ overflow-y: scroll;
+ padding: 0;
+}
+
+.module-sfn-dialog__contact {
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+ margin-bottom: 16px;
+
+ &--wrapper {
+ flex-grow: 1;
+ margin-left: 12px;
+ }
+
+ &--name {
+ @include font-body-1-bold;
+
+ @include dark-theme {
+ color: $color-white;
+ }
+ }
+
+ &--number {
+ @include light-theme {
+ color: $color-gray-60;
+ }
+
+ @include dark-theme {
+ color: $color-gray-25;
+ }
+ }
+
+ &--view {
+ @include font-body-1-bold;
+ background: inherit;
+ border: none;
+ cursor: pointer;
+ margin-right: 2px;
+ outline: none;
+ padding: 8px 14px;
+
+ @include keyboard-mode {
+ &:focus {
+ box-shadow: 0px 0px 0px 2px $ultramarine-ui-light;
+ }
+ }
+
+ @include light-theme {
+ color: $ultramarine-ui-light;
+ }
+
+ @include dark-theme {
+ color: $ultramarine-ui-dark;
+ }
+ }
+}
+
+.module-sfn-dialog__actions {
+ border-top: 1px solid $color-gray-05;
+ display: flex;
+ justify-content: flex-end;
+ margin-bottom: -18px;
+ margin-left: -16px;
+ margin-right: -16px;
+ margin-top: -14px;
+ padding-left: 16px;
+ padding-right: 16px;
+ padding-top: 16px;
+
+ &--cancel {
+ @include font-body-1-bold;
+ border: none;
+ border-radius: 4px;
+ outline: none;
+ padding: 7px 14px;
+
+ @include mouse-mode {
+ &:hover {
+ background: $color-gray-15;
+ }
+ }
+
+ @include keyboard-mode {
+ &:focus {
+ box-shadow: 0px 0px 0px 2px $ultramarine-ui-light;
+ }
+ }
+
+ @include light-theme {
+ background-color: $color-gray-05;
+ color: $ultramarine-ui-light;
+ }
+
+ @include dark-theme {
+ background-color: $color-gray-75;
+ color: $ultramarine-ui-dark;
+ }
+ }
+
+ &--confirm {
+ @include font-body-1-bold;
+ background: $ultramarine-ui-light;
+ border: none;
+ border-radius: 4px;
+ color: $color-white;
+ margin-left: 12px;
+ outline: none;
+ padding: 7px 14px;
+
+ @include mouse-mode {
+ &:hover {
+ background: $ultramarine-brand-dark;
+ }
+ }
+
+ @include keyboard-mode {
+ &:focus {
+ box-shadow: 0px 0px 0px 2px $ultramarine-brand-dark;
+ }
+ }
+ }
+}
+
+/* Safety Number verification */
+
+.module-safety-number {
+ &__icon {
+ height: 1.25em;
+ width: 1.25em;
+ vertical-align: text-bottom;
+ display: inline-block;
+ }
+
+ &__verification-label {
+ margin: 10px 0;
+ }
+
+ &__icon--verified {
+ display: inline-block;
+ height: 1.25em;
+ margin-right: 4px;
+ vertical-align: text-bottom;
+ width: 1.25em;
+
+ @include light-theme {
+ -webkit-mask: url('../images/icons/v2/check-24.svg') no-repeat center;
+ -webkit-mask-size: 100%;
+ background-color: #121212;
+ }
+
+ @include dark-theme {
+ -webkit-mask: url('../images/icons/v2/check-24.svg') no-repeat center;
+ -webkit-mask-size: 100%;
+ background-color: #f6f6f6;
+ }
+ }
+
+ &__icon--shield {
+ display: inline-block;
+ height: 1.25em;
+ margin-right: 4px;
+ vertical-align: text-bottom;
+ width: 1.25em;
+
+ @include light-theme {
+ -webkit-mask: url('../images/icons/v2/safety-number-outline-24.svg')
+ no-repeat center;
+ -webkit-mask-size: 100%;
+ background-color: #121212;
+ }
+
+ @include dark-theme {
+ -webkit-mask: url('../images/icons/v2/safety-number-solid-24.svg')
+ no-repeat center;
+ -webkit-mask-size: 100%;
+ background-color: #f6f6f6;
+ }
+ }
+
+ &__verify-container {
+ text-align: center;
+ }
+
+ &__button--verify {
+ border-radius: 5px;
+ font-weight: bold;
+ margin: 0;
+ outline: none;
+ padding: 10px;
+ }
+
+ &__number {
+ background: #f6f6f6;
+ border-radius: 5px;
+ border: solid 1px #dedede;
+ font-family: monospace;
+ margin: 20px auto 20px auto;
+ padding: 10px;
+ text-align: center;
+ width: 16em;
+
+ @include dark-theme {
+ background: #1b1b1b;
+ border: solid 1px #848484;
+ color: #f6f6f6;
+ }
+ }
+
+ &__verification-status {
+ margin: 30px 0 10px;
+ text-align: center;
+ }
+
+ &__close-button {
+ display: flex;
+ justify-content: flex-end;
+
+ button {
+ background: inherit;
+ border: none;
+ cursor: pointer;
+ padding: 0;
+
+ @include keyboard-mode {
+ &:focus {
+ border: 1px solid $ultramarine-ui-light;
+ }
+ }
+
+ span {
+ display: inline-block;
+ height: 24px;
+ width: 24px;
+
+ @include light-theme {
+ @include color-svg('../images/icons/v2/x-24.svg', $color-gray-60);
+ }
+ @include dark-theme {
+ @include color-svg('../images/icons/v2/x-24.svg', $color-gray-05);
+ }
+ }
+ }
+ }
+}
+
// Module: StickerPicker
.module-sticker-picker {
@@ -7094,7 +7367,7 @@ button.module-image__border-overlay:focus {
}
@include dark-theme() {
- background: $color-gray-75;
+ background: $color-gray-80;
color: $color-gray-05;
}
diff --git a/test/index.html b/test/index.html
index bc2dad1907..eb9c29dff0 100644
--- a/test/index.html
+++ b/test/index.html
@@ -167,33 +167,8 @@
diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts
index b7c6e09daf..b6f54d66f3 100644
--- a/ts/RemoteConfig.ts
+++ b/ts/RemoteConfig.ts
@@ -1,4 +1,3 @@
-// tslint:disable: no-backbone-get-set-outside-model
import { get, throttle } from 'lodash';
import { WebAPIType } from './textsecure/WebAPI';
diff --git a/ts/components/ConfirmationDialog.tsx b/ts/components/ConfirmationDialog.tsx
index bb836b4b45..1ef1f75237 100644
--- a/ts/components/ConfirmationDialog.tsx
+++ b/ts/components/ConfirmationDialog.tsx
@@ -70,33 +70,35 @@ export const ConfirmationDialog = React.memo(
{children}
-
-
- {i18n('confirmation-dialog--Cancel')}
-
- {actions.map((action, i) => (
+ {actions.length > 0 && (
+
- {action.text}
+ {i18n('confirmation-dialog--Cancel')}
- ))}
-
+ {actions.map((action, i) => (
+
+ {action.text}
+
+ ))}
+
+ )}
);
}
diff --git a/ts/components/SafetyNumberChangeDialog.stories.tsx b/ts/components/SafetyNumberChangeDialog.stories.tsx
new file mode 100644
index 0000000000..f3b0a31439
--- /dev/null
+++ b/ts/components/SafetyNumberChangeDialog.stories.tsx
@@ -0,0 +1,76 @@
+import * as React from 'react';
+import { SafetyNumberChangeDialog } from './SafetyNumberChangeDialog';
+import { ConversationType } from '../state/ducks/conversations';
+
+// @ts-ignore
+import { setup as setupI18n } from '../../js/modules/i18n';
+// @ts-ignore
+import enMessages from '../../_locales/en/messages.json';
+
+import { action } from '@storybook/addon-actions';
+import { storiesOf } from '@storybook/react';
+
+const i18n = setupI18n('en', enMessages);
+
+const contact = {
+ avatarPath: undefined,
+ color: 'signal-blue',
+ profileName: undefined,
+ name: 'Rick Sanchez',
+ phoneNumber: '3051234567',
+} as ConversationType;
+
+storiesOf('Components/SafetyNumberChangeDialog', module)
+ .add('Single Contact Dialog', () => {
+ return (
+ {
+ action('renderSafetyNumber');
+ return This is a mock Safety Number View
;
+ }}
+ />
+ );
+ })
+ .add('Multi Contact Dialog', () => {
+ return (
+ {
+ action('renderSafetyNumber');
+ return This is a mock Safety Number View
;
+ }}
+ />
+ );
+ })
+ .add('Scroll Dialog', () => {
+ return (
+ {
+ action('renderSafetyNumber');
+ return This is a mock Safety Number View
;
+ }}
+ />
+ );
+ });
diff --git a/ts/components/SafetyNumberChangeDialog.tsx b/ts/components/SafetyNumberChangeDialog.tsx
new file mode 100644
index 0000000000..de4be5166b
--- /dev/null
+++ b/ts/components/SafetyNumberChangeDialog.tsx
@@ -0,0 +1,135 @@
+import * as React from 'react';
+import { Avatar } from './Avatar';
+import { ConfirmationModal } from './ConfirmationModal';
+import { ConversationType } from '../state/ducks/conversations';
+import { LocalizerType } from '../types/Util';
+
+type SafetyNumberProps = {
+ contactID: string;
+ onClose?: () => void;
+};
+
+export type Props = {
+ readonly contacts: Array;
+ readonly i18n: LocalizerType;
+ readonly onCancel: () => void;
+ readonly onConfirm: () => void;
+ readonly renderSafetyNumber: (props: SafetyNumberProps) => JSX.Element;
+};
+
+type SafetyDialogContentProps = Props & {
+ readonly onView: (contact: ConversationType) => void;
+};
+
+const SafetyDialogContents = ({
+ contacts,
+ i18n,
+ onCancel,
+ onConfirm,
+ onView,
+}: SafetyDialogContentProps): JSX.Element => {
+ const cancelButtonRef = React.createRef();
+
+ React.useEffect(() => {
+ if (cancelButtonRef && cancelButtonRef.current) {
+ cancelButtonRef.current.focus();
+ }
+ }, [contacts]);
+
+ return (
+ <>
+
+ {i18n('safetyNumberChanges')}
+
+
+ {i18n('changedVerificationWarning')}
+
+
+ {contacts.map((contact: ConversationType) => (
+
+
+
+ {contact.name && (
+ <>
+
+ {contact.name}
+
+
+ {contact.phoneNumber}
+
+ >
+ )}
+ {!contact.name && (
+
+ {contact.phoneNumber}
+
+ )}
+
+ {
+ onView(contact);
+ }}
+ tabIndex={0}
+ >
+ {i18n('view')}
+
+
+ ))}
+
+
+
+ {i18n('cancel')}
+
+
+ {i18n('sendMessageToContact')}
+
+
+ >
+ );
+};
+
+export const SafetyNumberChangeDialog = (props: Props): JSX.Element => {
+ const { i18n, onCancel, renderSafetyNumber } = props;
+ const [contact, setViewSafetyNumber] = React.useState<
+ ConversationType | undefined
+ >(undefined);
+
+ const onClose = contact
+ ? () => {
+ setViewSafetyNumber(undefined);
+ }
+ : onCancel;
+
+ return (
+
+ {contact && renderSafetyNumber({ contactID: contact.id, onClose })}
+ {!contact && (
+ {
+ setViewSafetyNumber(selectedContact);
+ }}
+ />
+ )}
+
+ );
+};
diff --git a/ts/components/SafetyNumberViewer.stories.tsx b/ts/components/SafetyNumberViewer.stories.tsx
new file mode 100644
index 0000000000..2462276d61
--- /dev/null
+++ b/ts/components/SafetyNumberViewer.stories.tsx
@@ -0,0 +1,85 @@
+import * as React from 'react';
+import { SafetyNumberViewer } from './SafetyNumberViewer';
+import { ConversationType } from '../state/ducks/conversations';
+
+// @ts-ignore
+import { setup as setupI18n } from '../../js/modules/i18n';
+// @ts-ignore
+import enMessages from '../../_locales/en/messages.json';
+
+import { action } from '@storybook/addon-actions';
+import { boolean, text } from '@storybook/addon-knobs';
+import { storiesOf } from '@storybook/react';
+
+const i18n = setupI18n('en', enMessages);
+
+const defaultProps = {
+ contact: {
+ title: 'Summer Smith',
+ isVerified: true,
+ } as ConversationType,
+ generateSafetyNumber: action('generate-safety-number'),
+ i18n,
+ safetyNumber: 'XXX',
+ safetyNumberChanged: false,
+ toggleVerified: action('toggle-verified'),
+ verificationDisabled: false,
+};
+
+const permutations = [
+ {
+ title: 'Safety Number',
+ props: {},
+ },
+ {
+ title: 'Safety Number (not verified)',
+ props: {
+ contact: {
+ title: 'Morty Smith',
+ isVerified: false,
+ } as ConversationType,
+ },
+ },
+ {
+ title: 'Verification Disabled',
+ props: {
+ verificationDisabled: true,
+ },
+ },
+ {
+ title: 'Safety Number Changed',
+ props: {
+ safetyNumberChanged: true,
+ },
+ },
+ {
+ title: 'Safety Number (dialog close)',
+ props: {
+ onClose: action('close'),
+ },
+ },
+];
+
+storiesOf('Components/SafetyNumberViewer', module)
+ .add('Knobs Playground', () => {
+ const safetyNumber = text('safetyNumber', 'XXX');
+ const safetyNumberChanged = boolean('safetyNumberChanged', false);
+ const verificationDisabled = boolean('verificationDisabled', false);
+
+ return (
+
+ );
+ })
+ .add('Iterations', () => {
+ return permutations.map(({ props, title }) => (
+ <>
+ {title}
+
+ >
+ ));
+ });
diff --git a/ts/components/SafetyNumberViewer.tsx b/ts/components/SafetyNumberViewer.tsx
new file mode 100644
index 0000000000..e419cc3ff4
--- /dev/null
+++ b/ts/components/SafetyNumberViewer.tsx
@@ -0,0 +1,82 @@
+import React from 'react';
+import { ConversationType } from '../state/ducks/conversations';
+import { LocalizerType } from '../types/Util';
+import { getPlaceholder } from '../util/safetyNumber';
+
+type SafetyNumberViewerProps = {
+ contact?: ConversationType;
+ generateSafetyNumber: (contact: ConversationType) => void;
+ i18n: LocalizerType;
+ onClose?: () => void;
+ safetyNumber: string;
+ safetyNumberChanged?: boolean;
+ toggleVerified: (contact: ConversationType) => void;
+ verificationDisabled: boolean;
+};
+
+export const SafetyNumberViewer = ({
+ contact,
+ generateSafetyNumber,
+ i18n,
+ onClose,
+ safetyNumber,
+ safetyNumberChanged,
+ toggleVerified,
+ verificationDisabled,
+}: SafetyNumberViewerProps): JSX.Element | null => {
+ if (!contact) {
+ return null;
+ }
+
+ React.useEffect(() => {
+ generateSafetyNumber(contact);
+ }, [safetyNumber]);
+
+ const name = contact.title;
+ const isVerified = contact.isVerified;
+ const verifiedStatus = isVerified
+ ? i18n('isVerified', [name])
+ : i18n('isNotVerified', [name]);
+ const verifyButtonText = isVerified ? i18n('unverify') : i18n('verify');
+
+ return (
+
+ {onClose && (
+
+
+
+
+
+ )}
+
+ {safetyNumberChanged
+ ? i18n('changedRightAfterVerify', [name, name])
+ : i18n('yourSafetyNumberWith', [name])}
+
+
+ {safetyNumber || getPlaceholder()}
+
+ {i18n('verifyHelp', [name])}
+
+ {isVerified ? (
+
+ ) : (
+
+ )}
+ {verifiedStatus}
+
+
+ {
+ toggleVerified(contact);
+ }}
+ tabIndex={0}
+ >
+ {verifyButtonText}
+
+
+
+ );
+};
diff --git a/ts/components/SearchResults.stories.tsx b/ts/components/SearchResults.stories.tsx
index 0829c882a4..7a61b79b88 100644
--- a/ts/components/SearchResults.stories.tsx
+++ b/ts/components/SearchResults.stories.tsx
@@ -37,7 +37,6 @@ const SENT = 'sent' as 'sent';
const START_NEW_CONVERSATION = 'start-new-conversation' as 'start-new-conversation';
const SMS_MMS_NOT_SUPPORTED = 'sms-mms-not-supported-text' as 'sms-mms-not-supported-text';
-// tslint:disable-next-line no-backbone-get-set-outside-model
messageLookup.set('1-guid-guid-guid-guid-guid', {
id: '1-guid-guid-guid-guid-guid',
conversationId: '(202) 555-0015',
@@ -56,7 +55,6 @@ messageLookup.set('1-guid-guid-guid-guid-guid', {
},
});
-// tslint:disable-next-line no-backbone-get-set-outside-model
messageLookup.set('2-guid-guid-guid-guid-guid', {
id: '2-guid-guid-guid-guid-guid',
conversationId: '(202) 555-0016',
@@ -73,7 +71,6 @@ messageLookup.set('2-guid-guid-guid-guid-guid', {
},
});
-// tslint:disable-next-line no-backbone-get-set-outside-model
messageLookup.set('3-guid-guid-guid-guid-guid', {
id: '3-guid-guid-guid-guid-guid',
conversationId: 'EveryoneGroupID',
@@ -91,7 +88,6 @@ messageLookup.set('3-guid-guid-guid-guid-guid', {
},
});
-// tslint:disable-next-line no-backbone-get-set-outside-model
messageLookup.set('4-guid-guid-guid-guid-guid', {
id: '4-guid-guid-guid-guid-guid',
conversationId: 'EveryoneGroupID',
diff --git a/ts/shims/contactVerification.ts b/ts/shims/contactVerification.ts
new file mode 100644
index 0000000000..ccb0ce0723
--- /dev/null
+++ b/ts/shims/contactVerification.ts
@@ -0,0 +1,13 @@
+export async function toggleVerification(id: string): Promise {
+ const contact = window.getConversations().get(id);
+ if (contact) {
+ await contact.toggleVerified();
+ }
+}
+
+export async function reloadProfiles(id: string): Promise {
+ const contact = window.getConversations().get(id);
+ if (contact) {
+ await contact.getProfiles();
+ }
+}
diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts
index d4a7401f62..eaf18aeb37 100644
--- a/ts/sql/Server.ts
+++ b/ts/sql/Server.ts
@@ -1,4 +1,4 @@
-// tslint:disable no-backbone-get-set-outside-model no-console no-default-export no-unnecessary-local-variable
+// tslint:disable no-console no-default-export no-unnecessary-local-variable
import { join } from 'path';
import mkdirp from 'mkdirp';
diff --git a/ts/state/actions.ts b/ts/state/actions.ts
index e130bd02f5..24a6d122e4 100644
--- a/ts/state/actions.ts
+++ b/ts/state/actions.ts
@@ -4,6 +4,7 @@ import { actions as emojis } from './ducks/emojis';
import { actions as expiration } from './ducks/expiration';
import { actions as items } from './ducks/items';
import { actions as network } from './ducks/network';
+import { actions as safetyNumber } from './ducks/safetyNumber';
import { actions as search } from './ducks/search';
import { actions as stickers } from './ducks/stickers';
import { actions as updates } from './ducks/updates';
@@ -16,6 +17,7 @@ export const mapDispatchToProps = {
...expiration,
...items,
...network,
+ ...safetyNumber,
...search,
...stickers,
...updates,
diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts
index 0a13d7b48d..2394d5f6dc 100644
--- a/ts/state/ducks/conversations.ts
+++ b/ts/state/ducks/conversations.ts
@@ -24,12 +24,15 @@ export type DBConversationType = {
};
export type ConversationType = {
id: string;
+ uuid?: string;
+ e164: string;
name?: string;
profileName?: string;
avatarPath?: string;
color?: ColorType;
isArchived?: boolean;
isBlocked?: boolean;
+ isVerified?: boolean;
activeAt?: number;
timestamp: number;
inboxPosition: number;
@@ -42,6 +45,7 @@ export type ConversationType = {
type: 'direct' | 'group';
isMe: boolean;
lastUpdated: number;
+ title: string;
unreadCount: number;
isSelected: boolean;
typingContact?: {
diff --git a/ts/state/ducks/safetyNumber.ts b/ts/state/ducks/safetyNumber.ts
new file mode 100644
index 0000000000..ba23244a30
--- /dev/null
+++ b/ts/state/ducks/safetyNumber.ts
@@ -0,0 +1,214 @@
+import { generateSecurityNumberBlock } from '../../util/safetyNumber';
+import { ConversationType } from './conversations';
+import {
+ reloadProfiles,
+ toggleVerification,
+} from '../../shims/contactVerification';
+
+export type SafetyNumberContactType = {
+ safetyNumber: string;
+ safetyNumberChanged?: boolean;
+ verificationDisabled: boolean;
+};
+
+export type SafetyNumberStateType = {
+ contacts: {
+ [key: string]: SafetyNumberContactType;
+ };
+};
+
+const GENERATE = 'safetyNumber/GENERATE';
+const GENERATE_FULFILLED = 'safetyNumber/GENERATE_FULFILLED';
+const TOGGLE_VERIFIED = 'safetyNumber/TOGGLE_VERIFIED';
+const TOGGLE_VERIFIED_FULFILLED = 'safetyNumber/TOGGLE_VERIFIED_FULFILLED';
+const TOGGLE_VERIFIED_PENDING = 'safetyNumber/TOGGLE_VERIFIED_PENDING';
+
+type GenerateAsyncActionType = {
+ contact: ConversationType;
+ safetyNumber: string;
+};
+
+type GenerateActionType = {
+ type: 'safetyNumber/GENERATE';
+ payload: Promise;
+};
+
+type GenerateFulfilledActionType = {
+ type: 'safetyNumber/GENERATE_FULFILLED';
+ payload: GenerateAsyncActionType;
+};
+
+type ToggleVerifiedAsyncActionType = {
+ contact: ConversationType;
+ safetyNumber?: string;
+ safetyNumberChanged?: boolean;
+};
+
+type ToggleVerifiedActionType = {
+ type: 'safetyNumber/TOGGLE_VERIFIED';
+ payload: {
+ data: { contact: ConversationType };
+ promise: Promise;
+ };
+};
+
+type ToggleVerifiedPendingActionType = {
+ type: 'safetyNumber/TOGGLE_VERIFIED_PENDING';
+ payload: ToggleVerifiedAsyncActionType;
+};
+
+type ToggleVerifiedFulfilledActionType = {
+ type: 'safetyNumber/TOGGLE_VERIFIED_FULFILLED';
+ payload: ToggleVerifiedAsyncActionType;
+};
+
+export type SafetyNumberActionTypes =
+ | GenerateActionType
+ | GenerateFulfilledActionType
+ | ToggleVerifiedActionType
+ | ToggleVerifiedPendingActionType
+ | ToggleVerifiedFulfilledActionType;
+
+function generate(contact: ConversationType): GenerateActionType {
+ return {
+ type: GENERATE,
+ payload: doGenerate(contact),
+ };
+}
+
+async function doGenerate(
+ contact: ConversationType
+): Promise {
+ const securityNumberBlock = await generateSecurityNumberBlock(contact);
+ return {
+ contact,
+ safetyNumber: securityNumberBlock.join(' '),
+ };
+}
+
+function toggleVerified(contact: ConversationType): ToggleVerifiedActionType {
+ return {
+ type: TOGGLE_VERIFIED,
+ payload: {
+ data: { contact },
+ promise: doToggleVerified(contact),
+ },
+ };
+}
+
+async function alterVerification(contact: ConversationType): Promise {
+ try {
+ await toggleVerification(contact.id);
+ } catch (result) {
+ if (result instanceof Error) {
+ if (result.name === 'OutgoingIdentityKeyError') {
+ throw result;
+ } else {
+ window.log.error(
+ 'failed to toggle verified:',
+ result && result.stack ? result.stack : result
+ );
+ }
+ } else {
+ const keyError = result.errors.find(
+ (error: Error) => error.name === 'OutgoingIdentityKeyError'
+ );
+ if (keyError) {
+ throw keyError;
+ } else {
+ result.errors.forEach((error: Error) => {
+ window.log.error(
+ 'failed to toggle verified:',
+ error && error.stack ? error.stack : error
+ );
+ });
+ }
+ }
+ }
+}
+
+async function doToggleVerified(
+ contact: ConversationType
+): Promise {
+ try {
+ await alterVerification(contact);
+ } catch (err) {
+ if (err.name === 'OutgoingIdentityKeyError') {
+ await reloadProfiles(contact.id);
+ const securityNumberBlock = await generateSecurityNumberBlock(contact);
+
+ return {
+ contact,
+ safetyNumber: securityNumberBlock.join(' '),
+ safetyNumberChanged: true,
+ };
+ }
+ }
+
+ return { contact };
+}
+
+export const actions = {
+ generateSafetyNumber: generate,
+ toggleVerified,
+};
+
+function getEmptyState(): SafetyNumberStateType {
+ return {
+ contacts: {},
+ };
+}
+
+export function reducer(
+ state: SafetyNumberStateType = getEmptyState(),
+ action: SafetyNumberActionTypes
+): SafetyNumberStateType {
+ if (action.type === TOGGLE_VERIFIED_PENDING) {
+ const { contact } = action.payload;
+ const { id } = contact;
+ const record = state.contacts[id];
+ return {
+ contacts: {
+ ...state.contacts,
+ [id]: {
+ ...record,
+ safetyNumberChanged: false,
+ verificationDisabled: true,
+ },
+ },
+ };
+ }
+
+ if (action.type === TOGGLE_VERIFIED_FULFILLED) {
+ const { contact, ...restProps } = action.payload;
+ const { id } = contact;
+ const record = state.contacts[id];
+ return {
+ contacts: {
+ ...state.contacts,
+ [id]: {
+ ...record,
+ ...restProps,
+ verificationDisabled: false,
+ },
+ },
+ };
+ }
+
+ if (action.type === GENERATE_FULFILLED) {
+ const { contact, safetyNumber } = action.payload;
+ const { id } = contact;
+ const record = state.contacts[id];
+ return {
+ contacts: {
+ ...state.contacts,
+ [id]: {
+ ...record,
+ safetyNumber,
+ },
+ },
+ };
+ }
+
+ return state;
+}
diff --git a/ts/state/reducer.ts b/ts/state/reducer.ts
index 9f1b1c9fa8..0aa1fcb321 100644
--- a/ts/state/reducer.ts
+++ b/ts/state/reducer.ts
@@ -30,6 +30,11 @@ import {
NetworkStateType,
reducer as network,
} from './ducks/network';
+import {
+ reducer as safetyNumber,
+ SafetyNumberActionTypes,
+ SafetyNumberStateType,
+} from './ducks/safetyNumber';
import {
reducer as search,
SEARCH_TYPES as SearchActionType,
@@ -54,6 +59,7 @@ export type StateType = {
expiration: ExpirationStateType;
items: ItemsStateType;
network: NetworkStateType;
+ safetyNumber: SafetyNumberStateType;
search: SearchStateType;
stickers: StickersStateType;
updates: UpdatesStateType;
@@ -67,6 +73,7 @@ export type ActionsType =
| ConversationActionType
| ItemsActionType
| NetworkActionType
+ | SafetyNumberActionTypes
| StickersActionType
| SearchActionType
| UpdatesActionType;
@@ -78,6 +85,7 @@ export const reducers = {
expiration,
items,
network,
+ safetyNumber,
search,
stickers,
updates,
diff --git a/ts/state/roots/createSafetyNumberViewer.tsx b/ts/state/roots/createSafetyNumberViewer.tsx
new file mode 100644
index 0000000000..e670945220
--- /dev/null
+++ b/ts/state/roots/createSafetyNumberViewer.tsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import { Provider } from 'react-redux';
+
+import { Store } from 'redux';
+
+import { SmartSafetyNumberViewer } from '../smart/SafetyNumberViewer';
+
+// Workaround: A react component's required properties are filtering up through connect()
+// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363
+const FilteredSafetyNumberViewer = SmartSafetyNumberViewer as any;
+
+type Props = {
+ contactID: string;
+ onClose?: () => void;
+};
+
+export const createSafetyNumberViewer = (store: Store, props: Props) => (
+
+
+
+);
diff --git a/ts/state/selectors/safetyNumber.ts b/ts/state/selectors/safetyNumber.ts
new file mode 100644
index 0000000000..c977fa4616
--- /dev/null
+++ b/ts/state/selectors/safetyNumber.ts
@@ -0,0 +1,24 @@
+import { createSelector } from 'reselect';
+
+import { StateType } from '../reducer';
+import {
+ SafetyNumberContactType,
+ SafetyNumberStateType,
+} from '../ducks/safetyNumber';
+
+const getSafetyNumber = (state: StateType): SafetyNumberStateType =>
+ state.safetyNumber;
+
+type Props = {
+ contactID: string;
+};
+
+const getContactID = (_: StateType, props: Props): string => props.contactID;
+
+export const getContactSafetyNumber = createSelector(
+ [getSafetyNumber, getContactID],
+ (
+ { contacts }: SafetyNumberStateType,
+ contactID: string
+ ): SafetyNumberContactType => contacts[contactID]
+);
diff --git a/ts/state/smart/SafetyNumberViewer.tsx b/ts/state/smart/SafetyNumberViewer.tsx
new file mode 100644
index 0000000000..e544b127af
--- /dev/null
+++ b/ts/state/smart/SafetyNumberViewer.tsx
@@ -0,0 +1,25 @@
+import { connect } from 'react-redux';
+import { mapDispatchToProps } from '../actions';
+import { SafetyNumberViewer } from '../../components/SafetyNumberViewer';
+import { StateType } from '../reducer';
+import { getContactSafetyNumber } from '../selectors/safetyNumber';
+import { getConversationSelector } from '../selectors/conversations';
+import { getIntl } from '../selectors/user';
+
+type Props = {
+ contactID: string;
+ onClose?: () => void;
+};
+
+const mapStateToProps = (state: StateType, props: Props) => {
+ return {
+ ...props,
+ ...getContactSafetyNumber(state, props),
+ contact: getConversationSelector(state)(props.contactID),
+ i18n: getIntl(state),
+ };
+};
+
+const smart = connect(mapStateToProps, mapDispatchToProps);
+
+export const SmartSafetyNumberViewer = smart(SafetyNumberViewer);
diff --git a/ts/test/state/selectors/conversations_test.ts b/ts/test/state/selectors/conversations_test.ts
index 10e5a9a31d..65abee8620 100644
--- a/ts/test/state/selectors/conversations_test.ts
+++ b/ts/test/state/selectors/conversations_test.ts
@@ -14,6 +14,7 @@ describe('state/selectors/conversations', () => {
const data: ConversationLookupType = {
id1: {
id: 'id1',
+ e164: '+18005551111',
activeAt: Date.now(),
name: 'No timestamp',
timestamp: 0,
@@ -24,6 +25,7 @@ describe('state/selectors/conversations', () => {
type: 'direct',
isMe: false,
lastUpdated: Date.now(),
+ title: 'No timestamp',
unreadCount: 1,
isSelected: false,
typingContact: {
@@ -36,6 +38,7 @@ describe('state/selectors/conversations', () => {
},
id2: {
id: 'id2',
+ e164: '+18005551111',
activeAt: Date.now(),
name: 'B',
timestamp: 20,
@@ -46,6 +49,7 @@ describe('state/selectors/conversations', () => {
type: 'direct',
isMe: false,
lastUpdated: Date.now(),
+ title: 'B',
unreadCount: 1,
isSelected: false,
typingContact: {
@@ -58,6 +62,7 @@ describe('state/selectors/conversations', () => {
},
id3: {
id: 'id3',
+ e164: '+18005551111',
activeAt: Date.now(),
name: 'C',
timestamp: 20,
@@ -68,6 +73,7 @@ describe('state/selectors/conversations', () => {
type: 'direct',
isMe: false,
lastUpdated: Date.now(),
+ title: 'C',
unreadCount: 1,
isSelected: false,
typingContact: {
@@ -80,6 +86,7 @@ describe('state/selectors/conversations', () => {
},
id4: {
id: 'id4',
+ e164: '+18005551111',
activeAt: Date.now(),
name: 'Á',
timestamp: 20,
@@ -90,6 +97,7 @@ describe('state/selectors/conversations', () => {
type: 'direct',
isMe: false,
lastUpdated: Date.now(),
+ title: 'A',
unreadCount: 1,
isSelected: false,
typingContact: {
@@ -102,6 +110,7 @@ describe('state/selectors/conversations', () => {
},
id5: {
id: 'id5',
+ e164: '+18005551111',
activeAt: Date.now(),
name: 'First!',
timestamp: 30,
@@ -112,6 +121,7 @@ describe('state/selectors/conversations', () => {
type: 'direct',
isMe: false,
lastUpdated: Date.now(),
+ title: 'First!',
unreadCount: 1,
isSelected: false,
typingContact: {
diff --git a/ts/textsecure.d.ts b/ts/textsecure.d.ts
index fc44d06765..0630fb8f92 100644
--- a/ts/textsecure.d.ts
+++ b/ts/textsecure.d.ts
@@ -96,6 +96,14 @@ type StoredSignedPreKeyType = SignedPreKeyType & {
created_at: number;
};
+type IdentityKeyRecord = {
+ publicKey: ArrayBuffer;
+ firstUse: boolean;
+ timestamp: number;
+ verified: number;
+ nonblockingApproval: boolean;
+};
+
export type StorageProtocolType = StorageType & {
VerifiedStatus: {
DEFAULT: number;
@@ -105,6 +113,7 @@ export type StorageProtocolType = StorageType & {
archiveSiblingSessions: (identifier: string) => Promise;
removeSession: (identifier: string) => Promise;
getDeviceIds: (identifier: string) => Promise>;
+ getIdentityRecord: (identifier: string) => IdentityKeyRecord | undefined;
hydrateCaches: () => Promise;
clearPreKeyStore: () => Promise;
clearSignedPreKeysStore: () => Promise;
@@ -119,13 +128,7 @@ export type StorageProtocolType = StorageType & {
loadSignedPreKeys: () => Promise>;
saveIdentityWithAttributes: (
number: string,
- options: {
- publicKey: ArrayBuffer;
- firstUse: boolean;
- timestamp: number;
- verified: number;
- nonblockingApproval: boolean;
- }
+ options: IdentityKeyRecord
) => Promise;
removeSignedPreKey: (keyId: number) => Promise;
removeAllData: () => Promise;
diff --git a/ts/textsecure/AccountManager.ts b/ts/textsecure/AccountManager.ts
index a62f4eeb43..0d5d85831b 100644
--- a/ts/textsecure/AccountManager.ts
+++ b/ts/textsecure/AccountManager.ts
@@ -1,4 +1,4 @@
-// tslint:disable no-backbone-get-set-outside-model no-default-export no-unnecessary-local-variable
+// tslint:disable no-default-export no-unnecessary-local-variable
import EventTarget from './EventTarget';
import { WebAPIType } from './WebAPI';
diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts
index 33ad764271..80f9ad9d45 100644
--- a/ts/textsecure/SendMessage.ts
+++ b/ts/textsecure/SendMessage.ts
@@ -1,4 +1,4 @@
-// tslint:disable no-backbone-get-set-outside-model no-bitwise no-default-export
+// tslint:disable no-bitwise no-default-export
import { without } from 'lodash';
import PQueue from 'p-queue';
diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts
index 749c7874b9..a4bc5644c1 100644
--- a/ts/textsecure/WebAPI.ts
+++ b/ts/textsecure/WebAPI.ts
@@ -189,7 +189,6 @@ const agents: AgentCacheType = {};
function getContentType(response: Response) {
if (response.headers && response.headers.get) {
- // tslint:disable-next-line no-backbone-get-set-outside-model
return response.headers.get('content-type');
}
@@ -310,7 +309,6 @@ async function _promiseAjax(
let resultPromise;
if (
options.responseType === 'json' &&
- // tslint:disable-next-line no-backbone-get-set-outside-model
response.headers.get('Content-Type') === 'application/json'
) {
resultPromise = response.json();
@@ -1464,7 +1462,6 @@ export function initialize({
throw new Error('makeProxiedRequest: Problem retrieving header value');
}
- // tslint:disable-next-line no-backbone-get-set-outside-model
const range = response.headers.get('content-range');
const match = PARSE_RANGE_HEADER.exec(range);
diff --git a/ts/util/index.ts b/ts/util/index.ts
index 9fe3fea28b..880bbe9265 100644
--- a/ts/util/index.ts
+++ b/ts/util/index.ts
@@ -6,6 +6,10 @@ import { createBatcher } from './batcher';
import { createWaitBatcher } from './waitBatcher';
import { deleteForEveryone } from './deleteForEveryone';
import { downloadAttachment } from './downloadAttachment';
+import {
+ generateSecurityNumber,
+ getPlaceholder as getSafetyNumberPlaceholder,
+} from './safetyNumber';
import { hasExpired } from './hasExpired';
import { isFileDangerous } from './isFileDangerous';
import { makeLookup } from './makeLookup';
@@ -20,6 +24,8 @@ export {
createWaitBatcher,
deleteForEveryone,
downloadAttachment,
+ generateSecurityNumber,
+ getSafetyNumberPlaceholder,
GoogleChrome,
hasExpired,
isFileDangerous,
diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json
index 7ed8252974..9b13a69d26 100644
--- a/ts/util/lint/exceptions.json
+++ b/ts/util/lint/exceptions.json
@@ -223,7 +223,7 @@
"rule": "jQuery-wrap(",
"path": "js/models/conversations.js",
"line": " await wrap(",
- "lineNumber": 641,
+ "lineNumber": 644,
"reasonCategory": "falseMatch",
"updated": "2020-06-09T20:26:46.515Z"
},
@@ -710,46 +710,18 @@
{
"rule": "jQuery-$(",
"path": "js/views/key_verification_view.js",
- "line": " new QRCode(this.$('.qr')[0]).makeCode(",
- "lineNumber": 43,
+ "line": " this.$('.key-verification-wrapper').append(view.el);",
+ "lineNumber": 23,
"reasonCategory": "usageTrusted",
- "updated": "2020-02-14T20:02:37.507Z",
- "reasonDetail": "Hardcoded selector"
+ "updated": "2020-06-23T06:48:06.829Z"
},
{
- "rule": "jQuery-wrap(",
+ "rule": "jQuery-append(",
"path": "js/views/key_verification_view.js",
- "line": " dcodeIO.ByteBuffer.wrap(this.ourKey).toString('base64')",
- "lineNumber": 44,
- "reasonCategory": "falseMatch",
- "updated": "2020-02-14T20:02:37.507Z"
- },
- {
- "rule": "jQuery-insertBefore(",
- "path": "js/views/key_verification_view.js",
- "line": " dialog.$el.insertBefore(this.el);",
- "lineNumber": 86,
+ "line": " this.$('.key-verification-wrapper').append(view.el);",
+ "lineNumber": 23,
"reasonCategory": "usageTrusted",
- "updated": "2020-02-14T20:02:37.507Z",
- "reasonDetail": "Known DOM elements"
- },
- {
- "rule": "jQuery-$(",
- "path": "js/views/key_verification_view.js",
- "line": " this.$('button.verify').attr('disabled', true);",
- "lineNumber": 90,
- "reasonCategory": "usageTrusted",
- "updated": "2020-02-14T20:02:37.507Z",
- "reasonDetail": "Hardcoded selector"
- },
- {
- "rule": "jQuery-$(",
- "path": "js/views/key_verification_view.js",
- "line": " this.$('button.verify').removeAttr('disabled');",
- "lineNumber": 121,
- "reasonCategory": "usageTrusted",
- "updated": "2020-02-14T20:02:37.507Z",
- "reasonDetail": "Hardcoded selector"
+ "updated": "2020-06-23T06:48:06.829Z"
},
{
"rule": "jQuery-append(",
@@ -841,6 +813,22 @@
"updated": "2018-10-11T19:22:47.331Z",
"reasonDetail": "Operating on already-existing DOM elements"
},
+ {
+ "rule": "jQuery-$(",
+ "path": "js/views/safety_number_change_dialog_view.js",
+ "line": " this.$('.safety-number-change-dialog-wrapper').append(dialog.el);",
+ "lineNumber": 36,
+ "reasonCategory": "usageTrusted",
+ "updated": "2020-06-23T06:48:06.829Z"
+ },
+ {
+ "rule": "jQuery-append(",
+ "path": "js/views/safety_number_change_dialog_view.js",
+ "line": " this.$('.safety-number-change-dialog-wrapper').append(dialog.el);",
+ "lineNumber": 36,
+ "reasonCategory": "usageTrusted",
+ "updated": "2020-06-23T06:48:06.829Z"
+ },
{
"rule": "jQuery-$(",
"path": "js/views/settings_view.js",
@@ -11579,6 +11567,15 @@
"updated": "2020-02-14T20:02:37.507Z",
"reasonDetail": "Used only to set focus"
},
+ {
+ "rule": "React-createRef",
+ "path": "ts/components/SafetyNumberChangeDialog.js",
+ "line": " const cancelButtonRef = React.createRef();",
+ "lineNumber": 14,
+ "reasonCategory": "usageTrusted",
+ "updated": "2020-06-23T06:48:06.829Z",
+ "reasonDetail": "Used to focus cancel button when dialog opens"
+ },
{
"rule": "React-createRef",
"path": "ts/components/SearchResults.js",
diff --git a/ts/util/registration.ts b/ts/util/registration.ts
index adaa7ed7fc..07eb8b9832 100644
--- a/ts/util/registration.ts
+++ b/ts/util/registration.ts
@@ -13,11 +13,9 @@ export function remove() {
}
export function isDone() {
- // tslint:disable-next-line no-backbone-get-set-outside-model
return window.storage.get('chromiumRegistrationDone') === '';
}
export function everDone() {
- // tslint:disable-next-line no-backbone-get-set-outside-model
return window.storage.get('chromiumRegistrationDoneEver') === '' || isDone();
}
diff --git a/ts/util/safetyNumber.ts b/ts/util/safetyNumber.ts
new file mode 100644
index 0000000000..a4eb500256
--- /dev/null
+++ b/ts/util/safetyNumber.ts
@@ -0,0 +1,58 @@
+import { ConversationType } from '../state/ducks/conversations';
+
+export async function generateSecurityNumber(
+ ourNumber: string,
+ ourKey: ArrayBuffer,
+ theirNumber: string,
+ theirKey: ArrayBuffer
+): Promise {
+ return new window.libsignal.FingerprintGenerator(5200).createFor(
+ ourNumber,
+ ourKey,
+ theirNumber,
+ theirKey
+ );
+}
+
+export function getPlaceholder(): string {
+ return Array.from(Array(12))
+ .map(() => 'XXXXX')
+ .join(' ');
+}
+
+export async function generateSecurityNumberBlock(
+ contact: ConversationType
+): Promise> {
+ const ourNumber = window.textsecure.storage.user.getNumber();
+ const ourUuid = window.textsecure.storage.user.getUuid();
+
+ const us = window.textsecure.storage.protocol.getIdentityRecord(
+ ourUuid || ourNumber
+ );
+ const ourKey = us ? us.publicKey : null;
+
+ const them = window.textsecure.storage.protocol.getIdentityRecord(contact.id);
+ const theirKey = them ? them.publicKey : null;
+
+ if (!ourKey) {
+ throw new Error('Could not load our key');
+ }
+
+ if (!theirKey) {
+ throw new Error('Could not load their key');
+ }
+
+ const securityNumber = await generateSecurityNumber(
+ ourNumber,
+ ourKey,
+ contact.e164,
+ theirKey
+ );
+
+ const chunks = [];
+ for (let i = 0; i < securityNumber.length; i += 5) {
+ chunks.push(securityNumber.substring(i, i + 5));
+ }
+
+ return chunks;
+}
diff --git a/ts/window.d.ts b/ts/window.d.ts
index bddf0b9988..8b56c5eabe 100644
--- a/ts/window.d.ts
+++ b/ts/window.d.ts
@@ -13,9 +13,12 @@ import * as Crypto from './Crypto';
import { ColorType, LocalizerType } from './types/Util';
import { SendOptionsType } from './textsecure/SendMessage';
+type TaskResultType = any;
+
declare global {
interface Window {
dcodeIO: DCodeIOType;
+ getConversations: () => ConversationControllerType;
getExpiration: () => string;
getEnvironment: () => string;
getSocketStatus: () => number;
@@ -83,12 +86,16 @@ export type ConversationType = {
getColor(): ColorType | undefined;
getName(): string | undefined;
getNumber(): string;
+ getProfiles(): Promise>>;
getProfileName(): string | undefined;
getRecipients: () => Array;
getSendOptions(): SendOptionsType;
+ getTitle(): string;
+ isVerified(): boolean;
safeGetVerified(): Promise;
getIsAddedByContact(): boolean;
addCallHistory(details: CallHistoryDetailsType): void;
+ toggleVerified(): Promise;
};
export type ConversationControllerType = {
diff --git a/tslint.json b/tslint.json
index a0298466b4..9e5c769905 100644
--- a/tslint.json
+++ b/tslint.json
@@ -45,6 +45,9 @@
// We always want 'as Type'
"no-angle-bracket-type-assertion": true,
+ // mostly always a false positive
+ "no-backbone-get-set-outside-model": false,
+
"no-consecutive-blank-lines": [true, 2],
"object-literal-key-quotes": [true, "as-needed"],
"object-literal-sort-keys": false,