summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2020-08-26 14:30:45 +0200
committerOskar Nyberg <oskar@mullvad.net>2020-08-26 14:30:45 +0200
commit0e66e7084d0e070a6ff179bd971a84e37483bdfd (patch)
tree81b4109aea1c96374192bdba0877d9b684d8ee5b
parenta32d940445906730ed167ba188421c00ee83d93b (diff)
parent496c1598db5659aba6a429f17644e8b27d1fde58 (diff)
downloadmullvadvpn-0e66e7084d0e070a6ff179bd971a84e37483bdfd.tar.xz
mullvadvpn-0e66e7084d0e070a6ff179bd971a84e37483bdfd.zip
Merge branch 'convert-login-from-reactxp' into master
-rw-r--r--gui/src/renderer/components/Login.tsx334
-rw-r--r--gui/src/renderer/components/LoginStyles.tsx216
2 files changed, 221 insertions, 329 deletions
diff --git a/gui/src/renderer/components/Login.tsx b/gui/src/renderer/components/Login.tsx
index ee5f7e40ac..cf2a52dc58 100644
--- a/gui/src/renderer/components/Login.tsx
+++ b/gui/src/renderer/components/Login.tsx
@@ -1,5 +1,4 @@
-import * as React from 'react';
-import { Animated, Component, Styles, Text, TextInput, Types, UserInterface, View } from 'reactxp';
+import React, { useCallback } from 'react';
import { colors } from '../../config.json';
import consumePromise from '../../shared/promise';
import { messages } from '../../shared/gettext';
@@ -9,11 +8,22 @@ import * as AppButton from './AppButton';
import { Brand, HeaderBarSettingsButton } from './HeaderBar';
import ImageView from './ImageView';
import { Container, Header, Layout } from './Layout';
-import styles, {
- AccountDropdownItemButton,
- AccountDropdownItemButtonLabel,
- AccountDropdownRemoveIcon,
- InputSubmitIcon,
+import {
+ StyledAccountDropdownItemButton,
+ StyledAccountDropdownItemButtonLabel,
+ StyledAccountDropdownRemoveIcon,
+ StyledAccountInputBackdrop,
+ StyledAccountInputGroup,
+ StyledDropdownSpacer,
+ StyledFooter,
+ StyledInput,
+ StyledInputButton,
+ StyledInputSubmitIcon,
+ StyledLoginFooterPrompt,
+ StyledLoginForm,
+ StyledStatusIcon,
+ StyledSubtitle,
+ StyledTitle,
} from './LoginStyles';
import { AccountToken } from '../../shared/daemon-rpc-types';
@@ -37,47 +47,20 @@ interface IState {
const MIN_ACCOUNT_TOKEN_LENGTH = 10;
-export default class Login extends Component<IProps, IState> {
+export default class Login extends React.Component<IProps, IState> {
public state: IState = {
isActive: true,
};
- private accountInput = React.createRef<TextInput>();
+ private accountInput = React.createRef<HTMLInputElement>();
private shouldResetLoginError = false;
- private showsFooter = true;
- private footerAnimatedValue = Animated.createValue(0);
- private footerAnimation?: Types.Animated.CompositeAnimation;
- private footerAnimationStyle: Types.AnimatedViewStyleRuleSet;
- private footerRef = React.createRef<Animated.View>();
-
- private isLoginButtonActive = false;
- private loginButtonAnimatedValue = Animated.createValue(0);
- private loginButtonAnimation?: Types.Animated.CompositeAnimation;
- private loginButtonAnimationStyle: Types.AnimatedViewStyleRuleSet;
-
constructor(props: IProps) {
super(props);
if (props.loginState.type === 'failed') {
this.shouldResetLoginError = true;
}
-
- this.footerAnimationStyle = Styles.createAnimatedViewStyle({
- transform: [{ translateY: this.footerAnimatedValue }],
- });
-
- this.loginButtonAnimationStyle = Styles.createAnimatedViewStyle({
- backgroundColor: Animated.interpolate(
- this.loginButtonAnimatedValue,
- [0.0, 1.0],
- [colors.white, colors.green],
- ),
- });
- }
-
- public componentDidMount() {
- consumePromise(this.setFooterVisibility(this.shouldShowFooter()));
}
public componentDidUpdate(prevProps: IProps, _prevState: IState) {
@@ -89,17 +72,13 @@ export default class Login extends Component<IProps, IState> {
this.shouldResetLoginError = true;
// focus on login field when failed to log in
- const accountInput = this.accountInput.current;
- if (accountInput) {
- accountInput.focus();
- }
+ this.accountInput.current?.focus();
}
-
- this.setLoginButtonActive(this.shouldActivateLoginButton());
- consumePromise(this.setFooterVisibility(this.shouldShowFooter()));
}
public render() {
+ const showFooter = this.shouldShowFooter();
+
return (
<Layout>
<Header>
@@ -107,18 +86,14 @@ export default class Login extends Component<IProps, IState> {
<HeaderBarSettingsButton />
</Header>
<Container>
- <View style={styles.login_form}>
+ <StyledLoginForm>
{this.getStatusIcon()}
- <Text style={styles.title}>{this.formTitle()}</Text>
+ <StyledTitle>{this.formTitle()}</StyledTitle>
{this.createLoginForm()}
- </View>
+ </StyledLoginForm>
- <Animated.View
- ref={this.footerRef}
- style={[styles.login_footer, this.footerAnimationStyle]}>
- {this.createFooter()}
- </Animated.View>
+ <StyledFooter show={showFooter}>{this.createFooter()}</StyledFooter>
</Container>
</Layout>
);
@@ -128,14 +103,9 @@ export default class Login extends Component<IProps, IState> {
this.setState({ isActive: true });
};
- private onBlur = (e: Types.SyntheticEvent) => {
- // TOOD: relatedTarget is not exposed by ReactXP and may not work on non-web platforms.
- // Find a workaround.
- // @ts-ignore
- const relatedTarget = e.relatedTarget;
-
+ private onBlur = (e: React.FocusEvent<HTMLInputElement>) => {
// restore focus if click happened within dropdown
- if (relatedTarget) {
+ if (e.relatedTarget) {
if (this.accountInput.current) {
this.accountInput.current.focus();
}
@@ -145,73 +115,29 @@ export default class Login extends Component<IProps, IState> {
this.setState({ isActive: false });
};
- private setLoginButtonActive(isActive: boolean) {
- if (this.isLoginButtonActive === isActive) {
- return;
- }
-
- const animation = Animated.timing(this.loginButtonAnimatedValue, {
- toValue: isActive ? 1 : 0,
- easing: Animated.Easing.Linear(),
- duration: 250,
- });
-
- const oldAnimation = this.loginButtonAnimation;
- if (oldAnimation) {
- oldAnimation.stop();
- }
-
- animation.start();
-
- this.loginButtonAnimation = animation;
- this.isLoginButtonActive = isActive;
- }
-
- private async setFooterVisibility(show: boolean) {
- if (this.showsFooter === show || !this.footerRef.current) {
- return;
- }
-
- this.showsFooter = show;
-
- const layout = await UserInterface.measureLayoutRelativeToWindow(this.footerRef.current);
- const value = show ? 0 : layout.height;
-
- const animation = Animated.timing(this.footerAnimatedValue, {
- toValue: value,
- easing: Animated.Easing.InOut(),
- duration: 250,
- });
-
- const oldAnimation = this.footerAnimation;
- if (oldAnimation) {
- oldAnimation.stop();
- }
-
- animation.start();
-
- this.footerAnimation = animation;
- }
-
private onSubmit = () => {
- const accountToken = this.props.accountToken;
- if (accountToken && accountToken.length >= MIN_ACCOUNT_TOKEN_LENGTH) {
- this.props.login(accountToken);
+ if (this.accountTokenValid()) {
+ this.props.login(this.props.accountToken!);
}
};
- private onInputChange = (value: string) => {
+ private onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
// reset error when user types in the new account number
if (this.shouldResetLoginError) {
this.shouldResetLoginError = false;
this.props.resetLoginError();
}
- const accountToken = value.replace(/[^0-9]/g, '');
-
+ const accountToken = event.target.value.replace(/[^0-9]/g, '');
this.props.updateAccountToken(accountToken);
};
+ private onKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
+ if (event.key === 'Enter') {
+ this.onSubmit();
+ }
+ };
+
private formTitle() {
switch (this.props.loginState.type) {
case 'logging in':
@@ -253,9 +179,9 @@ export default class Login extends Component<IProps, IState> {
private getStatusIcon() {
const statusIconPath = this.getStatusIconPath();
return (
- <View style={styles.status_icon}>
+ <StyledStatusIcon>
{statusIconPath ? <ImageView source={statusIconPath} height={48} width={48} /> : null}
- </View>
+ </StyledStatusIcon>
);
}
@@ -272,48 +198,13 @@ export default class Login extends Component<IProps, IState> {
}
}
- private accountInputGroupStyles(): Types.ViewStyleRuleSet[] {
- const classes = [styles.account_input_group];
- if (this.state.isActive) {
- classes.push(styles.account_input_group__active);
- }
-
- if (!this.allowInteraction()) {
- classes.push(styles.account_input_group__inactive);
- } else if (
- this.props.loginState.type === 'failed' &&
- this.props.loginState.method === 'existing_account'
- ) {
- classes.push(styles.account_input_group__error);
- }
-
- return classes;
- }
-
- private accountInputButtonStyles() {
- const classes: Array<
- Types.StyleRuleSet<Types.AnimatedViewStyle> | Types.StyleRuleSet<Types.ViewStyle>
- > = [styles.input_button];
-
- if (!this.allowInteraction()) {
- classes.push(styles.input_button__invisible);
- }
-
- classes.push(this.loginButtonAnimationStyle);
-
- return classes;
- }
-
private allowInteraction() {
return this.props.loginState.type !== 'logging in' && this.props.loginState.type !== 'ok';
}
- private shouldActivateLoginButton(): boolean {
+ private accountTokenValid(): boolean {
const { accountToken } = this.props;
- if (accountToken && accountToken.length >= MIN_ACCOUNT_TOKEN_LENGTH) {
- return true;
- }
- return false;
+ return accountToken !== undefined && accountToken.length >= MIN_ACCOUNT_TOKEN_LENGTH;
}
private shouldShowAccountHistory() {
@@ -347,37 +238,42 @@ export default class Login extends Component<IProps, IState> {
}
private createLoginForm() {
+ const allowInteraction = this.allowInteraction();
+ const hasError =
+ this.props.loginState.type === 'failed' &&
+ this.props.loginState.method === 'existing_account';
+
return (
- <View>
- <Text style={styles.subtitle}>{this.formSubtitle()}</Text>
- <View style={this.accountInputGroupStyles()}>
- <View style={styles.account_input_backdrop}>
- <TextInput
- style={styles.account_input_textfield}
+ <>
+ <StyledSubtitle>{this.formSubtitle()}</StyledSubtitle>
+ <StyledAccountInputGroup
+ active={allowInteraction && this.state.isActive}
+ editable={allowInteraction}
+ error={hasError}>
+ <StyledAccountInputBackdrop>
+ <StyledInput
placeholder="0000 0000 0000 0000"
- placeholderTextColor={colors.blue40}
value={this.props.accountToken || ''}
- autoCorrect={false}
- editable={this.allowInteraction()}
+ disabled={!this.allowInteraction()}
onFocus={this.onFocus}
onBlur={this.onBlur}
- onChangeText={this.onInputChange}
- onSubmitEditing={this.onSubmit}
- returnKeyType="done"
- keyboardType="numeric"
+ onChange={this.onInputChange}
+ onKeyPress={this.onKeyPress}
autoFocus={true}
ref={this.accountInput}
/>
- <Animated.View style={this.accountInputButtonStyles()} onPress={this.onSubmit}>
- <InputSubmitIcon
+ <StyledInputButton
+ visible={this.allowInteraction() && this.accountTokenValid()}
+ onClick={this.onSubmit}>
+ <StyledInputSubmitIcon
visible={this.props.loginState.type !== 'logging in'}
source="icon-arrow"
height={16}
width={24}
tintColor="rgb(255, 255, 255)"
/>
- </Animated.View>
- </View>
+ </StyledInputButton>
+ </StyledAccountInputBackdrop>
<Accordion expanded={this.shouldShowAccountHistory()}>
{
<AccountDropdown
@@ -387,23 +283,23 @@ export default class Login extends Component<IProps, IState> {
/>
}
</Accordion>
- </View>
- </View>
+ </StyledAccountInputGroup>
+ </>
);
}
private createFooter() {
return (
- <View>
- <Text style={styles.login_footer__prompt}>
+ <>
+ <StyledLoginFooterPrompt>
{messages.pgettext('login-view', "Don't have an account number?")}
- </Text>
+ </StyledLoginFooterPrompt>
<AppButton.BlueButton
onClick={this.props.createNewAccount}
disabled={!this.allowInteraction()}>
{messages.pgettext('login-view', 'Create account')}
</AppButton.BlueButton>
- </View>
+ </>
);
}
}
@@ -414,26 +310,24 @@ interface IAccountDropdownProps {
onRemove: (value: AccountToken) => void;
}
-class AccountDropdown extends Component<IAccountDropdownProps> {
- public render() {
- const uniqueItems = [...new Set(this.props.items)];
- return (
- <View>
- {uniqueItems.map((token) => {
- const label = formatAccountToken(token);
- return (
- <AccountDropdownItem
- key={token}
- value={token}
- label={label}
- onSelect={this.props.onSelect}
- onRemove={this.props.onRemove}
- />
- );
- })}
- </View>
- );
- }
+function AccountDropdown(props: IAccountDropdownProps) {
+ const uniqueItems = [...new Set(props.items)];
+ return (
+ <>
+ {uniqueItems.map((token) => {
+ const label = formatAccountToken(token);
+ return (
+ <AccountDropdownItem
+ key={token}
+ value={token}
+ label={label}
+ onSelect={props.onSelect}
+ onRemove={props.onRemove}
+ />
+ );
+ })}
+ </>
+ );
}
interface IAccountDropdownItemProps {
@@ -443,33 +337,31 @@ interface IAccountDropdownItemProps {
onSelect: (value: AccountToken) => void;
}
-class AccountDropdownItem extends Component<IAccountDropdownItemProps> {
- public render() {
- return (
- <View>
- <View style={styles.account_dropdown__spacer} />
- <AccountDropdownItemButton>
- <AccountDropdownItemButtonLabel onClick={this.handleSelect}>
- {this.props.label}
- </AccountDropdownItemButtonLabel>
- <AccountDropdownRemoveIcon
- tintColor={colors.blue40}
- tintHoverColor={colors.blue}
- source="icon-close-sml"
- height={16}
- width={16}
- onClick={this.handleRemove}
- />
- </AccountDropdownItemButton>
- </View>
- );
- }
+function AccountDropdownItem(props: IAccountDropdownItemProps) {
+ const handleSelect = useCallback(() => {
+ props.onSelect(props.value);
+ }, [props.onSelect, props.value]);
- private handleSelect = () => {
- this.props.onSelect(this.props.value);
- };
+ const handleRemove = useCallback(() => {
+ props.onRemove(props.value);
+ }, [props.onRemove, props.value]);
- private handleRemove = () => {
- this.props.onRemove(this.props.value);
- };
+ return (
+ <>
+ <StyledDropdownSpacer />
+ <StyledAccountDropdownItemButton>
+ <StyledAccountDropdownItemButtonLabel onClick={handleSelect}>
+ {props.label}
+ </StyledAccountDropdownItemButtonLabel>
+ <StyledAccountDropdownRemoveIcon
+ tintColor={colors.blue40}
+ tintHoverColor={colors.blue}
+ source="icon-close-sml"
+ height={16}
+ width={16}
+ onClick={handleRemove}
+ />
+ </StyledAccountDropdownItemButton>
+ </>
+ );
}
diff --git a/gui/src/renderer/components/LoginStyles.tsx b/gui/src/renderer/components/LoginStyles.tsx
index 2cd9a8debb..65d6266ceb 100644
--- a/gui/src/renderer/components/LoginStyles.tsx
+++ b/gui/src/renderer/components/LoginStyles.tsx
@@ -1,10 +1,10 @@
-import { Styles } from 'reactxp';
import styled from 'styled-components';
import { colors } from '../../config.json';
import ImageView from './ImageView';
import * as Cell from './Cell';
+import { bigText, smallText } from './common-styles';
-export const AccountDropdownRemoveIcon = styled(ImageView)({
+export const StyledAccountDropdownRemoveIcon = styled(ImageView)({
justifyContent: 'center',
paddingTop: '10px',
paddingRight: '12px',
@@ -13,7 +13,7 @@ export const AccountDropdownRemoveIcon = styled(ImageView)({
marginLeft: '0px',
});
-export const InputSubmitIcon = styled(ImageView)((props: { visible: boolean }) => ({
+export const StyledInputSubmitIcon = styled(ImageView)((props: { visible: boolean }) => ({
flex: 0,
borderWidth: '0px',
width: '48px',
@@ -22,7 +22,7 @@ export const InputSubmitIcon = styled(ImageView)((props: { visible: boolean }) =
opacity: props.visible ? 1 : 0,
}));
-export const AccountDropdownItemButton = styled(Cell.CellButton)({
+export const StyledAccountDropdownItemButton = styled(Cell.CellButton)({
padding: '0px',
marginBottom: '0px',
flexDirection: 'row',
@@ -34,7 +34,7 @@ export const AccountDropdownItemButton = styled(Cell.CellButton)({
},
});
-export const AccountDropdownItemButtonLabel = styled(Cell.Label)({
+export const StyledAccountDropdownItemButtonLabel = styled(Cell.Label)({
padding: '11px 0px 11px 12px',
margin: '0',
color: colors.blue80,
@@ -42,111 +42,111 @@ export const AccountDropdownItemButtonLabel = styled(Cell.Label)({
textAlign: 'left',
marginLeft: 0,
cursor: 'default',
- [AccountDropdownItemButton + ':hover']: {
+ [StyledAccountDropdownItemButton + ':hover']: {
color: colors.blue,
},
});
-export default {
- login_footer: Styles.createViewStyle({
- flex: 0,
- paddingTop: 18,
- paddingBottom: 22,
- paddingHorizontal: 22,
- backgroundColor: colors.darkBlue,
- }),
- status_icon: Styles.createViewStyle({
- flex: 0,
- marginBottom: 30,
- alignItems: 'center',
- height: 48,
- }),
- login_form: Styles.createViewStyle({
- flex: 1,
- flexDirection: 'column',
- overflow: 'visible',
- paddingTop: 0,
- paddingBottom: 0,
- paddingLeft: 22,
- paddingRight: 22,
- marginTop: 83,
- marginBottom: 0,
- marginRight: 0,
- marginLeft: 0,
- }),
- account_input_group: Styles.createViewStyle({
- borderWidth: 2,
- borderRadius: 8,
- borderColor: 'transparent',
- }),
- account_input_group__active: Styles.createViewStyle({
- borderColor: colors.darkBlue,
- }),
- account_input_group__inactive: Styles.createViewStyle({
- opacity: 0.6,
- }),
- account_input_group__error: Styles.createViewStyle({
- borderColor: colors.red40,
- }),
- account_input_backdrop: Styles.createViewStyle({
- backgroundColor: colors.white,
- borderColor: colors.darkBlue,
- flexDirection: 'row',
- }),
- input_button: Styles.createViewStyle({
- flex: 0,
- borderWidth: 0,
- width: 48,
- alignItems: 'center',
- justifyContent: 'center',
- }),
- input_button__invisible: Styles.createViewStyle({
- backgroundColor: colors.white,
- opacity: 0,
- }),
- account_dropdown__spacer: Styles.createViewStyle({
- height: 1,
- backgroundColor: colors.darkBlue,
- }),
+export const StyledFooter = styled.div({}, (props: { show: boolean }) => ({
+ position: 'absolute',
+ width: '100%',
+ bottom: 0,
+ transform: `translateY(${props.show ? 0 : 100}%)`,
+ display: 'flex',
+ flexDirection: 'column',
+ padding: '18px 22px 22px',
+ backgroundColor: colors.darkBlue,
+ transition: 'transform 250ms ease-in-out',
+}));
- login_footer__prompt: Styles.createTextStyle({
- color: colors.white80,
- fontFamily: 'Open Sans',
- fontSize: 13,
- fontWeight: '600',
- lineHeight: 18,
- marginBottom: 8,
- }),
- // TODO: Use bigText in comonStyles when converted from ReactXP
- title: Styles.createTextStyle({
- fontFamily: 'DINPro',
- fontSize: 30,
- fontWeight: '900',
- lineHeight: 40,
- color: colors.white,
- marginBottom: 7,
- flex: 0,
- }),
- subtitle: Styles.createTextStyle({
- fontFamily: 'Open Sans',
- fontSize: 13,
- lineHeight: 15,
- fontWeight: '600',
- color: colors.white80,
- marginBottom: 8,
- }),
- account_input_textfield: Styles.createTextInputStyle({
- borderWidth: 0,
- paddingTop: 10,
- paddingRight: 12,
- paddingLeft: 12,
- paddingBottom: 12,
- fontFamily: 'DINPro',
- fontSize: 20,
- fontWeight: '900',
- lineHeight: 26,
- color: colors.blue,
- backgroundColor: 'transparent',
- flex: 1,
- }),
-};
+export const StyledStatusIcon = styled.div({
+ display: 'flex',
+ flex: 0,
+ marginBottom: '30px',
+ justifyContent: 'center',
+ height: '48px',
+ minHeight: '48px',
+});
+
+export const StyledLoginForm = styled.div({
+ display: 'flex',
+ flex: 1,
+ flexDirection: 'column',
+ overflow: 'visible',
+ padding: '0 22px',
+ margin: '83px 0 0',
+});
+
+interface IStyledAccountInputGroupProps {
+ editable: boolean;
+ active: boolean;
+ error: boolean;
+}
+
+export const StyledAccountInputGroup = styled.div((props: IStyledAccountInputGroupProps) => ({
+ borderWidth: '2px',
+ borderStyle: 'solid',
+ borderRadius: '8px',
+ overflow: 'hidden',
+ borderColor: props.error ? colors.red40 : props.active ? colors.darkBlue : 'transparent',
+ opacity: props.editable ? 1 : 0.6,
+}));
+
+export const StyledAccountInputBackdrop = styled.div({
+ display: 'flex',
+ backgroundColor: colors.white,
+ borderColor: colors.darkBlue,
+});
+
+export const StyledInputButton = styled.button((props: { visible: boolean }) => ({
+ display: 'flex',
+ flex: 0,
+ borderWidth: 0,
+ width: '48px',
+ alignItems: 'center',
+ justifyContent: 'center',
+ opacity: props.visible ? 1 : 0,
+ transition: 'opacity 250ms ease-in-out',
+ backgroundColor: colors.green,
+}));
+
+export const StyledDropdownSpacer = styled.div({
+ height: 1,
+ backgroundColor: colors.darkBlue,
+});
+
+export const StyledLoginFooterPrompt = styled.span({
+ color: colors.white80,
+ fontFamily: 'Open Sans',
+ fontSize: '13px',
+ fontWeight: 600,
+ lineHeight: '18px',
+ marginBottom: '8px',
+});
+
+export const StyledTitle = styled.span(bigText, {
+ lineHeight: '40px',
+ marginBottom: '7px',
+ flex: 0,
+});
+
+export const StyledSubtitle = styled.span(smallText, {
+ lineHeight: '15px',
+ marginBottom: '8px',
+});
+
+export const StyledInput = styled.input({
+ minWidth: 0,
+ borderWidth: 0,
+ padding: '10px 12px 12px',
+ fontFamily: 'DINPro',
+ fontSize: '20px',
+ fontWeight: 900,
+ lineHeight: '26px',
+ color: colors.blue,
+ backgroundColor: 'transparent',
+ flex: 1,
+ '::placeholder': {
+ color: colors.blue40,
+ },
+});