diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 83a3658d9..2a95145b3 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2289,5 +2289,77 @@ "example": "" } } + }, + "createAccount": { + "message": "Create Account" + }, + "signIn": { + "message": "Sign In" + }, + "yourUniqueSessionID": { + "message": "Your Unique Session ID" + }, + "allUsersAreRandomly...": { + "message": + "All users are randomly generated a set of numbers that act as their unique Session ID. Share your Session ID in order to chat with your friends!" + }, + "getStarted": { + "message": "Get started" + }, + "generateSessionID": { + "message": "Generate Session ID" + }, + "mnemonicSeed": { + "message": "Mnemonic Seed" + }, + "enterSeed": { + "message": "Enter Seed" + }, + "displayName": { + "message": "Display Name" + }, + "enterDisplayName": { + "message": "Enter Display Name / Alias" + }, + "optionalPassword": { + "message": "Optional Password" + }, + "enterOptionalPassword": { + "message": "Enter Optional Password" + }, + "verifyPassword": { + "message": "Verify Password" + }, + "devicePairingHeader": { + "message": + "Open the Loki Messenger App on your primary device and select Device Pairing from the main menu. Then, enter your Session ID below to sign in." + }, + "enterSessionIDHere": { + "message": "Enter your Session ID here" + }, + "continueYourSession": { + "message": "Continue Your Session" + }, + "restoreUsingSeed": { + "message": "Restore Using Seed" + }, + "linkDeviceToExistingAccount": { + "message": "Link Device To Existing Account" + }, + "or": { + "message": "or" + }, + "ByUsingThiService...": { + "message": + "By using this service, you agree to our Terms and Conditions and Privacy Statement" + }, + "beginYourSession": { + "message": "Begin
your
Session." + }, + "welcomeToYourSession": { + "message": "Welcome to your Session!" + }, + "completeSignUp": { + "message": "Complete Sign Up" } } diff --git a/background.html b/background.html index eeda839c3..78d3d7870 100644 --- a/background.html +++ b/background.html @@ -666,93 +666,6 @@ {{/isError}} - @@ -812,7 +725,7 @@ - + @@ -823,7 +736,7 @@ - + diff --git a/images/session/brand.svg b/images/session/brand.svg new file mode 100644 index 000000000..beb9de0e1 --- /dev/null +++ b/images/session/brand.svg @@ -0,0 +1,31 @@ + +image/svg+xml \ No newline at end of file diff --git a/js/background.js b/js/background.js index d5bb0898f..5c30f5ff4 100644 --- a/js/background.js +++ b/js/background.js @@ -321,7 +321,7 @@ window.Events = { getDeviceName: () => textsecure.storage.user.getDeviceName(), - getThemeSetting: () => storage.get('theme-setting', 'light'), + getThemeSetting: () => storage.get('theme-setting', 'dark'), setThemeSetting: value => { storage.put('theme-setting', value); onChangeTheme(); @@ -901,6 +901,12 @@ } }); + Whisper.events.on('openInbox', () => { + appView.openInbox({ + initialLoadComplete, + }); + }); + Whisper.events.on('onEditProfile', async () => { const ourNumber = window.storage.get('primaryDevicePubKey'); const conversation = await ConversationController.getOrCreateAndWait( diff --git a/js/modules/signal.js b/js/modules/signal.js index bf214dc98..65ef8c401 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -50,6 +50,9 @@ const { } = require('../../ts/components/conversation/CreateGroupDialog'); const { EditProfileDialog } = require('../../ts/components/EditProfileDialog'); const { UserDetailsDialog } = require('../../ts/components/UserDetailsDialog'); +const { + SessionRegistrationView, +} = require('../../ts/components/session/SessionRegistrationView'); const { UpdateGroupDialog, @@ -239,6 +242,7 @@ exports.setup = (options = {}) => { CreateGroupDialog, EditProfileDialog, UserDetailsDialog, + SessionRegistrationView, ConfirmDialog, UpdateGroupDialog, InviteFriendsDialog, diff --git a/js/views/app_view.js b/js/views/app_view.js index a946577e4..ab8fbc193 100644 --- a/js/views/app_view.js +++ b/js/views/app_view.js @@ -105,7 +105,7 @@ openStandalone() { window.addSetupMenuItems(); this.resetViews(); - this.standaloneView = new Whisper.StandaloneRegistrationView(); + this.standaloneView = new Whisper.SessionRegistrationView(); this.openView(this.standaloneView); }, closeStandalone() { diff --git a/js/views/session_registration_view.js b/js/views/session_registration_view.js new file mode 100644 index 000000000..619248f86 --- /dev/null +++ b/js/views/session_registration_view.js @@ -0,0 +1,133 @@ +/* eslint-disable no-plusplus */ +/* global + Whisper, +*/ + +/* eslint-disable more/no-then */ + +// eslint-disable-next-line func-names +(function() { + 'use strict'; + + window.Whisper = window.Whisper || {}; + + Whisper.SessionRegistrationView = Whisper.View.extend({ + className: 'session-fullscreen', + initialize() { + this.render(); + }, + render() { + this.session_registration_view = new Whisper.ReactWrapperView({ + className: 'session-full-screen-flow session-fullscreen', + Component: window.Signal.Components.SessionRegistrationView, + props: {}, + }); + + this.$el.append(this.session_registration_view.el); + return this; + }, + + log(s) { + window.log.info(s); + this.$('#status').text(s); + }, + displayError(error) { + this.$('#error') + .hide() + .text(error) + .addClass('in') + .fadeIn(); + }, + + showToast(message) { + const toast = new Whisper.MessageToastView({ + message, + }); + toast.$el.appendTo(this.$el); + toast.render(); + }, + }); + + class TextScramble { + constructor(el) { + this.el = el; + this.chars = '0123456789qwertyuiopasdfghjklzxcvbnm'; + this.update = this.update.bind(this); + } + + setText(newText) { + const oldText = this.el.innerText; + const length = Math.max(oldText.length, newText.length); + // eslint-disable-next-line no-return-assign + const promise = new Promise(resolve => (this.resolve = resolve)); + this.queue = []; + + for (let i = 0; i < length; i++) { + const from = oldText[i] || ''; + const to = newText[i] || ''; + const start = Math.floor(Math.random() * 40); + const end = start + Math.floor(Math.random() * 40); + this.queue.push({ + from, + to, + start, + end, + }); + } + + cancelAnimationFrame(this.frameRequest); + this.frame = 0; + this.update(); + return promise; + } + + update() { + let output = ''; + let complete = 0; + + for (let i = 0, n = this.queue.length; i < n; i++) { + const { from, to, start, end } = this.queue[i]; + let { char } = this.queue[i]; + + if (this.frame >= end) { + complete++; + output += to; + } else if (this.frame >= start) { + if (!char || Math.random() < 0.28) { + char = this.randomChar(); + this.queue[i].char = char; + } + + output += `${char}`; + } else { + output += from; + } + } + + this.el.innerHTML = output; + + if (complete === this.queue.length) { + this.resolve(); + } else { + this.frameRequest = requestAnimationFrame(this.update); + this.frame++; + } + } + + randomChar() { + return this.chars[Math.floor(Math.random() * this.chars.length)]; + } + } + window.Session = window.Session || {}; + + window.Session.setNewSessionID = sessionID => { + const el = document.querySelector('.session-signin-enter-session-id'); + const fx = new TextScramble(el); + el.innerHTML = sessionID; + fx.setText(sessionID); + }; + + window.Session.emptyContentEditableDivs = () => { + window.$('div[contenteditable]').html(''); + }; +})(); diff --git a/package.json b/package.json index 3fb73e63b..2dd44aa85 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "test-node-coverage-html": "nyc --reporter=lcov --reporter=html mocha --recursive test/app test/modules ts/test libloki/test/node", "eslint": "eslint .", "lint": "yarn format --list-different && yarn lint-windows", + "dev-lint": "yarn format --list-different; yarn lint-windows", "lint-windows": "yarn eslint && yarn tslint", "lint-deps": "node ts/util/lint/linter.js", "tslint": "tslint --format stylish --project .", @@ -54,6 +55,7 @@ "dependencies": { "@journeyapps/sqlcipher": "https://github.com/scottnonnenberg-signal/node-sqlcipher.git#2e28733b61640556b0272a3bfc78b0357daf71e6", "@sindresorhus/is": "0.8.0", + "@types/dompurify": "^2.0.0", "backbone": "1.3.3", "blob-util": "1.3.0", "blueimp-canvas-to-blob": "3.14.0", @@ -63,6 +65,7 @@ "classnames": "2.2.5", "color": "^3.1.2", "config": "1.28.1", + "dompurify": "^2.0.7", "electron-context-menu": "^0.15.0", "electron-editor-context-menu": "1.1.1", "electron-is-dev": "0.3.0", diff --git a/preload.js b/preload.js index cc55e5aba..7fdaadbfc 100644 --- a/preload.js +++ b/preload.js @@ -472,3 +472,9 @@ window.lokiFeatureFlags = { multiDeviceUnpairing: true, privateGroupChats: false, }; + +// eslint-disable-next-line no-extend-native,func-names +Promise.prototype.ignore = function() { + // eslint-disable-next-line more/no-then + this.then(() => {}); +}; diff --git a/stylesheets/_session.scss b/stylesheets/_session.scss index 40931f734..dfb00b139 100644 --- a/stylesheets/_session.scss +++ b/stylesheets/_session.scss @@ -21,13 +21,15 @@ font-weight: bold; font-style: italic; } - @font-face { font-family: 'Wasa'; - src: url('../fonts/Wasa-Bold.otf') format('truetype'); + src: url('../fonts/Wasa-Bold.otf') format('opentype'); + font-weight: bold; } // Session Colors +$session-font-family: 'Wasa'; + $session-color-green: #00f782; $session-color-green-alt-1: #00f480; $session-color-green-alt-2: #00fd73; @@ -56,11 +58,13 @@ $session-opaque-dark-2: rgba(0, 0, 0, 0.37); $session-opaque-dark-3: rgba(0, 0, 0, 0.5); $session-color-white: #fff; +$session-color-dark-grey: #353535; $session-color-black: #000; $session-color-danger: #ff4538; $session-color-primary: $session-shade-13; $session-color-secondary: $session-shade-16; $session-color-warning: $session-shade-17; +$session-color-light-grey: #a0a0a0; $session-shadow-opacity: 0.15; $session-overlay-opacity: 0.3; @@ -69,7 +73,6 @@ $session-overlay-opacity: 0.3; color: rgba($color, 0.6); } -$session-font-family: 'Wasa'; $session-transition-duration: 0.25s; $session-icon-size-sm: 15px; @@ -78,6 +81,18 @@ $session-icon-size-lg: 30px; $session-conversation-header-height: 60px; +@mixin fontWasaBold { + font-weight: 700; + font-family: $session-font-family; +} + +a, +div, +span, +label { + user-select: none; +} + $session-gradient-green: linear-gradient( 270deg, rgba($session-color-green-alt-1, 1), @@ -126,6 +141,7 @@ $session_message-container-border-radius: 5px; font-weight: 700; user-select: none; cursor: pointer; + transition: $session-transition-duration; &.default, &.square, @@ -133,7 +149,11 @@ $session_message-container-border-radius: 5px; color: $session-color-white; &.green { + border: 2px solid $session-color-green; background-color: $session-color-green; + &:hover { + @include transparent-background($session-color-green); + } } &.white { background-color: $session-color-white; @@ -157,9 +177,16 @@ $session_message-container-border-radius: 5px; &.square-outline { &.green { @include transparent-background($session-color-green); + + &:hover { + @include transparent-background($session-color-white); + } } &.white { @include transparent-background($session-color-white); + &:hover { + @include transparent-background($session-color-green); + } } &.primary { @include transparent-background($session-color-primary); @@ -179,7 +206,7 @@ $session_message-container-border-radius: 5px; min-width: 165px; height: 45px; line-height: 45px; - padding: 0 35px 0 35px; + padding: 0; font-size: 15px; font-family: $session-font-family; border-radius: 500px; @@ -271,45 +298,105 @@ $session_message-container-border-radius: 5px; padding-top: 5px; } -.module-message__container { +.odule-message__container { border-radius: $session_message-container-border-radius; -} -.module-message__attachment-container, -.module-image--curved-bottom-right, -.module-image--curved-bottom-left { - border-top-left-radius: 0px; - border-top-right-radius: 0px; - border-bottom-left-radius: $session_message-container-border-radius; - border-bottom-right-radius: $session_message-container-border-radius; -} + label { + user-select: none; + } -.conversation-header .session-icon-button { - @include standard-icon-button(); -} + .module-message__attachment-container, + .module-image--curved-bottom-right, + .module-image--curved-bottom-left { + border-top-left-radius: 0px; + border-top-right-radius: 0px; + border-bottom-left-radius: $session_message-container-border-radius; + border-bottom-right-radius: $session_message-container-border-radius; + } -.module-conversation-header, -.message-selection-overlay { - height: $session-conversation-header-height; -} + .conversation-header .session-icon-button { + @include standard-icon-button(); + } + + .module-conversation-header, + .message-selection-overlay { + height: $session-conversation-header-height; + } -.message-selection-overlay { - position: absolute; - left: 15px; - right: 15px; - display: none; + .message-selection-overlay { + position: absolute; + left: 15px; + right: 15px; + display: none; - .close-button { - float: left; - margin-top: 17px; - margin-left: 7px; + .close-button { + float: left; + margin-top: 17px; + margin-left: 7px; + } + } + .message-selection-overlay div[role='button'] { + display: inline-block; + } + + .message-selection-overlay .button-group { + float: right; + margin-top: 13.5px; } } -.message-selection-overlay div[role='button'] { - display: inline-block; + +.hidden { + visibility: hidden; } -.message-selection-overlay .button-group { - float: right; - margin-top: 13.5px; +.input-with-label-container { + height: 46.5px; + width: 280px; + color: $session-color-white; + padding: 2px 0 2px 0; + transition: opacity $session-transition-duration; + opacity: 1; + position: relative; + + label { + line-height: 14px; + opacity: 0; + color: #737373; + font-size: 10px; + line-height: 11px; + position: absolute; + top: 0px; + } + + &.filled { + opacity: 1; + } + + input { + border: none; + outline: 0; + height: 14px; + width: 280px; + background: transparent; + color: $session-color-white; + font-size: 12px; + line-height: 14px; + position: absolute; + top: 50%; + transform: translateY(-50%); + } + + hr { + border: 1px solid $session-color-white; + width: 100%; + position: absolute; + bottom: 0px; + } + + button { + position: absolute; + top: 50%; + transform: translateY(-50%); + right: 0px; + } } diff --git a/stylesheets/_session_signin.scss b/stylesheets/_session_signin.scss new file mode 100644 index 000000000..a0617f156 --- /dev/null +++ b/stylesheets/_session_signin.scss @@ -0,0 +1,245 @@ +.session { + &-fullscreen { + overflow-y: auto; + height: 100%; + background: linear-gradient(90deg, #121212 100%, #171717 0%); + } + + &-content { + width: 100%; + height: 100%; + display: flex; + align-items: center; + + &-accent { + flex-grow: 1; + padding-left: 20px; + + &-text { + color: $session-color-white; + font-family: $session-font-family; + + .title { + font-size: 100px; + font-weight: 700; + line-height: 120px; + } + } + } + + &-registration { + height: 45%; + padding-right: 128px; + } + + &-close-button { + position: absolute; + top: 17px; + left: 20px; + } + + &-session-button { + position: absolute; + top: 17px; + right: 20px; + + img { + width: 30px; + } + } + } + + &-registration { + &-container { + display: flex; + flex-direction: column; + width: 289px; + } + + &__content { + width: 100%; + overflow-y: auto; + padding-top: 20px; + } + + &__sections { + display: flex; + flex-grow: 1; + flex-direction: column; + } + + &__tab-container { + display: flex; + flex-grow: 0; + flex-shrink: 0; + cursor: pointer; + width: 289px; + height: 30px; + left: 0; + right: 0; + + margin-left: auto; + margin-right: auto; + color: $session-color-white; + } + + &__tab { + @include fontWasaBold(); + width: 100%; + padding-bottom: 10px; + background-color: transparent; + text-align: center; + color: $session-color-white; + border-bottom: 2px solid $session-color-dark-grey; + transition: border-color $session-transition-duration linear; + line-height: 17px; + font-size: 15px; + + &--active { + border-bottom: 4px solid $session-color-green; + } + } + + @mixin registration-label-mixin { + color: $session-color-white; + text-align: center; + font-size: 17px; + font-weight: 700; + line-height: 17px; + padding: 12px; + } + + &__or { + @include registration-label-mixin; + } + + &__welcome-session { + @include registration-label-mixin; + font-size: 12px; + font-weight: 700; + line-height: 12px; + padding-top: 2em; + } + + &__unique-session-id { + @include registration-label-mixin; + padding-top: 3em; + } + + &__entry-fields { + margin: 0px; + padding-bottom: 30px; + } + } + + &-input-floating-label-show-hide { + padding-right: 30px; + } + + &-input-with-label-container { + height: 46.5px; + width: 280px; + color: $session-color-white; + padding: 2px 0 2px 0; + transition: opacity $session-transition-duration; + opacity: 1; + position: relative; + + label { + line-height: 14px; + opacity: 0; + color: #737373; + font-size: 10px; + line-height: 11px; + position: absolute; + top: 0px; + } + + &.filled { + opacity: 1; + } + + input { + border: none; + outline: 0; + height: 14px; + width: 280px; + background: transparent; + color: $session-color-white; + font-size: 12px; + line-height: 14px; + position: absolute; + top: 50%; + transform: translateY(-50%); + } + + hr { + border: 1px solid $session-color-light-grey; + width: 100%; + position: absolute; + bottom: 0px; + } + + .session-icon-button { + position: absolute; + top: 50%; + transform: translateY(-50%); + right: 0px; + } + } + + &-terms-conditions-agreement { + padding-top: 10px; + color: $session-color-light-grey; + text-align: center; + font-size: 12px; + + a { + white-space: nowrap; + font-weight: bold; + color: $session-color-light-grey; + transition: $session-transition-duration; + + &:visited &:link { + color: $session-color-light-grey; + } + + &:hover { + color: $session-color-white; + } + } + } + + &-signup-header, + &-signin-device-pairing-header { + padding-top: 10px; + padding-bottom: 10px; + color: $session-color-light-grey; + text-align: center; + font-size: 12px; + line-height: 20px; + } + + &-signin-enter-session-id { + height: 94px; + width: 289px; + border-radius: 8px; + border: 2px solid $session-color-dark-grey; + outline: 0; + background: transparent; + color: $session-color-white; + font-size: 15px; + line-height: 18px; + text-align: center; + margin-bottom: 20px; + overflow-wrap: break-word; + padding: 20px 5px 20px 5px; + display: inline-block; + font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace; + } +} + +[contenteditable='true']:empty::before { + content: attr(placeholder); + color: $session-color-light-grey; +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 8f126d33a..6c36adcf5 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -25,6 +25,7 @@ // New CSS @import 'modules'; @import 'session'; +@import 'session_signin'; @import 'session_theme_dark'; // Installer diff --git a/ts/components/UserDetailsDialog.tsx b/ts/components/UserDetailsDialog.tsx index e52f49713..57cdec0f7 100644 --- a/ts/components/UserDetailsDialog.tsx +++ b/ts/components/UserDetailsDialog.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { Avatar } from './Avatar'; - declare global { interface Window { displayNameRegex: any; diff --git a/ts/components/conversation/Linkify.tsx b/ts/components/conversation/Linkify.tsx index 6562b6ed8..37313d478 100644 --- a/ts/components/conversation/Linkify.tsx +++ b/ts/components/conversation/Linkify.tsx @@ -4,6 +4,7 @@ import LinkifyIt from 'linkify-it'; import { RenderTextCallbackType } from '../../types/Util'; import { isLinkSneaky } from '../../../js/modules/link_previews'; +import { SessionHtmlRenderer } from '../session/SessionHTMLRenderer'; const linkify = LinkifyIt(); @@ -28,19 +29,7 @@ export class Linkify extends React.Component { let count = 1; if (isRss && text.indexOf(')<[^<]*)*<\/script>/gi, - '' - ) - .replace(/)<[^<]*)*<\/style>/gi, ''), - }} - /> - ); + results.push(); // should already have links return results; diff --git a/ts/components/session/AccentText.tsx b/ts/components/session/AccentText.tsx new file mode 100644 index 000000000..d7a562afc --- /dev/null +++ b/ts/components/session/AccentText.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +import { SessionHtmlRenderer } from './SessionHTMLRenderer'; + +export const AccentText: React.FC = () => ( +
+
+ +
+
+); diff --git a/ts/components/session/RegistrationTabs.tsx b/ts/components/session/RegistrationTabs.tsx new file mode 100644 index 000000000..610c506e4 --- /dev/null +++ b/ts/components/session/RegistrationTabs.tsx @@ -0,0 +1,763 @@ +import React from 'react'; +import classNames from 'classnames'; + +import { SessionInput } from './SessionInput'; +import { + SessionButton, + SessionButtonColor, + SessionButtonType, +} from './SessionButton'; +import { trigger } from '../../shims/events'; +import { SessionHtmlRenderer } from './SessionHTMLRenderer'; + +enum SignInMode { + Default, + UsingSeed, + LinkingDevice, +} + +enum SignUpMode { + Default, + SessionIDShown, + EnterDetails, +} + +enum TabType { + Create, + SignIn, +} + +interface State { + selectedTab: TabType; + signInMode: SignInMode; + signUpMode: SignUpMode; + displayName: string; + password: string; + validatePassword: string; + passwordErrorString: string; + passwordFieldsMatch: boolean; + mnemonicSeed: string; + hexGeneratedPubKey: string; + primaryDevicePubKey: string; +} + +const Tab = ({ + isSelected, + label, + onSelect, + type, +}: { + isSelected: boolean; + label: string; + onSelect?: (event: TabType) => void; + type: TabType; +}) => { + const handleClick = onSelect + ? () => { + onSelect(type); + } + : undefined; + + return ( +
+ {label} +
+ ); +}; + +export class RegistrationTabs extends React.Component<{}, State> { + private readonly accountManager: any; + + constructor(props: any) { + super(props); + + this.onSeedChanged = this.onSeedChanged.bind(this); + this.onDisplayNameChanged = this.onDisplayNameChanged.bind(this); + this.onPasswordChanged = this.onPasswordChanged.bind(this); + this.onPasswordVerifyChanged = this.onPasswordVerifyChanged.bind(this); + this.onSignUpGenerateSessionIDClick = this.onSignUpGenerateSessionIDClick.bind( + this + ); + this.onSignUpGetStartedClick = this.onSignUpGetStartedClick.bind(this); + this.onSecondDeviceSessionIDChanged = this.onSecondDeviceSessionIDChanged.bind( + this + ); + this.onSecondaryDeviceRegistered = this.onSecondaryDeviceRegistered.bind( + this + ); + this.onCompleteSignUpClick = this.onCompleteSignUpClick.bind(this); + this.handlePressEnter = this.handlePressEnter.bind(this); + this.handleContinueYourSessionClick = this.handleContinueYourSessionClick.bind( + this + ); + + this.state = { + selectedTab: TabType.Create, + signInMode: SignInMode.Default, + signUpMode: SignUpMode.Default, + displayName: '', + password: '', + validatePassword: '', + passwordErrorString: '', + passwordFieldsMatch: false, + mnemonicSeed: '', + hexGeneratedPubKey: '', + primaryDevicePubKey: '', + }; + + this.accountManager = window.getAccountManager(); + // Clean status in case the app closed unexpectedly + window.textsecure.storage.remove('secondaryDeviceStatus'); + } + + public render() { + this.generateMnemonicAndKeyPair().ignore(); + + return this.renderTabs(); + } + + private async generateMnemonicAndKeyPair() { + if (this.state.mnemonicSeed === '') { + const language = 'english'; + const mnemonic = await this.accountManager.generateMnemonic(language); + + let seedHex = window.mnemonic.mn_decode(mnemonic, language); + // handle shorter than 32 bytes seeds + const privKeyHexLength = 32 * 2; + if (seedHex.length !== privKeyHexLength) { + seedHex = seedHex.concat(seedHex); + seedHex = seedHex.substring(0, privKeyHexLength); + } + const privKeyHex = window.mnemonic.sc_reduce32(seedHex); + const privKey = window.dcodeIO.ByteBuffer.wrap( + privKeyHex, + 'hex' + ).toArrayBuffer(); + const keyPair = await window.libsignal.Curve.async.createKeyPair(privKey); + const hexGeneratedPubKey = Buffer.from(keyPair.pubKey).toString('hex'); + + this.setState({ + mnemonicSeed: mnemonic, + hexGeneratedPubKey, // our 'frontend' sessionID + }); + } + } + + private renderTabs() { + const { selectedTab } = this.state; + + const createAccount = window.i18n('createAccount'); + const signIn = window.i18n('signIn'); + const isCreateSelected = selectedTab === TabType.Create; + const isSignInSelected = selectedTab === TabType.SignIn; + + return ( +
+
+ + +
+ {this.renderSections()} +
+ ); + } + + private readonly handleTabSelect = (tabType: TabType): void => { + if (tabType !== TabType.SignIn) { + this.cancelSecondaryDevice().ignore(); + } + this.setState({ + selectedTab: tabType, + signInMode: SignInMode.Default, + signUpMode: SignUpMode.Default, + displayName: '', + password: '', + validatePassword: '', + passwordErrorString: '', + passwordFieldsMatch: false, + mnemonicSeed: '', + hexGeneratedPubKey: '', + primaryDevicePubKey: '', + }); + }; + + private onSeedChanged(val: string) { + this.setState({ mnemonicSeed: val }); + } + + private onDisplayNameChanged(val: string) { + const sanitizedName = this.sanitiseNameInput(val); + this.setState({ displayName: sanitizedName }); + } + + private onPasswordChanged(val: string) { + this.setState({ password: val }); + this.onValidatePassword(); // FIXME add bubbles or something to help the user know what he did wrong + } + + private onPasswordVerifyChanged(val: string) { + this.setState({ validatePassword: val }); + } + + private renderSections() { + const { selectedTab } = this.state; + if (selectedTab === TabType.Create) { + return this.renderSignUp(); + } + + return this.renderSignIn(); + } + + private renderSignUp() { + const { signUpMode } = this.state; + switch (signUpMode) { + case SignUpMode.Default: + return ( +
+ {this.renderSignUpHeader()} + {this.renderSignUpButton()} +
+ ); + case SignUpMode.SessionIDShown: + return ( +
+ {this.renderSignUpHeader()} +
+ {window.i18n('yourUniqueSessionID')} +
+ {this.renderEnterSessionID(false)} + {this.renderSignUpButton()} + {this.getRenderTermsConditionAgreement()} +
+ ); + + default: + return ( +
+
+ {window.i18n('welcomeToYourSession')} +
+ + {this.renderRegistrationContent()} + { + this.onCompleteSignUpClick(); + }} + buttonType={SessionButtonType.Brand} + buttonColor={SessionButtonColor.Green} + text={window.i18n('completeSignUp')} + /> +
+ ); + } + } + + private getRenderTermsConditionAgreement() { + const { selectedTab, signInMode, signUpMode } = this.state; + if (selectedTab === TabType.Create) { + return signUpMode !== SignUpMode.Default + ? this.renderTermsConditionAgreement() + : null; + } else { + return signInMode !== SignInMode.Default + ? this.renderTermsConditionAgreement() + : null; + } + } + + private renderSignUpHeader() { + const allUsersAreRandomly = window.i18n('allUsersAreRandomly...'); + + return
{allUsersAreRandomly}
; + } + + private renderSignUpButton() { + const { signUpMode } = this.state; + + let buttonType: SessionButtonType; + let buttonColor: SessionButtonColor; + let buttonText: string; + if (signUpMode !== SignUpMode.Default) { + buttonType = SessionButtonType.Brand; + buttonColor = SessionButtonColor.Green; + buttonText = window.i18n('getStarted'); + } else { + buttonType = SessionButtonType.BrandOutline; + buttonColor = SessionButtonColor.Green; + buttonText = window.i18n('generateSessionID'); + } + + return ( + { + if (signUpMode === SignUpMode.Default) { + this.onSignUpGenerateSessionIDClick().ignore(); + } else { + this.onSignUpGetStartedClick(); + } + }} + buttonType={buttonType} + buttonColor={buttonColor} + text={buttonText} + /> + ); + } + + private async onSignUpGenerateSessionIDClick() { + this.setState( + { + signUpMode: SignUpMode.SessionIDShown, + }, + () => { + window.Session.setNewSessionID(this.state.hexGeneratedPubKey); + } + ); + } + + private onSignUpGetStartedClick() { + this.setState({ + signUpMode: SignUpMode.EnterDetails, + }); + } + + private onCompleteSignUpClick() { + this.register('english').ignore(); + } + + private renderSignIn() { + return ( +
+ {this.renderRegistrationContent()} + + {this.renderSignInButtons()} + {this.getRenderTermsConditionAgreement()} +
+ ); + } + + private renderRegistrationContent() { + const { signInMode, signUpMode } = this.state; + + if (signInMode === SignInMode.UsingSeed) { + return ( +
+ { + this.onSeedChanged(val); + }} + onEnterPressed={() => { + this.handlePressEnter(); + }} + /> + {this.renderNamePasswordAndVerifyPasswordFields()} +
+ ); + } + if (signInMode === SignInMode.LinkingDevice) { + return ( +
+
+ {window.i18n('devicePairingHeader')} +
+ {this.renderEnterSessionID(true)} +
+ ); + } + if (signUpMode === SignUpMode.EnterDetails) { + return ( +
+ {this.renderNamePasswordAndVerifyPasswordFields()}; +
+ ); + } + + return null; + } + + private renderNamePasswordAndVerifyPasswordFields() { + return ( +
+ { + this.onDisplayNameChanged(val); + }} + onEnterPressed={() => { + this.handlePressEnter(); + }} + /> + + { + this.onPasswordChanged(val); + }} + onEnterPressed={() => { + this.handlePressEnter(); + }} + /> + + { + this.onPasswordVerifyChanged(val); + }} + onEnterPressed={() => { + this.handlePressEnter(); + }} + /> +
+ ); + } + + private renderEnterSessionID(contentEditable: boolean) { + const enterSessionIDHere = window.i18n('enterSessionIDHere'); + + return ( +
{ + if (contentEditable) { + this.onSecondDeviceSessionIDChanged(e); + } + }} + /> + ); + } + + private onSecondDeviceSessionIDChanged(e: any) { + e.preventDefault(); + const hexEncodedPubKey = e.target.innerHTML; + this.setState({ + primaryDevicePubKey: hexEncodedPubKey, + }); + } + + private renderSignInButtons() { + const { signInMode } = this.state; + + const or = window.i18n('or'); + + if (signInMode === SignInMode.Default) { + return ( +
+ {this.renderRestoreUsingSeedButton( + SessionButtonType.BrandOutline, + SessionButtonColor.Green + )} +
{or}
+ {this.renderLinkDeviceToExistingAccountButton()} +
+ ); + } + + if (signInMode === SignInMode.LinkingDevice) { + return ( +
+ {this.renderContinueYourSessionButton()} +
{or}
+ {this.renderRestoreUsingSeedButton( + SessionButtonType.BrandOutline, + SessionButtonColor.Green + )} +
+ ); + } + + return ( +
+ {this.renderContinueYourSessionButton()} +
{or}
+ {this.renderLinkDeviceToExistingAccountButton()} +
+ ); + } + + private renderTermsConditionAgreement() { + // FIXME link to our Terms and Conditions and privacy statement + + return ( +
+ +
+ ); + } + + private handleContinueYourSessionClick() { + if (this.state.signInMode === SignInMode.UsingSeed) { + this.register('english').ignore(); + } else { + this.registerSecondaryDevice().ignore(); + } + } + + private renderContinueYourSessionButton() { + return ( + { + this.handleContinueYourSessionClick(); + }} + buttonType={SessionButtonType.Brand} + buttonColor={SessionButtonColor.Green} + text={window.i18n('continueYourSession')} + /> + ); + } + + private renderRestoreUsingSeedButton( + buttonType: SessionButtonType, + buttonColor: SessionButtonColor + ) { + return ( + { + this.cancelSecondaryDevice().ignore(); + this.setState({ + signInMode: SignInMode.UsingSeed, + primaryDevicePubKey: '', + mnemonicSeed: '', + displayName: '', + signUpMode: SignUpMode.Default, + }); + //FIXME ugly hack to empty the content editable div used on enter session ID + window.Session.emptyContentEditableDivs(); + }} + buttonType={buttonType} + buttonColor={buttonColor} + text={window.i18n('restoreUsingSeed')} + /> + ); + } + + private renderLinkDeviceToExistingAccountButton() { + return ( + { + this.setState({ + signInMode: SignInMode.LinkingDevice, + mnemonicSeed: '', + displayName: '', + signUpMode: SignUpMode.Default, + }); + }} + buttonType={SessionButtonType.BrandOutline} + buttonColor={SessionButtonColor.White} + text={window.i18n('linkDeviceToExistingAccount')} + /> + ); + } + + private handlePressEnter() { + const { signInMode, signUpMode } = this.state; + if (signUpMode === SignUpMode.EnterDetails) { + this.onCompleteSignUpClick(); + + return; + } + + if (signInMode === SignInMode.UsingSeed) { + this.handleContinueYourSessionClick(); + + return; + } + } + + private trim(value: string) { + return value ? value.trim() : value; + } + + private validatePassword() { + const input = this.trim(this.state.password); + const confirmationInput = this.trim(this.state.validatePassword); + + // If user hasn't set a value then skip + if (!input && !confirmationInput) { + return null; + } + + const error = window.passwordUtil.validatePassword(input, window.i18n); + if (error) { + return error; + } + + if (input !== confirmationInput) { + return "Password don't match"; + } + + return null; + } + + private onValidatePassword() { + const passwordValidation = this.validatePassword(); + if (passwordValidation) { + this.setState({ passwordErrorString: passwordValidation }); + } else { + // Show green box around inputs that match + const input = this.trim(this.state.password); + const confirmationInput = this.trim(this.state.validatePassword); + const passwordFieldsMatch = + input !== undefined && input === confirmationInput; + + this.setState({ + passwordErrorString: '', + passwordFieldsMatch, + }); + } + } + + private sanitiseNameInput(val: string) { + return val.trim().replace(window.displayNameRegex, ''); + } + + private async resetRegistration() { + await window.Signal.Data.removeAllIdentityKeys(); + await window.Signal.Data.removeAllPrivateConversations(); + window.Whisper.Registration.remove(); + // Do not remove all items since they are only set + // at startup. + window.textsecure.storage.remove('identityKey'); + window.textsecure.storage.remove('secondaryDeviceStatus'); + window.ConversationController.reset(); + await window.ConversationController.load(); + window.Whisper.RotateSignedPreKeyListener.stop(window.Whisper.events); + } + + private async register(language: string) { + const { password, mnemonicSeed, displayName } = this.state; + // Make sure the password is valid + if (this.validatePassword()) { + //this.showToast(window.i18n('invalidPassword')); + return; + } + if (!mnemonicSeed) { + return; + } + if (!displayName) { + return; + } + + // Ensure we clear the secondary device registration status + window.textsecure.storage.remove('secondaryDeviceStatus'); + + try { + await this.resetRegistration(); + + await window.setPassword(password); + await this.accountManager.registerSingleDevice( + mnemonicSeed, + language, + displayName + ); + trigger('openInbox'); + } catch (e) { + if (typeof e === 'string') { + //this.showToast(e); + } + //this.log(e); + } + } + + private async cancelSecondaryDevice() { + window.Whisper.events.off( + 'secondaryDeviceRegistration', + this.onSecondaryDeviceRegistered + ); + + await this.resetRegistration(); + } + + private async registerSecondaryDevice() { + // tslint:disable-next-line: no-backbone-get-set-outside-model + if (window.textsecure.storage.get('secondaryDeviceStatus') === 'ongoing') { + return; + } + await this.resetRegistration(); + window.textsecure.storage.put('secondaryDeviceStatus', 'ongoing'); + + const primaryPubKey = this.state.primaryDevicePubKey; + + // Ensure only one listener + window.Whisper.events.off( + 'secondaryDeviceRegistration', + this.onSecondaryDeviceRegistered + ); + window.Whisper.events.once( + 'secondaryDeviceRegistration', + this.onSecondaryDeviceRegistered + ); + + const onError = async (error: any) => { + window.console.error(error); + + await this.resetRegistration(); + }; + + const c = new window.Whisper.Conversation({ + id: primaryPubKey, + type: 'private', + }); + + const validationError = c.validateNumber(); + if (validationError) { + onError('Invalid public key').ignore(); + + return; + } + try { + const fakeMnemonic = this.state.mnemonicSeed; + + await this.accountManager.registerSingleDevice( + fakeMnemonic, + 'english', + null + ); + + await this.accountManager.requestPairing(primaryPubKey); + const pubkey = window.textsecure.storage.user.getNumber(); + const words = window.mnemonic.pubkey_to_secret_words(pubkey); + window.console.log('pubkey_to_secret_words'); + window.console.log(`Here is your secret:\n${words}`); + } catch (e) { + window.console.log(e); + //onError(e); + } + } + + private async onSecondaryDeviceRegistered() { + // Ensure the left menu is updated + trigger('userChanged', { isSecondaryDevice: true }); + // will re-run the background initialisation + trigger('registration_done'); + trigger('openInbox'); + } +} diff --git a/ts/components/session/SessionButton.tsx b/ts/components/session/SessionButton.tsx index 587a7d522..62b0aebd2 100644 --- a/ts/components/session/SessionButton.tsx +++ b/ts/components/session/SessionButton.tsx @@ -1,8 +1,6 @@ import React from 'react'; import classNames from 'classnames'; -//import { LocalizerType } from '../../types/Util'; - export enum SessionButtonType { Brand = 'brand', BrandOutline = 'brand-outline', @@ -23,7 +21,6 @@ export enum SessionButtonColor { } interface Props { - //i18n: LocalizerType; text: string; buttonType: SessionButtonType; buttonColor: SessionButtonColor; @@ -45,13 +42,18 @@ export class SessionButton extends React.PureComponent { public render() { const { buttonType, buttonColor, text } = this.props; + const buttonTypes = []; + + buttonTypes.push(buttonType); + if (buttonType.includes('-outline')) { + buttonTypes.push(buttonType.replace('-outline', '')); + } + return (
{ - this.clickHandler(e); - }} - className={classNames('session-button', buttonType, buttonColor)} + className={classNames('session-button', ...buttonTypes, buttonColor)} role="button" + onClick={this.clickHandler} > {text}
diff --git a/ts/components/session/SessionHTMLRenderer.tsx b/ts/components/session/SessionHTMLRenderer.tsx new file mode 100644 index 000000000..24e7c4bc8 --- /dev/null +++ b/ts/components/session/SessionHTMLRenderer.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import DOMPurify from 'dompurify'; + +interface ReceivedProps { + html: string; + tag?: string; + key?: any; +} + +type Props = ReceivedProps; + +export const SessionHtmlRenderer: React.SFC = ({ + tag = 'div', + key, + html, +}) => { + const clean = DOMPurify.sanitize(html, { + USE_PROFILES: { html: true }, + FORBID_ATTR: ['style', 'script'], + }); + + return React.createElement(tag, { + key, + dangerouslySetInnerHTML: { __html: clean }, + }); +}; diff --git a/ts/components/session/SessionInput.tsx b/ts/components/session/SessionInput.tsx new file mode 100644 index 000000000..b4789ae9a --- /dev/null +++ b/ts/components/session/SessionInput.tsx @@ -0,0 +1,103 @@ +import React from 'react'; + +import classNames from 'classnames'; +import { SessionIconButton, SessionIconSize, SessionIconType } from './icon'; + +interface Props { + label: string; + type: string; + value?: string; + placeholder: string; + enableShowHide?: boolean; + onValueChanged?: any; + onEnterPressed?: any; +} + +interface State { + inputValue: string; + forceShow: boolean; +} + +export class SessionInput extends React.PureComponent { + constructor(props: any) { + super(props); + + this.updateInputValue = this.updateInputValue.bind(this); + this.renderShowHideButton = this.renderShowHideButton.bind(this); + + this.state = { + inputValue: '', + forceShow: false, + }; + } + + public render() { + const { placeholder, type, label, value, enableShowHide } = this.props; + const { inputValue, forceShow } = this.state; + + const correctType = forceShow ? 'text' : type; + + return ( +
+ + { + this.updateInputValue(e); + }} + className={classNames( + enableShowHide ? 'session-input-floating-label-show-hide' : '' + )} + onKeyPress={event => { + event.persist(); + if (event.key === 'Enter' && this.props.onEnterPressed) { + this.props.onEnterPressed(); + } + }} + /> + + {enableShowHide && this.renderShowHideButton()} + +
+
+ ); + } + + private renderShowHideButton() { + return ( + { + this.setState({ + forceShow: !this.state.forceShow, + }); + }} + /> + ); + } + + private updateInputValue(e: any) { + e.preventDefault(); + this.setState({ + inputValue: e.target.value, + }); + + if (this.props.onValueChanged) { + this.props.onValueChanged(e.target.value); + } + } +} diff --git a/ts/components/session/SessionRegistrationView.tsx b/ts/components/session/SessionRegistrationView.tsx new file mode 100644 index 000000000..3da95f6b5 --- /dev/null +++ b/ts/components/session/SessionRegistrationView.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { AccentText } from './AccentText'; + +import { RegistrationTabs } from './RegistrationTabs'; +import { SessionIconButton, SessionIconSize, SessionIconType } from './icon'; + +export const SessionRegistrationView: React.FC = () => ( +
+
+
+ { + window.close(); + }} + /> +
+ +
+ +
+
+ +
+
+ brand +
+
+); diff --git a/ts/global.d.ts b/ts/global.d.ts new file mode 100644 index 000000000..5817c1f87 --- /dev/null +++ b/ts/global.d.ts @@ -0,0 +1,19 @@ +interface Window { + getAccountManager: any; + mnemonic: any; + passwordUtil: any; + dcodeIO: any; + libsignal: any; + displayNameRegex: any; + Signal: any; + Whisper: any; + ConversationController: any; + setPassword: any; + textsecure: any; + Session: any; + i18n: any; +} + +interface Promise { + ignore(): void; +} diff --git a/tslint.json b/tslint.json index b114819d2..84c0bb5c3 100644 --- a/tslint.json +++ b/tslint.json @@ -141,9 +141,9 @@ "react-no-dangerous-html": [ true, { - "file": "ts/components/conversation/Linkify.tsx", - "method": "render", - "comment": "Usage has been approved by Ryan Tharp on 2019-07-22" + "file": "ts/components/session/SessionHTMLRenderer.tsx", + "method": "", + "comment": "Usage has been approved by Maxim on 13 Dec 2019" } ], // Reasonable functions can exceed the default of 100 lines diff --git a/yarn.lock b/yarn.lock index 6aa154576..75bf08394 100644 --- a/yarn.lock +++ b/yarn.lock @@ -138,6 +138,13 @@ resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.5.tgz#b14efa8852b7768d898906613c23f688713e02cd" integrity sha512-Q1y515GcOdTHgagaVFhHnIFQ38ygs/kmxdNpvpou+raI9UO3YZcHDngBSYKQklcKlvA7iuQlmIKbzvmxcOE9CQ== +"@types/dompurify@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-2.0.0.tgz#9616caa5bf2569aea2e4889d4f929d968c081b40" + integrity sha512-g/ilp+Bo6Ljy60i5LnjkGw00X7EIoFjoPGlxqZhV8TJ9fWEzXheioU1O+U/UzCzUA7pUDy/JNMytTQDJctpUHg== + dependencies: + "@types/trusted-types" "*" + "@types/events@*": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7" @@ -324,6 +331,11 @@ resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-2.3.5.tgz#9da44ed75571999b65c37b60c9b2b88db54c585d" integrity sha512-SCcK7mvGi3+ZNz833RRjFIxrn4gI1PPR3NtuIS+6vMkvmsGjosqTJwRt5bAEFLRz+wtJMWv8+uOnZf2hi2QXTg== +"@types/trusted-types@*": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-1.0.4.tgz#922d092c84a776a59acb0bd6785fd82b59b9bad5" + integrity sha512-6jtHrHpmiXOXoJ31Cg9R+iEVwuEKPf0XHwFUI93eEPXx492/J2JHyafkleKE2EYzZprayk9FSjTyK1GDqcwDng== + "@types/uuid@3.4.4": version "3.4.4" resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-3.4.4.tgz#7af69360fa65ef0decb41fd150bf4ca5c0cefdf5" @@ -2581,6 +2593,11 @@ domain-browser@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda" +dompurify@^2.0.7: + version "2.0.7" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.0.7.tgz#f8266ad38fe1602fb5b3222f31eedbf5c16c4fd5" + integrity sha512-S3O0lk6rFJtO01ZTzMollCOGg+WAtCwS3U5E2WSDY/x/sy7q70RjEC4Dmrih5/UqzLLB9XoKJ8KqwBxaNvBu4A== + dot-prop@^4.1.0: version "4.1.1" resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.1.1.tgz#a8493f0b7b5eeec82525b5c7587fa7de7ca859c1"