Move StandaloneRegistration to React

This commit is contained in:
Fedor Indutny 2021-11-30 18:51:53 +01:00 committed by GitHub
parent 67b17ec317
commit 7c1ce3366d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 452 additions and 1358 deletions

View file

@ -1759,6 +1759,10 @@ app.on('will-finish-launching', () => {
if (isCaptchaHref(incomingHref, getLogger())) {
const { captcha } = parseCaptchaHref(incomingHref, getLogger());
challengeHandler.handleCaptcha(captcha);
// Show window after handling captcha
showWindow();
return;
}

View file

@ -85,14 +85,6 @@
<button class='finish' tabIndex='1'><span class='icon'></span></button>
</script>
<script type="text/x-tmpl-mustache" id="phone-number">
<div class='phone-input-form'>
<div class='number-container'>
<input type='tel' class='number' placeholder="Phone Number" />
</div>
</div>
</script>
<script type="text/x-tmpl-mustache" id="group-member-list">
<div class='container' tabindex='0'>
{{ #summary }} <div class='summary'>{{ summary }}</div>{{ /summary }}
@ -198,36 +190,6 @@
{{/isError}}
</script>
<script type="text/x-tmpl-mustache" id="standalone">
<div class='module-title-bar-drag-area'></div>
<div class='step'>
<div class='inner'>
<div class='step-body'>
<div class="banner-image module-splash-screen__logo module-img--128"></div>
<div class='header'>Create your Signal Account</div>
<div id='phone-number-input'>
<div class='phone-input-form'>
<div id='number-container' class='number-container'>
<input type='tel' class='number' placeholder='Phone Number' />
</div>
</div>
</div>
<div class='clearfix'>
<a class='button' id='request-sms'>Send SMS</a>
<a class='link' id='request-voice' tabindex='-1'>Call</a>
</div>
<input class='form-control' type='text' pattern='\s*[0-9]{3}-?[0-9]{3}\s*' title='Enter your 6-digit verification code. If you did not receive a code, click Call or Send SMS to request a new one' id='code' placeholder='Verification Code' autocomplete='off'>
<div id='error' class='collapse'></div>
<div id='status'></div>
</div>
<div class='nav'>
<a class='button' id='verifyCode' data-loading-text='Please wait...'>Register</a>
</div>
</div>
</div>
</script>
<script type="text/javascript" src="js/components.js"></script>
<script type="text/javascript" src="ts/set_os_class.js"></script>
<script

View file

@ -36,7 +36,6 @@
"node_modules/mustache/mustache.js",
"node_modules/underscore/underscore.js",
"components/qrcode/**/*.js",
"node_modules/intl-tel-input/build/js/intlTelInput.js",
"components/autosize/**/*.js",
"components/webaudiorecorder/lib/WebAudioRecorder.js"
]

View file

@ -107,7 +107,7 @@
"heic-convert": "^1.2.4",
"history": "4.9.0",
"humanize-duration": "3.26.0",
"intl-tel-input": "12.1.15",
"intl-tel-input": "17.0.13",
"jquery": "3.5.0",
"js-yaml": "3.13.1",
"linkify-it": "2.2.0",
@ -195,6 +195,7 @@
"@types/google-libphonenumber": "7.4.14",
"@types/history": "4.7.2",
"@types/humanize-duration": "^3.18.1",
"@types/intl-tel-input": "17.0.4",
"@types/jquery": "3.5.6",
"@types/js-yaml": "3.12.0",
"@types/linkify-it": "2.1.0",

View file

@ -457,7 +457,6 @@ try {
require('./ts/views/conversation_view');
require('./ts/views/inbox_view');
require('./ts/views/install_view');
require('./ts/views/standalone_registration_view');
require('./ts/SignalProtocolStore');
require('./ts/background');

View file

@ -570,7 +570,28 @@ $loading-height: 16px;
@media (min-height: 750px) and (min-width: 700px) {
font-size: 20pt;
}
&:disabled {
background-color: $color-gray-20;
cursor: auto;
}
}
button.link {
@include button-reset;
display: block;
margin: 0.5em auto;
text-align: center;
text-decoration: underline;
color: $color-ultramarine;
&:disabled {
color: $color-gray-20;
cursor: auto;
}
}
a.link {
display: block;
cursor: pointer;

View file

@ -5,9 +5,16 @@
@import 'variables';
@import '../node_modules/intl-tel-input/build/css/intlTelInput.css';
@import 'progress';
.iti-flag {
.iti__flag {
// override intlTelInput's flags image location
background: url('../node_modules/intl-tel-input/build/img/flags.png');
background-image: url('../node_modules/intl-tel-input/build/img/flags.png');
}
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
.iti__flag {
background-image: url('../node_modules/intl-tel-input/build/img/flags@2x.png');
}
}
.intl-tel-input .country-list {

View file

@ -54,14 +54,6 @@
<button class='close'><span class='icon'></span></button>
</script>
<script type="text/x-tmpl-mustache" id="phone-number">
<div class='phone-input-form'>
<div class='number-container'>
<input type='tel' class='number' placeholder="Phone Number" />
</div>
</div>
</script>
<script type="text/x-tmpl-mustache" id="file-size-modal">
{{ file-size-warning }}
({{ limit }}{{ units }})
@ -169,36 +161,6 @@
{{/isError}}
</script>
<script type="text/x-tmpl-mustache" id="standalone">
<div class='module-title-bar-drag-area'></div>
<div class='step'>
<div class='inner'>
<div class='step-body'>
<div class="banner-image module-splash-screen__logo module-img--128"></div>
<div class='header'>Create your Signal Account</div>
<div id='phone-number-input'>
<div class='phone-input-form'>
<div id='number-container' class='number-container'>
<input type='tel' class='number' placeholder='Phone Number' />
</div>
</div>
</div>
<div class='clearfix'>
<a class='button' id='request-sms'>Send SMS</a>
<a class='link' id='request-voice' tabindex='-1'>Call</a>
</div>
<input class='form-control' type='text' pattern='\s*[0-9]{3}-?[0-9]{3}\s*' title='Enter your 6-digit verification code. If you did not receive a code, click Call or Send SMS to request a new one' id='code' placeholder='Verification Code' autocomplete='off'>
<div id='error' class='collapse'></div>
<div id='status'></div>
</div>
<div class='nav'>
<a class='button' id='verifyCode' data-loading-text='Please wait...'>Register</a>
</div>
</div>
</div>
</script>
<script
type="text/javascript"
src="../libtextsecure/test/fake_web_api.js"

View file

@ -2009,6 +2009,14 @@ export async function startApp(): Promise<void> {
window.textsecure.messaging = new window.textsecure.MessageSender(server);
// Update our profile key in the conversation if we just got linked.
const profileKey = await ourProfileKeyService.get();
if (firstRun && profileKey) {
const me = window.ConversationController.getOurConversation();
strictAssert(me !== undefined, "Didn't find newly created ourselves");
await me.setProfileKey(Bytes.toBase64(profileKey));
}
if (connectCount === 0) {
try {
// Force a re-fetch before we process our queue. We may want to turn on

View file

@ -312,6 +312,19 @@ export class ChallengeHandler {
await this.persist();
}
public async requestCaptcha(token = ''): Promise<string> {
const request: IPCRequest = { seq: this.seq };
this.seq += 1;
this.options.requestChallenge(request);
const response = await new Promise<ChallengeResponse>((resolve, reject) => {
this.responseHandlers.set(request.seq, { token, resolve, reject });
});
return response.captcha;
}
private async persist(): Promise<void> {
assert(
this.isLoaded,
@ -407,16 +420,10 @@ export class ChallengeHandler {
}
private async solve(token: string): Promise<void> {
const request: IPCRequest = { seq: this.seq };
this.seq += 1;
this.options.setChallengeStatus('required');
this.options.requestChallenge(request);
this.challengeToken = token;
this.challengeToken = token || '';
const response = await new Promise<ChallengeResponse>((resolve, reject) => {
this.responseHandlers.set(request.seq, { token, resolve, reject });
});
const captcha = await this.requestCaptcha(token);
// Another `.solve()` has completed earlier than us
if (this.challengeToken === undefined) {
@ -434,7 +441,7 @@ export class ChallengeHandler {
await this.sendChallengeResponse({
type: 'recaptcha',
token: lastToken,
captcha: response.captcha,
captcha,
});
} catch (error) {
log.error(`challenge: challenge failure, error: ${error && error.stack}`);

View file

@ -18,6 +18,13 @@ type PropsType = {
appView: AppViewType;
renderCallManager: () => JSX.Element;
renderGlobalModalContainer: () => JSX.Element;
openInbox: () => void;
requestVerification: (
type: 'sms' | 'voice',
number: string,
token: string
) => Promise<void>;
registerSingleDevice: (number: string, code: string) => Promise<void>;
theme: ThemeType;
} & ComponentProps<typeof Inbox>;
@ -34,6 +41,9 @@ export const App = ({
renderCustomizingPreferredReactionsModal,
renderGlobalModalContainer,
renderSafetyNumber,
openInbox,
requestVerification,
registerSingleDevice,
theme,
verifyConversationsStoppingMessageSend,
}: PropsType): JSX.Element => {
@ -42,7 +52,17 @@ export const App = ({
if (appView === AppViewType.Installer) {
contents = <Install />;
} else if (appView === AppViewType.Standalone) {
contents = <StandaloneRegistration />;
const onComplete = () => {
window.removeSetupMenuItems();
openInbox();
};
contents = (
<StandaloneRegistration
onComplete={onComplete}
requestVerification={requestVerification}
registerSingleDevice={registerSingleDevice}
/>
);
} else if (appView === AppViewType.Inbox) {
contents = (
<Inbox

View file

@ -1,14 +1,268 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { BackboneHost } from './BackboneHost';
import type { ChangeEvent } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import type { Plugin } from 'intl-tel-input';
import intlTelInput from 'intl-tel-input';
import { strictAssert } from '../util/assert';
import { getChallengeURL } from '../challenge';
const PhoneInput = ({
onValidation,
onNumberChange,
}: {
onValidation: (isValid: boolean) => void;
onNumberChange: (number?: string) => void;
}): JSX.Element => {
const [isValid, setIsValid] = useState(false);
const pluginRef = useRef<Plugin | undefined>();
const elemRef = useRef<HTMLInputElement | null>(null);
const onRef = useCallback((elem: HTMLInputElement | null) => {
elemRef.current = elem;
if (!elem) {
return;
}
pluginRef.current?.destroy();
const plugin = intlTelInput(elem);
pluginRef.current = plugin;
}, []);
const validateNumber = useCallback(
(number: string) => {
const { current: plugin } = pluginRef;
if (!plugin) {
return;
}
const regionCode = plugin.getSelectedCountryData().iso2;
const parsedNumber = window.libphonenumber.util.parseNumber(
number,
regionCode
);
setIsValid(parsedNumber.isValidNumber);
onValidation(parsedNumber.isValidNumber);
onNumberChange(
parsedNumber.isValidNumber ? parsedNumber.e164 : undefined
);
},
[setIsValid, onNumberChange, onValidation]
);
const onChange = useCallback(
(_: ChangeEvent<HTMLInputElement>) => {
if (elemRef.current) {
validateNumber(elemRef.current.value);
}
},
[validateNumber]
);
const onKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
// Pacify TypeScript and handle events bubbling up
if (event.target instanceof HTMLInputElement) {
validateNumber(event.target.value);
}
},
[validateNumber]
);
export const StandaloneRegistration = (): JSX.Element => {
return (
<BackboneHost
className="full-screen-flow"
View={window.Whisper.StandaloneRegistrationView}
/>
<div className="phone-input">
<div className="phone-input-form">
<div className={`number-container ${isValid ? 'valid' : 'invalid'}`}>
<input
className="number"
type="tel"
ref={onRef}
onChange={onChange}
onKeyDown={onKeyDown}
placeholder="Phone Number"
/>
</div>
</div>
</div>
);
};
export const StandaloneRegistration = ({
onComplete,
requestVerification,
registerSingleDevice,
}: {
onComplete: () => void;
requestVerification: (
type: 'sms' | 'voice',
number: string,
token: string
) => Promise<void>;
registerSingleDevice: (number: string, code: string) => Promise<void>;
}): JSX.Element => {
useEffect(() => {
window.readyForUpdates();
}, []);
const [isValidNumber, setIsValidNumber] = useState(false);
const [isValidCode, setIsValidCode] = useState(false);
const [number, setNumber] = useState<string | undefined>(undefined);
const [code, setCode] = useState('');
const [error, setError] = useState<string | undefined>(undefined);
const [status, setStatus] = useState<string | undefined>(undefined);
const onRequestCode = useCallback(
async (type: 'sms' | 'voice') => {
if (!isValidNumber) {
return;
}
if (!number) {
setIsValidNumber(false);
setError(undefined);
return;
}
document.location.href = getChallengeURL();
const token = await window.Signal.challengeHandler.requestCaptcha();
try {
requestVerification(type, number, token);
setError(undefined);
} catch (err) {
setError(err.message);
}
},
[isValidNumber, setIsValidNumber, setError, requestVerification, number]
);
const onSMSClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
onRequestCode('sms');
},
[onRequestCode]
);
const onVoiceClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
onRequestCode('voice');
},
[onRequestCode]
);
const onChangeCode = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
setIsValidCode(value.length === 6);
setCode(value);
},
[setIsValidCode, setCode]
);
const onVerifyCode = useCallback(
async (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (!isValidNumber || !isValidCode) {
return;
}
strictAssert(number && code, 'Missing number or code');
try {
await registerSingleDevice(number, code);
onComplete();
} catch (err) {
setStatus(err.message);
}
},
[
registerSingleDevice,
onComplete,
number,
code,
setStatus,
isValidNumber,
isValidCode,
]
);
return (
<div className="full-screen-flow">
<div className="module-title-bar-drag-area" />
<div className="step">
<div className="inner">
<div className="step-body">
<div className="banner-image module-splash-screen__logo module-img--128" />
<div className="header">Create your Signal Account</div>
<div>
<div className="phone-input-form">
<PhoneInput
onValidation={setIsValidNumber}
onNumberChange={setNumber}
/>
</div>
</div>
<div className="clearfix">
<button
type="button"
className="button"
disabled={!isValidNumber}
onClick={onSMSClick}
>
Send SMS
</button>
<button
type="button"
className="link"
tabIndex={-1}
disabled={!isValidNumber}
onClick={onVoiceClick}
>
Call
</button>
</div>
<input
className={`form-control ${isValidCode ? 'valid' : 'invalid'}`}
type="text"
pattern="\s*[0-9]{3}-?[0-9]{3}\s*"
title="Enter your 6-digit verification code. If you did not receive a code, click Call or Send SMS to request a new one"
placeholder="Verification Code"
autoComplete="off"
value={code}
onChange={onChangeCode}
/>
<div>{error}</div>
<div>{status}</div>
</div>
<div className="nav">
<button
type="button"
className="button"
disabled={!isValidNumber || !isValidCode}
onClick={onVerifyCode}
>
Register
</button>
</div>
</div>
</div>
</div>
);
};

View file

@ -38,6 +38,22 @@ const mapStateToProps = (state: StateType) => {
renderSafetyNumber: (props: SafetyNumberProps) => (
<SmartSafetyNumberViewer {...props} />
),
requestVerification: (
type: 'sms' | 'voice',
number: string,
token: string
): Promise<void> => {
const accountManager = window.getAccountManager();
if (type === 'sms') {
return accountManager.requestSMSVerification(number, token);
}
return accountManager.requestVoiceVerification(number, token);
},
registerSingleDevice: (number: string, code: string): Promise<void> => {
return window.getAccountManager().registerSingleDevice(number, code);
},
theme: getTheme(state),
};
};

View file

@ -83,12 +83,12 @@ export default class AccountManager extends EventTarget {
this.pending = Promise.resolve();
}
async requestVoiceVerification(number: string) {
return this.server.requestVerificationVoice(number);
async requestVoiceVerification(number: string, token: string) {
return this.server.requestVerificationVoice(number, token);
}
async requestSMSVerification(number: string) {
return this.server.requestVerificationSMS(number);
async requestSMSVerification(number: string, token: string) {
return this.server.requestVerificationSMS(number, token);
}
encryptDeviceName(name: string, identityKey: KeyPairType) {

View file

@ -869,8 +869,8 @@ export type WebAPIType = {
registerKeys: (genKeys: KeysType) => Promise<void>;
registerSupportForUnauthenticatedDelivery: () => Promise<void>;
reportMessage: (senderE164: string, serverGuid: string) => Promise<void>;
requestVerificationSMS: (number: string) => Promise<void>;
requestVerificationVoice: (number: string) => Promise<void>;
requestVerificationSMS: (number: string, token: string) => Promise<void>;
requestVerificationVoice: (number: string, token: string) => Promise<void>;
sendMessages: (
destination: string,
messageArray: ReadonlyArray<MessageType>,
@ -1557,19 +1557,19 @@ export function initialize({
});
}
async function requestVerificationSMS(number: string) {
async function requestVerificationSMS(number: string, token: string) {
await _ajax({
call: 'accounts',
httpType: 'GET',
urlParameters: `/sms/code/${number}`,
urlParameters: `/sms/code/${number}?captcha=${token}`,
});
}
async function requestVerificationVoice(number: string) {
async function requestVerificationVoice(number: string, token: string) {
await _ajax({
call: 'accounts',
httpType: 'GET',
urlParameters: `/voice/code/${number}`,
urlParameters: `/voice/code/${number}?captcha=${token}`,
});
}

File diff suppressed because it is too large Load diff

View file

@ -1,38 +0,0 @@
// Copyright 2015-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
window.Whisper = window.Whisper || {};
export const PhoneInputView = window.Whisper.View.extend({
tagName: 'div',
className: 'phone-input',
template: () => $('#phone-number').html(),
initialize() {
this.$('input.number').intlTelInput();
},
events: {
change: 'validateNumber',
keydown: 'validateNumber',
},
validateNumber() {
const input = this.$('input.number');
const regionCode = this.$('li.active')
.attr('data-country-code')
.toUpperCase();
const number = input.val();
const parsedNumber = window.libphonenumber.util.parseNumber(
number,
regionCode
);
if (parsedNumber.isValidNumber) {
this.$('.number-container').removeClass('invalid');
this.$('.number-container').addClass('valid');
} else {
this.$('.number-container').removeClass('valid');
}
input.trigger('validation');
return parsedNumber.isValidNumber ? parsedNumber.e164 : undefined;
},
});

View file

@ -1,117 +0,0 @@
// Copyright 2017-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as log from '../logging/log';
import { PhoneInputView } from './phone_input_view';
window.Whisper = window.Whisper || {};
const { Whisper } = window;
export const StandaloneRegistrationView = Whisper.View.extend({
template: () => $('#standalone').html(),
className: 'full-screen-flow',
initialize() {
window.readyForUpdates();
this.accountManager = window.getAccountManager();
this.render();
const number = window.textsecure.storage.user.getNumber();
if (number) {
this.$('input.number').val(number);
}
this.phoneView = new PhoneInputView({
el: this.$('#phone-number-input'),
});
this.$('#error').hide();
},
events: {
'validation input.number': 'onValidation',
'click #request-voice': 'requestVoice',
'click #request-sms': 'requestSMSVerification',
'change #code': 'onChangeCode',
'click #verifyCode': 'verifyCode',
},
getVerificationCode() {
const codeHTML = $('#code').val();
if (!codeHTML) {
return;
}
return String(codeHTML).replace(/\D+/g, '');
},
async verifyCode() {
const number = this.phoneView.validateNumber();
const verificationCode = this.getVerificationCode();
try {
await this.accountManager.registerSingleDevice(number, verificationCode);
this.$el.trigger('openInbox');
} catch (err) {
this.log(err);
}
},
log(s: Error) {
log.info(s);
this.$('#status').text(s);
},
validateCode() {
const verificationCode = this.getVerificationCode();
if (verificationCode.length === 6) {
return verificationCode;
}
return null;
},
displayError(error: Error) {
this.$('#error').hide().text(error).addClass('in').fadeIn();
},
onValidation() {
if (this.$('#number-container').hasClass('valid')) {
this.$('#request-sms, #request-voice').removeAttr('disabled');
} else {
this.$('#request-sms, #request-voice').prop('disabled', 'disabled');
}
},
onChangeCode() {
if (!this.validateCode()) {
this.$('#code').addClass('invalid');
} else {
this.$('#code').removeClass('invalid');
}
},
async requestVoice() {
window.removeSetupMenuItems();
this.$('#error').hide();
const number = this.phoneView.validateNumber();
if (number) {
this.$('#step2').addClass('in').fadeIn();
try {
await this.accountManager.requestVoiceVerification(number);
} catch (err) {
this.displayError(err);
}
} else {
this.$('#number-container').addClass('invalid');
}
},
async requestSMSVerification() {
window.removeSetupMenuItems();
$('#error').hide();
const number = this.phoneView.validateNumber();
if (number) {
this.$('#step2').addClass('in').fadeIn();
try {
await this.accountManager.requestSMSVerification(number);
} catch (err) {
this.displayError(err);
}
} else {
this.$('#number-container').addClass('invalid');
}
},
});
Whisper.StandaloneRegistrationView = StandaloneRegistrationView;

1
ts/window.d.ts vendored
View file

@ -591,6 +591,5 @@ export type WhisperType = {
KeyVerificationPanelView: typeof AnyViewClass;
ReactWrapperView: typeof BasicReactWrapperViewClass;
SafetyNumberChangeDialogView: typeof AnyViewClass;
StandaloneRegistrationView: typeof AnyViewClass;
View: typeof AnyViewClass;
};

View file

@ -2567,6 +2567,13 @@
resolved "https://registry.yarnpkg.com/@types/humanize-duration/-/humanize-duration-3.18.1.tgz#10090d596053703e7de0ac43a37b96cd9fc78309"
integrity sha512-MUgbY3CF7hg/a/jogixmAufLjJBQT7WEf8Q+kYJkOc47ytngg1IuZobCngdTjAgY83JWEogippge5O5fplaQlw==
"@types/intl-tel-input@17.0.4":
version "17.0.4"
resolved "https://registry.yarnpkg.com/@types/intl-tel-input/-/intl-tel-input-17.0.4.tgz#a7be083bd1989830e7e02f6d552a40428fd5ed7c"
integrity sha512-AIX0Azxs7fAkeI2wjAsMNE4Lfoa+/NqazWDPM4k4Vm8lhcId0BNMjzC/BACEGE0nAScc71+pznlKnTxBfW5HHA==
dependencies:
"@types/jquery" "*"
"@types/jquery@*":
version "3.5.0"
resolved "https://registry.yarnpkg.com/@types/jquery/-/jquery-3.5.0.tgz#ccb7dfd317d02d4227dd3803c75297d0c10dad68"
@ -10064,10 +10071,10 @@ interpret@~1.1.0:
resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.1.0.tgz#7ed1b1410c6a0e0f78cf95d3b8440c63f78b8614"
integrity sha1-ftGxQQxqDg94z5XTuEQMY/eLhhQ=
intl-tel-input@12.1.15:
version "12.1.15"
resolved "https://registry.yarnpkg.com/intl-tel-input/-/intl-tel-input-12.1.15.tgz#7393e6b77572731bbc65ca4585782e8ba3d74de4"
integrity sha512-9TN9x6aGdO1eL6iGFpobuLU4UymZqjSnS9UnsOSi//LU3A8nkLOcokSYBYjak18Uu8OM59HsGYDd1jKmwRsskw==
intl-tel-input@17.0.13:
version "17.0.13"
resolved "https://registry.yarnpkg.com/intl-tel-input/-/intl-tel-input-17.0.13.tgz#74a51db3b44f47ae8264df7d101a0810e53c77a6"
integrity sha512-+jPgPKUcgbhSSRN0BZLJOdntTaMv8tqowLrVQ3CGCNGpMKb4B9PRyzOZQpwo1FeA+LTOzvErOlBhbCuQog7R3g==
invariant@2.2.4, invariant@^2.2.3, invariant@^2.2.4:
version "2.2.4"