diff options
| author | Hank <hank@mullvad.net> | 2022-10-11 13:40:19 +0200 |
|---|---|---|
| committer | Hank <hank@mullvad.net> | 2022-10-11 13:40:19 +0200 |
| commit | a98c2eb6dc2eba3cd8885a7d74d43f6313e26de7 (patch) | |
| tree | 3e8394d75db83c1913091c6e09527eb4e15c781b | |
| parent | b27a50b785e29f0ca8ba5a0f3362b5b174760b22 (diff) | |
| parent | ed5c5f59cbc225fd4e17ce3742d2ff9992afea26 (diff) | |
| download | mullvadvpn-a98c2eb6dc2eba3cd8885a7d74d43f6313e26de7.tar.xz mullvadvpn-a98c2eb6dc2eba3cd8885a7d74d43f6313e26de7.zip | |
Merge branch 'connect-component'
| -rw-r--r-- | gui/src/renderer/components/Connect.tsx | 319 | ||||
| -rw-r--r-- | gui/src/renderer/components/MainView.tsx | 4 | ||||
| -rw-r--r-- | gui/src/renderer/containers/ConnectPage.tsx | 105 |
3 files changed, 174 insertions, 254 deletions
diff --git a/gui/src/renderer/components/Connect.tsx b/gui/src/renderer/components/Connect.tsx index 35b13786bb..10ee3f209d 100644 --- a/gui/src/renderer/components/Connect.tsx +++ b/gui/src/renderer/components/Connect.tsx @@ -1,31 +1,27 @@ -import * as React from 'react'; +import { useCallback, useMemo } from 'react'; +import { sprintf } from 'sprintf-js'; import styled from 'styled-components'; -import { hasExpired } from '../../shared/account-expiry'; -import { AuthFailureKind, parseAuthFailure } from '../../shared/auth-failure'; -import NotificationArea from '../components/NotificationArea'; -import { LoginState } from '../redux/account/reducers'; -import { IConnectionReduxState } from '../redux/connection/reducers'; +import { messages, relayLocations } from '../../shared/gettext'; +import log from '../../shared/logging'; +import { useAppContext } from '../context'; +import { useHistory } from '../lib/history'; +import { RoutePath } from '../lib/routes'; +import { IRelayLocationRedux, RelaySettingsRedux } from '../redux/settings/reducers'; +import { useSelector } from '../redux/store'; import { calculateHeaderBarStyle, DefaultHeaderBar } from './HeaderBar'; import ImageView from './ImageView'; import { Container, Layout } from './Layout'; import Map, { MarkerStyle, ZoomLevel } from './Map'; +import NotificationArea from './NotificationArea'; import TunnelControl from './TunnelControl'; -interface IProps { - connection: IConnectionReduxState; - loginState: LoginState; - accountExpiry?: string; - blockWhenDisconnected: boolean; - selectedRelayName: string; - onSelectLocation: () => void; - onConnect: () => void; - onDisconnect: () => void; - onReconnect: () => void; -} - type MarkerOrSpinner = 'marker' | 'spinner' | 'none'; +const StyledContainer = styled(Container)({ + position: 'relative', +}); + const StyledMap = styled(Map)({ position: 'absolute', top: 0, @@ -35,10 +31,6 @@ const StyledMap = styled(Map)({ zIndex: 0, }); -const StyledContainer = styled(Container)({ - position: 'relative', -}); - const Content = styled.div({ display: 'flex', flex: 1, @@ -66,161 +58,194 @@ const StyledMain = styled.main({ flex: 1, }); -interface IState { - isAccountExpired: boolean; -} +export default function Connect() { + const history = useHistory(); + const { connectTunnel, disconnectTunnel, reconnectTunnel } = useAppContext(); -export default class Connect extends React.Component<IProps, IState> { - constructor(props: IProps) { - super(props); + const connection = useSelector((state) => state.connection); + const blockWhenDisconnected = useSelector((state) => state.settings.blockWhenDisconnected); + const relaySettings = useSelector((state) => state.settings.relaySettings); + const relayLocations = useSelector((state) => state.settings.relayLocations); - this.state = { - isAccountExpired: this.checkAccountExpired(false), - }; - } + const mapCenter = useMemo<[number, number] | undefined>(() => { + const { longitude, latitude } = connection; + return typeof longitude === 'number' && typeof latitude === 'number' + ? [longitude, latitude] + : undefined; + }, [connection]); - public componentDidUpdate() { - this.updateAccountExpired(); - } + const showMarkerOrSpinner = useMemo<MarkerOrSpinner>(() => { + if (!mapCenter) { + return 'none'; + } - public render() { - return ( - <Layout> - <DefaultHeaderBar barStyle={calculateHeaderBarStyle(this.props.connection.status)} /> - <StyledContainer>{this.renderMap()}</StyledContainer> - </Layout> - ); - } + switch (connection.status.state) { + case 'error': + return 'none'; + case 'connecting': + case 'disconnecting': + return 'spinner'; + case 'connected': + case 'disconnected': + return 'marker'; + } + }, [mapCenter, connection.status.state]); + + const markerStyle = useMemo<MarkerStyle>(() => { + switch (connection.status.state) { + case 'connecting': + case 'connected': + return MarkerStyle.secure; + case 'error': + return !connection.status.details.blockFailure ? MarkerStyle.secure : MarkerStyle.unsecure; + case 'disconnected': + return MarkerStyle.unsecure; + case 'disconnecting': + switch (connection.status.details) { + case 'block': + case 'reconnect': + return MarkerStyle.secure; + case 'nothing': + return MarkerStyle.unsecure; + } + } + }, [connection.status]); - private updateAccountExpired() { - const nextAccountExpired = this.checkAccountExpired(this.state.isAccountExpired); + const zoomLevel = useMemo<ZoomLevel>(() => { + const { longitude, latitude } = connection; - if (nextAccountExpired !== this.state.isAccountExpired) { - this.setState({ - isAccountExpired: nextAccountExpired, - }); + if (typeof longitude === 'number' && typeof latitude === 'number') { + return connection.status.state === 'connected' ? ZoomLevel.high : ZoomLevel.medium; + } else { + return ZoomLevel.low; } - } + }, [connection.latitude, connection.longitude, connection.status.state]); + + const mapProps = useMemo<Map['props']>(() => { + return { + center: mapCenter ?? [0, 0], + showMarker: showMarkerOrSpinner === 'marker', + markerStyle, + zoomLevel, + // a magic offset to align marker with spinner + offset: [0, mapCenter ? 123 : 0], + }; + }, [mapCenter, showMarkerOrSpinner, markerStyle, zoomLevel]); + + const onSelectLocation = useCallback(() => { + history.show(RoutePath.selectLocation); + }, [history.show]); - private checkAccountExpired(prevAccountExpired: boolean): boolean { - const tunnelState = this.props.connection.status; + const selectedRelayName = useMemo(() => getRelayName(relaySettings, relayLocations), [ + relaySettings, + relayLocations, + ]); - // Blocked with auth failure / expired account - if ( - tunnelState.state === 'error' && - tunnelState.details.cause.reason === 'auth_failed' && - parseAuthFailure(tunnelState.details.cause.reason).kind === AuthFailureKind.expiredAccount - ) { - return true; + const onConnect = useCallback(async () => { + try { + await connectTunnel(); + } catch (e) { + const error = e as Error; + log.error(`Failed to connect the tunnel: ${error.message}`); } + }, []); - // Use the account expiry to deduce the account state - if (this.props.accountExpiry) { - return hasExpired(this.props.accountExpiry); + const onDisconnect = useCallback(async () => { + try { + await disconnectTunnel(); + } catch (e) { + const error = e as Error; + log.error(`Failed to disconnect the tunnel: ${error.message}`); } + }, []); - // Do not assume that the account hasn't expired if the expiry is not available at the moment - // instead return the last known state. - return prevAccountExpired; - } + const onReconnect = useCallback(async () => { + try { + await reconnectTunnel(); + } catch (e) { + const error = e as Error; + log.error(`Failed to reconnect the tunnel: ${error.message}`); + } + }, []); - private renderMap() { - return ( - <> - <StyledMap {...this.getMapProps()} /> + return ( + <Layout> + <DefaultHeaderBar barStyle={calculateHeaderBarStyle(connection.status)} /> + <StyledContainer> + <StyledMap {...mapProps} /> <Content> <StyledNotificationArea /> <StyledMain> {/* show spinner when connecting */} - {this.showMarkerOrSpinner() === 'spinner' ? ( + {showMarkerOrSpinner === 'spinner' ? ( <StatusIcon source="icon-spinner" height={60} width={60} /> ) : null} <TunnelControl - tunnelState={this.props.connection.status} - blockWhenDisconnected={this.props.blockWhenDisconnected} - selectedRelayName={this.props.selectedRelayName} - city={this.props.connection.city} - country={this.props.connection.country} - onConnect={this.props.onConnect} - onDisconnect={this.props.onDisconnect} - onReconnect={this.props.onReconnect} - onSelectLocation={this.props.onSelectLocation} + tunnelState={connection.status} + blockWhenDisconnected={blockWhenDisconnected} + selectedRelayName={selectedRelayName} + city={connection.city} + country={connection.country} + onConnect={onConnect} + onDisconnect={onDisconnect} + onReconnect={onReconnect} + onSelectLocation={onSelectLocation} /> </StyledMain> </Content> - </> - ); - } - - private getMapProps(): Map['props'] { - const mapCenter = this.getMapCenter(); - - return { - center: mapCenter ?? [0, 0], - showMarker: this.showMarkerOrSpinner() === 'marker', - markerStyle: this.getMarkerStyle(), - zoomLevel: this.getZoomLevel(), - // a magic offset to align marker with spinner - offset: [0, mapCenter ? 123 : 0], - }; - } - - private getMapCenter(): [number, number] | undefined { - const { longitude, latitude } = this.props.connection; - - return typeof longitude === 'number' && typeof latitude === 'number' - ? [longitude, latitude] - : undefined; - } + </StyledContainer> + </Layout> + ); +} - private getMarkerStyle(): MarkerStyle { - const { status } = this.props.connection; +function getRelayName(relaySettings: RelaySettingsRedux, locations: IRelayLocationRedux[]): string { + if ('normal' in relaySettings) { + const location = relaySettings.normal.location; - switch (status.state) { - case 'connecting': - case 'connected': - return MarkerStyle.secure; - case 'error': - return !status.details.blockFailure ? MarkerStyle.secure : MarkerStyle.unsecure; - case 'disconnected': - return MarkerStyle.unsecure; - case 'disconnecting': - switch (status.details) { - case 'block': - case 'reconnect': - return MarkerStyle.secure; - case 'nothing': - return MarkerStyle.unsecure; + if (location === 'any') { + return 'Automatic'; + } else if ('country' in location) { + const country = locations.find(({ code }) => code === location.country); + if (country) { + return relayLocations.gettext(country.name); + } + } else if ('city' in location) { + const [countryCode, cityCode] = location.city; + const country = locations.find(({ code }) => code === countryCode); + if (country) { + const city = country.cities.find(({ code }) => code === cityCode); + if (city) { + return relayLocations.gettext(city.name); } + } + } else if ('hostname' in location) { + const [countryCode, cityCode, hostname] = location.hostname; + const country = locations.find(({ code }) => code === countryCode); + if (country) { + const city = country.cities.find(({ code }) => code === cityCode); + if (city) { + return sprintf( + // TRANSLATORS: The selected location label displayed on the main view, when a user selected a specific host to connect to. + // TRANSLATORS: Example: Malmö (se-mma-001) + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(city)s - a city name + // TRANSLATORS: %(hostname)s - a hostname + messages.pgettext('connect-container', '%(city)s (%(hostname)s)'), + { + city: relayLocations.gettext(city.name), + hostname, + }, + ); + } + } } - } - - private showMarkerOrSpinner(): MarkerOrSpinner { - if (!this.getMapCenter()) { - return 'none'; - } - - switch (this.props.connection.status.state) { - case 'error': - return 'none'; - case 'connecting': - case 'disconnecting': - return 'spinner'; - case 'connected': - case 'disconnected': - return 'marker'; - } - } - - private getZoomLevel(): ZoomLevel { - const { longitude, latitude, status } = this.props.connection; - if (typeof longitude === 'number' && typeof latitude === 'number') { - return status.state === 'connected' ? ZoomLevel.high : ZoomLevel.medium; - } else { - return ZoomLevel.low; - } + return 'Unknown'; + } else if (relaySettings.customTunnelEndpoint) { + return 'Custom'; + } else { + throw new Error('Unsupported relay settings.'); } } diff --git a/gui/src/renderer/components/MainView.tsx b/gui/src/renderer/components/MainView.tsx index f59097854e..33f5f92ad5 100644 --- a/gui/src/renderer/components/MainView.tsx +++ b/gui/src/renderer/components/MainView.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { hasExpired } from '../../shared/account-expiry'; -import ConnectPage from '../containers/ConnectPage'; +import Connect from '../components/Connect'; import ExpiredAccountErrorViewContainer from '../containers/ExpiredAccountErrorViewContainer'; import { useHistory } from '../lib/history'; import { RoutePath } from '../lib/routes'; @@ -39,6 +39,6 @@ export default function MainView() { if (showAccountExpired.show) { return <ExpiredAccountErrorViewContainer />; } else { - return <ConnectPage />; + return <Connect />; } } diff --git a/gui/src/renderer/containers/ConnectPage.tsx b/gui/src/renderer/containers/ConnectPage.tsx deleted file mode 100644 index aeba16e5d0..0000000000 --- a/gui/src/renderer/containers/ConnectPage.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { connect } from 'react-redux'; -import { sprintf } from 'sprintf-js'; - -import { messages, relayLocations } from '../../shared/gettext'; -import log from '../../shared/logging'; -import Connect from '../components/Connect'; -import withAppContext, { IAppContext } from '../context'; -import { IHistoryProps, withHistory } from '../lib/history'; -import { RoutePath } from '../lib/routes'; -import { IRelayLocationRedux, RelaySettingsRedux } from '../redux/settings/reducers'; -import { IReduxState, ReduxDispatch } from '../redux/store'; - -function getRelayName(relaySettings: RelaySettingsRedux, locations: IRelayLocationRedux[]): string { - if ('normal' in relaySettings) { - const location = relaySettings.normal.location; - - if (location === 'any') { - return 'Automatic'; - } else if ('country' in location) { - const country = locations.find(({ code }) => code === location.country); - if (country) { - return relayLocations.gettext(country.name); - } - } else if ('city' in location) { - const [countryCode, cityCode] = location.city; - const country = locations.find(({ code }) => code === countryCode); - if (country) { - const city = country.cities.find(({ code }) => code === cityCode); - if (city) { - return relayLocations.gettext(city.name); - } - } - } else if ('hostname' in location) { - const [countryCode, cityCode, hostname] = location.hostname; - const country = locations.find(({ code }) => code === countryCode); - if (country) { - const city = country.cities.find(({ code }) => code === cityCode); - if (city) { - return sprintf( - // TRANSLATORS: The selected location label displayed on the main view, when a user selected a specific host to connect to. - // TRANSLATORS: Example: Malmö (se-mma-001) - // TRANSLATORS: Available placeholders: - // TRANSLATORS: %(city)s - a city name - // TRANSLATORS: %(hostname)s - a hostname - messages.pgettext('connect-container', '%(city)s (%(hostname)s)'), - { - city: relayLocations.gettext(city.name), - hostname, - }, - ); - } - } - } - - return 'Unknown'; - } else if (relaySettings.customTunnelEndpoint) { - return 'Custom'; - } else { - throw new Error('Unsupported relay settings.'); - } -} - -const mapStateToProps = (state: IReduxState) => { - return { - accountExpiry: state.account.expiry, - loginState: state.account.status, - blockWhenDisconnected: state.settings.blockWhenDisconnected, - selectedRelayName: getRelayName(state.settings.relaySettings, state.settings.relayLocations), - connection: state.connection, - }; -}; - -const mapDispatchToProps = (_dispatch: ReduxDispatch, props: IHistoryProps & IAppContext) => { - return { - onSelectLocation: () => { - props.history.show(RoutePath.selectLocation); - }, - onConnect: async () => { - try { - await props.app.connectTunnel(); - } catch (e) { - const error = e as Error; - log.error(`Failed to connect the tunnel: ${error.message}`); - } - }, - onDisconnect: async () => { - try { - await props.app.disconnectTunnel(); - } catch (e) { - const error = e as Error; - log.error(`Failed to disconnect the tunnel: ${error.message}`); - } - }, - onReconnect: async () => { - try { - await props.app.reconnectTunnel(); - } catch (e) { - const error = e as Error; - log.error(`Failed to reconnect the tunnel: ${error.message}`); - } - }, - }; -}; - -export default withAppContext(withHistory(connect(mapStateToProps, mapDispatchToProps)(Connect))); |
