diff options
| author | Hank <hank@mullvad.net> | 2022-10-11 18:36:03 +0200 |
|---|---|---|
| committer | Hank <hank@mullvad.net> | 2022-10-17 07:49:47 +0200 |
| commit | 11097c1d42572d7389d8a897fc098d58b74f51a9 (patch) | |
| tree | db08dc9ad623b9a3c6a82b6596c94e85df06c9ad | |
| parent | 169121cd2a078fd89abb01672464658c47fe48f5 (diff) | |
| download | mullvadvpn-11097c1d42572d7389d8a897fc098d58b74f51a9.tar.xz mullvadvpn-11097c1d42572d7389d8a897fc098d58b74f51a9.zip | |
Make ExpiredAccountErrorView.tsx a functional component
| -rw-r--r-- | gui/src/renderer/components/ExpiredAccountErrorView.tsx | 458 | ||||
| -rw-r--r-- | gui/src/renderer/components/MainView.tsx | 4 |
2 files changed, 270 insertions, 192 deletions
diff --git a/gui/src/renderer/components/ExpiredAccountErrorView.tsx b/gui/src/renderer/components/ExpiredAccountErrorView.tsx index 2d4fcdb216..7c2e8ead51 100644 --- a/gui/src/renderer/components/ExpiredAccountErrorView.tsx +++ b/gui/src/renderer/components/ExpiredAccountErrorView.tsx @@ -1,10 +1,15 @@ -import * as React from 'react'; +import { createContext, ReactNode, useCallback, useContext, useMemo, useState } from 'react'; import { sprintf } from 'sprintf-js'; import { links } from '../../config.json'; -import { AccountToken, TunnelState } from '../../shared/daemon-rpc-types'; import { messages } from '../../shared/gettext'; -import { LoginState } from '../redux/account/reducers'; +import log from '../../shared/logging'; +import { useAppContext } from '../context'; +import { useHistory } from '../lib/history'; +import { RoutePath } from '../lib/routes'; +import { IAccountReduxState } from '../redux/account/reducers'; +import { IConnectionReduxState } from '../redux/connection/reducers'; +import { useSelector } from '../redux/store'; import * as AppButton from './AppButton'; import { AriaDescribed, AriaDescription, AriaDescriptionGroup } from './AriaGroup'; import * as Cell from './cell'; @@ -26,220 +31,242 @@ import ImageView from './ImageView'; import { Footer, Layout } from './Layout'; import { ModalAlert, ModalAlertType, ModalMessage } from './Modal'; -export enum RecoveryAction { +enum RecoveryAction { openBrowser, disconnect, disableBlockedWhenDisconnected, } -interface IExpiredAccountErrorViewProps { - isBlocked: boolean; - blockWhenDisconnected: boolean; - accountToken?: AccountToken; - loginState: LoginState; - tunnelState: TunnelState; - onExternalLinkWithAuth: (url: string) => Promise<void>; - onDisconnect: () => Promise<void>; - setBlockWhenDisconnected: (value: boolean) => void; - navigateToRedeemVoucher: () => void; +export default function ExpiredAccountErrorView() { + return ( + <ExpiredAccountContextProvider> + <ExpiredAccountErrorViewComponent /> + </ExpiredAccountContextProvider> + ); } -interface IExpiredAccountErrorViewState { - showBlockWhenDisconnectedAlert: boolean; +function ExpiredAccountErrorViewComponent() { + const { account, connection, getRecoveryAction, isNewAccount } = useExpiredAccountContext(); + + const history = useHistory(); + const { disconnectTunnel } = useAppContext(); + + const headerBarStyle = useMemo(() => { + return isNewAccount ? HeaderBarStyle.default : calculateHeaderBarStyle(connection.status); + }, [account.status, connection.status]); + + const onDisconnect = useCallback(async () => { + try { + await disconnectTunnel(); + } catch (e) { + const error = e as Error; + log.error(`Failed to disconnect the tunnel: ${error.message}`); + } + }, []); + + const navigateToRedeemVoucher = useCallback(() => { + history.push(RoutePath.redeemVoucher); + }, [history.push]); + + return ( + <Layout> + <StyledHeader barStyle={headerBarStyle} /> + <StyledCustomScrollbars fillContainer> + <StyledContainer> + <StyledBody>{isNewAccount ? <WelcomeView /> : <Content />}</StyledBody> + + <Footer> + <AppButton.ButtonGroup> + {getRecoveryAction() === RecoveryAction.disconnect && ( + <AppButton.BlockingButton onClick={onDisconnect}> + <AppButton.RedButton> + {messages.pgettext('connect-view', 'Disconnect')} + </AppButton.RedButton> + </AppButton.BlockingButton> + )} + + <ExternalPaymentButton /> + + <AppButton.GreenButton onClick={navigateToRedeemVoucher}> + {messages.pgettext('connect-view', 'Redeem voucher')} + </AppButton.GreenButton> + </AppButton.ButtonGroup> + </Footer> + + <BlockWhenDisconnectedAlert /> + </StyledContainer> + </StyledCustomScrollbars> + </Layout> + ); } -export default class ExpiredAccountErrorView extends React.Component< - IExpiredAccountErrorViewProps, - IExpiredAccountErrorViewState -> { - public state: IExpiredAccountErrorViewState = { - showBlockWhenDisconnectedAlert: false, - }; +function WelcomeView() { + const { account, getRecoveryActionMessage } = useExpiredAccountContext(); - public render() { - const headerBarStyle = - this.props.loginState.type === 'ok' && this.props.loginState.method === 'new_account' - ? HeaderBarStyle.default - : calculateHeaderBarStyle(this.props.tunnelState); + return ( + <> + <StyledTitle>{messages.pgettext('connect-view', 'Congrats!')}</StyledTitle> + <StyledAccountTokenMessage> + {messages.pgettext('connect-view', 'Here’s your account number. Save it!')} + <StyledAccountTokenContainer> + <StyledAccountTokenLabel accountToken={account.accountToken || ''} obscureValue={false} /> + </StyledAccountTokenContainer> + </StyledAccountTokenMessage> - return ( - <Layout> - <StyledHeader barStyle={headerBarStyle} /> - <StyledCustomScrollbars fillContainer> - <StyledContainer> - <StyledBody>{this.renderContent()}</StyledBody> + <StyledMessage> + {sprintf('%(introduction)s %(recoveryMessage)s', { + introduction: messages.pgettext( + 'connect-view', + 'To start using the app, you first need to add time to your account.', + ), + recoveryMessage: getRecoveryActionMessage(), + })} + </StyledMessage> + </> + ); +} - <Footer> - <AppButton.ButtonGroup> - {this.getRecoveryAction() === RecoveryAction.disconnect && ( - <AppButton.BlockingButton onClick={this.props.onDisconnect}> - <AppButton.RedButton> - {messages.pgettext('connect-view', 'Disconnect')} - </AppButton.RedButton> - </AppButton.BlockingButton> - )} +function Content() { + const { getRecoveryActionMessage } = useExpiredAccountContext(); - {this.renderExternalPaymentButton()} + return ( + <> + <StyledStatusIcon> + <ImageView source="icon-fail" height={60} width={60} /> + </StyledStatusIcon> + <StyledTitle>{messages.pgettext('connect-view', 'Out of time')}</StyledTitle> + <StyledMessage> + {sprintf('%(introduction)s %(recoveryMessage)s', { + introduction: messages.pgettext( + 'connect-view', + 'You have no more VPN time left on this account.', + ), + recoveryMessage: getRecoveryActionMessage(), + })} + </StyledMessage> + </> + ); +} - <AppButton.GreenButton onClick={this.props.navigateToRedeemVoucher}> - {messages.pgettext('connect-view', 'Redeem voucher')} - </AppButton.GreenButton> - </AppButton.ButtonGroup> - </Footer> +function ExternalPaymentButton() { + const { getRecoveryAction, isNewAccount, onOpenExternalPayment } = useExpiredAccountContext(); - {this.renderBlockWhenDisconnectedAlert()} - </StyledContainer> - </StyledCustomScrollbars> - </Layout> - ); - } + const buttonText = isNewAccount + ? messages.gettext('Buy credit') + : messages.gettext('Buy more credit'); - private renderContent() { - if (this.isNewAccount()) { - return this.renderWelcomeView(); - } + return ( + <AppButton.BlockingButton + disabled={getRecoveryAction() === RecoveryAction.disconnect} + onClick={onOpenExternalPayment}> + <AriaDescriptionGroup> + <AriaDescribed> + <AppButton.GreenButton> + <AppButton.Label>{buttonText}</AppButton.Label> + <AriaDescription> + <AppButton.Icon + source="icon-extLink" + height={16} + width={16} + aria-label={messages.pgettext('accessibility', 'Opens externally')} + /> + </AriaDescription> + </AppButton.GreenButton> + </AriaDescribed> + </AriaDescriptionGroup> + </AppButton.BlockingButton> + ); +} - return ( - <> - <StyledStatusIcon> - <ImageView source="icon-fail" height={60} width={60} /> - </StyledStatusIcon> - <StyledTitle>{messages.pgettext('connect-view', 'Out of time')}</StyledTitle> - <StyledMessage> - {sprintf('%(introduction)s %(recoveryMessage)s', { - introduction: messages.pgettext( - 'connect-view', - 'You have no more VPN time left on this account.', - ), - recoveryMessage: this.getRecoveryActionMessage(), - })} - </StyledMessage> - </> - ); - } +function BlockWhenDisconnectedAlert() { + const { + blockWhenDisconnected, + setBlockWhenDisconnected, + showBlockWhenDisconnectedAlert, + setShowBlockWhenDisconnectedAlert, + } = useExpiredAccountContext(); - private renderWelcomeView() { - return ( - <> - <StyledTitle>{messages.pgettext('connect-view', 'Congrats!')}</StyledTitle> - <StyledAccountTokenMessage> - {messages.pgettext('connect-view', 'Here’s your account number. Save it!')} - <StyledAccountTokenContainer> - <StyledAccountTokenLabel - accountToken={this.props.accountToken || ''} - obscureValue={false} - /> - </StyledAccountTokenContainer> - </StyledAccountTokenMessage> + const onCloseBlockWhenDisconnectedInstructions = useCallback(() => { + setShowBlockWhenDisconnectedAlert(false); + }, []); - <StyledMessage> - {sprintf('%(introduction)s %(recoveryMessage)s', { - introduction: messages.pgettext( - 'connect-view', - 'To start using the app, you first need to add time to your account.', - ), - recoveryMessage: this.getRecoveryActionMessage(), - })} - </StyledMessage> - </> - ); - } + const onChange = useCallback(async (blockWhenDisconnected: boolean) => { + try { + await setBlockWhenDisconnected(blockWhenDisconnected); + } catch (e) { + const error = e as Error; + log.error('Failed to update block when disconnected', error.message); + } + }, []); - private getRecoveryActionMessage() { - switch (this.getRecoveryAction()) { - case RecoveryAction.openBrowser: - case RecoveryAction.disableBlockedWhenDisconnected: - return messages.pgettext( + return ( + <ModalAlert + isOpen={showBlockWhenDisconnectedAlert} + type={ModalAlertType.caution} + buttons={[ + <AppButton.BlueButton key="cancel" onClick={onCloseBlockWhenDisconnectedInstructions}> + {messages.gettext('Close')} + </AppButton.BlueButton>, + ]} + close={onCloseBlockWhenDisconnectedInstructions}> + <ModalMessage> + {messages.pgettext( 'connect-view', - 'Either buy credit on our website or redeem a voucher.', - ); - case RecoveryAction.disconnect: - return messages.pgettext( + 'You need to disable "Lockdown mode" in order to access the Internet to add time.', + )} + </ModalMessage> + <ModalMessage> + {messages.pgettext( 'connect-view', - 'To add more, you will need to disconnect and access the Internet with an unsecure connection.', - ); - } - } + 'Remember, turning it off will allow network traffic while the VPN is disconnected until you turn it back on under Advanced settings.', + )} + </ModalMessage> + <StyledModalCellContainer> + <Cell.Label>{messages.pgettext('vpn-settings-view', 'Lockdown mode')}</Cell.Label> + <Cell.Switch isOn={blockWhenDisconnected} onChange={onChange} /> + </StyledModalCellContainer> + </ModalAlert> + ); +} - private renderExternalPaymentButton() { - const buttonText = this.isNewAccount() - ? messages.gettext('Buy credit') - : messages.gettext('Buy more credit'); +type ExpiredAccountContextType = { + account: IAccountReduxState; + blockWhenDisconnected: boolean; + connection: IConnectionReduxState; + getRecoveryAction: () => RecoveryAction; + getRecoveryActionMessage: () => string; + isNewAccount: boolean; + onOpenExternalPayment: () => Promise<void>; + setBlockWhenDisconnected: (val: boolean) => Promise<void>; + setShowBlockWhenDisconnectedAlert: (val: boolean) => void; + showBlockWhenDisconnectedAlert: boolean; +}; - return ( - <AppButton.BlockingButton - disabled={this.getRecoveryAction() === RecoveryAction.disconnect} - onClick={this.onOpenExternalPayment}> - <AriaDescriptionGroup> - <AriaDescribed> - <AppButton.GreenButton> - <AppButton.Label>{buttonText}</AppButton.Label> - <AriaDescription> - <AppButton.Icon - source="icon-extLink" - height={16} - width={16} - aria-label={messages.pgettext('accessibility', 'Opens externally')} - /> - </AriaDescription> - </AppButton.GreenButton> - </AriaDescribed> - </AriaDescriptionGroup> - </AppButton.BlockingButton> - ); - } +const ExpiredAccountContext = createContext<ExpiredAccountContextType | undefined>(undefined); - private renderBlockWhenDisconnectedAlert() { - return ( - <ModalAlert - isOpen={this.state.showBlockWhenDisconnectedAlert} - type={ModalAlertType.caution} - buttons={[ - <AppButton.BlueButton - key="cancel" - onClick={this.onCloseBlockWhenDisconnectedInstructions}> - {messages.gettext('Close')} - </AppButton.BlueButton>, - ]} - close={this.onCloseBlockWhenDisconnectedInstructions}> - <ModalMessage> - {messages.pgettext( - 'connect-view', - 'You need to disable "Lockdown mode" in order to access the Internet to add time.', - )} - </ModalMessage> - <ModalMessage> - {messages.pgettext( - 'connect-view', - 'Remember, turning it off will allow network traffic while the VPN is disconnected until you turn it back on under Advanced settings.', - )} - </ModalMessage> - <StyledModalCellContainer> - <Cell.Label>{messages.pgettext('vpn-settings-view', 'Lockdown mode')}</Cell.Label> - <Cell.Switch - isOn={this.props.blockWhenDisconnected} - onChange={this.props.setBlockWhenDisconnected} - /> - </StyledModalCellContainer> - </ModalAlert> - ); - } +const ExpiredAccountContextProvider = ({ children }: { children: ReactNode }) => { + const account = useSelector((state) => state.account); + const blockWhenDisconnected = useSelector((state) => state.settings.blockWhenDisconnected); + const connection = useSelector((state) => state.connection); + const { setBlockWhenDisconnected, openLinkWithAuth } = useAppContext(); - private isNewAccount() { - return this.props.loginState.type === 'ok' && this.props.loginState.method === 'new_account'; - } + const isBlocked = connection.isBlocked; + const [showBlockWhenDisconnectedAlert, setShowBlockWhenDisconnectedAlert] = useState(false); + + const isNewAccount = useMemo( + () => account.status.type === 'ok' && account.status.method === 'new_account', + [account.status], + ); - private onOpenExternalPayment = async (): Promise<void> => { - if (this.getRecoveryAction() === RecoveryAction.disableBlockedWhenDisconnected) { - this.setState({ showBlockWhenDisconnectedAlert: true }); + const onOpenExternalPayment = async () => { + if (getRecoveryAction() === RecoveryAction.disableBlockedWhenDisconnected) { + setShowBlockWhenDisconnectedAlert(true); } else { - await this.props.onExternalLinkWithAuth(links.purchase); + await openLinkWithAuth(links.purchase); } }; - private getRecoveryAction() { - const { blockWhenDisconnected, isBlocked } = this.props; - + const getRecoveryAction = () => { if (blockWhenDisconnected && isBlocked) { return RecoveryAction.disableBlockedWhenDisconnected; } else if (!blockWhenDisconnected && isBlocked) { @@ -247,9 +274,60 @@ export default class ExpiredAccountErrorView extends React.Component< } else { return RecoveryAction.openBrowser; } - } + }; - private onCloseBlockWhenDisconnectedInstructions = () => { - this.setState({ showBlockWhenDisconnectedAlert: false }); + const getRecoveryActionMessage = () => { + switch (getRecoveryAction()) { + case RecoveryAction.openBrowser: + case RecoveryAction.disableBlockedWhenDisconnected: + return messages.pgettext( + 'connect-view', + 'Either buy credit on our website or redeem a voucher.', + ); + case RecoveryAction.disconnect: + return messages.pgettext( + 'connect-view', + 'To add more, you will need to disconnect and access the Internet with an unsecure connection.', + ); + } }; -} + + const value: ExpiredAccountContextType = useMemo( + () => ({ + account, + blockWhenDisconnected, + connection, + getRecoveryAction, + getRecoveryActionMessage, + isNewAccount, + onOpenExternalPayment, + setBlockWhenDisconnected, + setShowBlockWhenDisconnectedAlert, + showBlockWhenDisconnectedAlert, + }), + [ + account, + blockWhenDisconnected, + connection, + getRecoveryAction, + getRecoveryActionMessage, + isNewAccount, + onOpenExternalPayment, + setBlockWhenDisconnected, + setShowBlockWhenDisconnectedAlert, + showBlockWhenDisconnectedAlert, + ], + ); + return <ExpiredAccountContext.Provider value={value}>{children}</ExpiredAccountContext.Provider>; +}; + +const useExpiredAccountContext = () => { + const context = useContext(ExpiredAccountContext); + if (!context) { + throw new Error( + 'useExpiredAccountContext must be used within an ExpiredAccountContextProvider', + ); + } + + return context; +}; diff --git a/gui/src/renderer/components/MainView.tsx b/gui/src/renderer/components/MainView.tsx index 33f5f92ad5..cc2419ddfd 100644 --- a/gui/src/renderer/components/MainView.tsx +++ b/gui/src/renderer/components/MainView.tsx @@ -2,10 +2,10 @@ import { useEffect, useState } from 'react'; import { hasExpired } from '../../shared/account-expiry'; import Connect from '../components/Connect'; -import ExpiredAccountErrorViewContainer from '../containers/ExpiredAccountErrorViewContainer'; import { useHistory } from '../lib/history'; import { RoutePath } from '../lib/routes'; import { useSelector } from '../redux/store'; +import ExpiredAccountErrorView from './ExpiredAccountErrorView'; type ExpiryData = { show: false } | { show: true; expiry: string | undefined }; @@ -37,7 +37,7 @@ export default function MainView() { }, [showAccountExpired, accountHasExpired]); if (showAccountExpired.show) { - return <ExpiredAccountErrorViewContainer />; + return <ExpiredAccountErrorView />; } else { return <Connect />; } |
