diff options
| author | Oskar <oskar@mullvad.net> | 2024-08-16 13:45:54 +0200 |
|---|---|---|
| committer | Oskar <oskar@mullvad.net> | 2024-08-21 17:05:50 +0200 |
| commit | c9050739874b153d14efb9dfd363fd140278390f (patch) | |
| tree | cbc6ee293fa7368c6812dbdc1276ef92ce4a9b0f /gui/src | |
| parent | 86da8428eae571b22dee94a99e5ae00812516f7e (diff) | |
| download | mullvadvpn-c9050739874b153d14efb9dfd363fd140278390f.tar.xz mullvadvpn-c9050739874b153d14efb9dfd363fd140278390f.zip | |
Implement main view connection island
Diffstat (limited to 'gui/src')
16 files changed, 970 insertions, 808 deletions
diff --git a/gui/src/renderer/components/AppRouter.tsx b/gui/src/renderer/components/AppRouter.tsx index 0f85601a92..ca17d76f1f 100644 --- a/gui/src/renderer/components/AppRouter.tsx +++ b/gui/src/renderer/components/AppRouter.tsx @@ -8,7 +8,6 @@ import { ITransitionSpecification, transitions, useHistory } from '../lib/histor import { RoutePath } from '../lib/routes'; import Account from './Account'; import ApiAccessMethods from './ApiAccessMethods'; -import Connect from './Connect'; import Debug from './Debug'; import { DeviceRevokedView } from './DeviceRevokedView'; import { EditApiAccessMethod } from './EditApiAccessMethod'; @@ -23,6 +22,7 @@ import ExpiredAccountErrorView from './ExpiredAccountErrorView'; import Filter from './Filter'; import Focus, { IFocusHandle } from './Focus'; import Launch from './Launch'; +import MainView from './main-view/MainView'; import OpenVpnSettings from './OpenVpnSettings'; import ProblemReport from './ProblemReport'; import SelectLanguage from './SelectLanguage'; @@ -71,7 +71,7 @@ export default function AppRouter() { <Route exact path={RoutePath.login} component={LoginPage} /> <Route exact path={RoutePath.tooManyDevices} component={TooManyDevices} /> <Route exact path={RoutePath.deviceRevoked} component={DeviceRevokedView} /> - <Route exact path={RoutePath.main} component={Connect} /> + <Route exact path={RoutePath.main} component={MainView} /> <Route exact path={RoutePath.expired} component={ExpiredAccountErrorView} /> <Route exact path={RoutePath.redeemVoucher} component={VoucherInput} /> <Route exact path={RoutePath.voucherSuccess} component={VoucherVerificationSuccess} /> diff --git a/gui/src/renderer/components/ConnectionPanel.tsx b/gui/src/renderer/components/ConnectionPanel.tsx deleted file mode 100644 index 03e2753ede..0000000000 --- a/gui/src/renderer/components/ConnectionPanel.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import * as React from 'react'; -import { sprintf } from 'sprintf-js'; -import styled from 'styled-components'; - -import { colors, strings } from '../../config.json'; -import { - EndpointObfuscationType, - ProxyType, - RelayProtocol, - TunnelType, - tunnelTypeToString, -} from '../../shared/daemon-rpc-types'; -import { messages } from '../../shared/gettext'; -import { default as ConnectionPanelDisclosure } from '../components/ConnectionPanelDisclosure'; -import { tinyText } from './common-styles'; -import Marquee from './Marquee'; - -export interface IEndpoint { - ip: string; - port: number; - protocol: RelayProtocol; -} - -export interface IInAddress extends IEndpoint { - tunnelType: TunnelType; -} - -export interface IBridgeData extends IEndpoint { - bridgeType: ProxyType; -} - -export interface IObfuscationData extends IEndpoint { - obfuscationType: EndpointObfuscationType; -} - -export interface IOutAddress { - ipv4?: string; - ipv6?: string; -} - -interface IProps { - isOpen: boolean; - hostname?: string; - bridgeHostname?: string; - entryHostname?: string; - inAddress?: IInAddress; - entryLocationInAddress?: IInAddress; - bridgeInfo?: IBridgeData; - outAddress?: IOutAddress; - obfuscationEndpoint?: IObfuscationData; - daita: boolean; - onToggle: () => void; - className?: string; -} - -const Container = styled.div({ - display: 'flex', - flexDirection: 'column', -}); - -const Row = styled.div({ - display: 'flex', - marginTop: '3px', -}); - -const Text = styled.span(tinyText, { - lineHeight: '15px', - color: colors.white, -}); - -const Caption = styled(Text)({ - flex: 0, - marginRight: '8px', -}); - -const IpAddresses = styled.div({ - display: 'flex', - flexDirection: 'column', -}); - -const Header = styled.div({ - alignSelf: 'start', - display: 'flex', - alignItems: 'center', - width: '100%', -}); - -export default class ConnectionPanel extends React.Component<IProps> { - public render() { - const { outAddress } = this.props; - const entryPoint = this.getEntryPoint(); - - return ( - <Container className={this.props.className}> - {this.props.hostname && ( - <Header> - <ConnectionPanelDisclosure pointsUp={this.props.isOpen} onToggle={this.props.onToggle}> - <Marquee data-testid="hostname-line">{this.hostnameLine()}</Marquee> - </ConnectionPanelDisclosure> - </Header> - )} - - {this.props.isOpen && this.props.hostname && ( - <React.Fragment> - {this.props.inAddress && ( - <Row> - <Text data-testid="tunnel-protocol">{this.transportLine()}</Text> - </Row> - )} - - {entryPoint && ( - <Row> - <Caption>{messages.pgettext('connection-info', 'In')}</Caption> - <Text data-testid="in-ip"> - {`${entryPoint.ip}:${entryPoint.port} ${entryPoint.protocol.toUpperCase()}`} - </Text> - </Row> - )} - - {outAddress && (outAddress.ipv4 || outAddress.ipv6) && ( - <Row> - <Caption>{messages.pgettext('connection-info', 'Out')}</Caption> - <IpAddresses> - {outAddress.ipv4 && <Text>{outAddress.ipv4}</Text>} - {outAddress.ipv6 && <Text>{outAddress.ipv6}</Text>} - </IpAddresses> - </Row> - )} - </React.Fragment> - )} - </Container> - ); - } - - private getEntryPoint(): IEndpoint | undefined { - const { obfuscationEndpoint, inAddress, entryLocationInAddress, bridgeInfo } = this.props; - - if (obfuscationEndpoint) { - return obfuscationEndpoint; - } else if (entryLocationInAddress && inAddress) { - return entryLocationInAddress; - } else if (bridgeInfo && inAddress) { - return bridgeInfo; - } else { - return inAddress; - } - } - - private hostnameLine() { - let hostname = ''; - - if (this.props.hostname && this.props.bridgeHostname) { - hostname = sprintf(messages.pgettext('connection-info', '%(relay)s via %(entry)s'), { - relay: this.props.hostname, - entry: this.props.bridgeHostname, - }); - } else if (this.props.hostname && this.props.entryHostname) { - hostname = sprintf( - // TRANSLATORS: The hostname line displayed below the country on the main screen - // TRANSLATORS: Available placeholders: - // TRANSLATORS: %(relay)s - the relay hostname - // TRANSLATORS: %(entry)s - the entry relay hostname - messages.pgettext('connection-info', '%(relay)s via %(entry)s'), - { - relay: this.props.hostname, - entry: this.props.entryHostname, - }, - ); - } else if (this.props.bridgeInfo !== undefined) { - hostname = sprintf(messages.pgettext('connection-info', '%(relay)s via Custom bridge'), { - relay: this.props.hostname, - }); - } else if (this.props.hostname) { - hostname = this.props.hostname; - } - - if (hostname !== '' && this.props.daita) { - hostname = sprintf( - // TRANSLATORS: %(hostname)s - The current server the app is connected to, e.g. "se-got-wg-001 using DAITA" - // TRANSLATORS: %(daita)s - Will be replaced with "DAITA" - messages.pgettext('connection-info', '%(hostname)s using %(daita)s'), - { - hostname, - daita: strings.daita, - }, - ); - } - - return hostname; - } - - private transportLine() { - const { inAddress, bridgeInfo } = this.props; - - if (inAddress) { - const tunnelType = tunnelTypeToString(inAddress.tunnelType); - - if (bridgeInfo) { - const bridgeType = this.bridgeType(); - - return sprintf( - // TRANSLATORS: The tunnel type line displayed below the hostname line on the main screen - // TRANSLATORS: Available placeholders: - // TRANSLATORS: %(tunnelType)s - the tunnel type, i.e OpenVPN - // TRANSLATORS: %(bridgeType)s - the bridge type, i.e Shadowsocks - messages.pgettext('connection-info', '%(tunnelType)s via %(bridgeType)s'), - { - tunnelType, - bridgeType, - }, - ); - } else { - return tunnelType; - } - } else { - return ''; - } - } - - private bridgeType() { - if (this.props.bridgeHostname && this.props.bridgeInfo?.bridgeType === 'shadowsocks') { - return 'Shadowsocks bridge'; - } else { - return 'Custom bridge'; - } - } -} diff --git a/gui/src/renderer/components/ConnectionPanelDisclosure.tsx b/gui/src/renderer/components/ConnectionPanelDisclosure.tsx deleted file mode 100644 index 3941c3d88f..0000000000 --- a/gui/src/renderer/components/ConnectionPanelDisclosure.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import * as React from 'react'; -import styled from 'styled-components'; - -import { colors } from '../../config.json'; -import { normalText } from './common-styles'; -import ImageView from './ImageView'; - -const Container = styled.div({ - display: 'flex', - alignItems: 'center', - width: '100%', -}); - -const Caption = styled.span<{ $open: boolean }>(normalText, (props) => ({ - fontWeight: 600, - lineHeight: '20px', - minWidth: '0px', - color: props.$open ? colors.white : colors.white40, - [Container + ':hover &&']: { - color: colors.white, - }, -})); - -const Chevron = styled(ImageView)({ - [Container + ':hover &&']: { - backgroundColor: colors.white, - }, -}); - -interface IProps { - pointsUp: boolean; - onToggle?: () => void; - children: React.ReactNode; - className?: string; -} - -export default function ConnectionPanelDisclosure(props: IProps) { - return ( - <Container className={props.className} onClick={props.onToggle}> - <Caption $open={props.pointsUp}>{props.children}</Caption> - <Chevron - source={props.pointsUp ? 'icon-chevron-up' : 'icon-chevron-down'} - width={22} - height={22} - tintColor={colors.white40} - /> - </Container> - ); -} diff --git a/gui/src/renderer/components/TunnelControl.tsx b/gui/src/renderer/components/TunnelControl.tsx deleted file mode 100644 index 0d11762d38..0000000000 --- a/gui/src/renderer/components/TunnelControl.tsx +++ /dev/null @@ -1,305 +0,0 @@ -import * as React from 'react'; -import { sprintf } from 'sprintf-js'; -import styled from 'styled-components'; - -import { TunnelState } from '../../shared/daemon-rpc-types'; -import { messages, relayLocations } from '../../shared/gettext'; -import ConnectionPanelContainer from '../containers/ConnectionPanelContainer'; -import * as AppButton from './AppButton'; -import { hugeText, measurements, normalText } from './common-styles'; -import ImageView from './ImageView'; -import { Footer } from './Layout'; -import Marquee from './Marquee'; -import { MultiButton } from './MultiButton'; -import SecuredLabel, { SecuredDisplayStyle } from './SecuredLabel'; - -interface ITunnelControlProps { - tunnelState: TunnelState; - blockWhenDisconnected: boolean; - selectedRelayName: string; - city?: string; - country?: string; - onConnect: () => void; - onDisconnect: () => void; - onReconnect: () => void; - onSelectLocation: () => void; -} - -const Secured = styled(SecuredLabel)(normalText, { - fontWeight: 700, - lineHeight: '22px', -}); - -const Body = styled.div({ - display: 'flex', - flexDirection: 'column', - padding: `0 ${measurements.viewMargin}`, - minHeight: '185px', -}); - -const Wrapper = styled.div({ - display: 'flex', - flexDirection: 'column', - justifyContent: 'end', - flex: 1, -}); - -const Location = styled.div({ - display: 'flex', - flexDirection: 'column', -}); - -const LocationRow = styled.div({ - height: '36px', -}); - -const StyledMarquee = styled(Marquee)(hugeText, { - lineHeight: '36px', - overflow: 'hidden', -}); - -const SelectedLocationChevron = styled(AppButton.Icon)({ - margin: '0 4px', -}); - -export default class TunnelControl extends React.Component<ITunnelControlProps> { - public render() { - let state = this.props.tunnelState.state; - let pq = false; - - switch (this.props.tunnelState.state) { - case 'disconnecting': - switch (this.props.tunnelState.details) { - case 'block': - state = 'error'; - break; - case 'reconnect': - state = 'connecting'; - break; - default: - state = 'disconnecting'; - break; - } - break; - case 'connecting': - if (this.props.tunnelState.details) { - pq = this.props.tunnelState.details.endpoint.quantumResistant; - } - break; - case 'connected': - pq = this.props.tunnelState.details.endpoint.quantumResistant; - break; - } - - switch (state) { - case 'connecting': { - const displayStyle = pq ? SecuredDisplayStyle.securingPq : SecuredDisplayStyle.securing; - return ( - <Wrapper> - <Body> - <Secured displayStyle={displayStyle} /> - <Location> - {this.renderCountry()} - {this.renderCity()} - </Location> - <ConnectionPanelContainer /> - </Body> - <Footer> - <AppButton.ButtonGroup> - {this.switchLocationButton()} - <MultiButton mainButton={this.cancelButton} sideButton={this.reconnectButton} /> - </AppButton.ButtonGroup> - </Footer> - </Wrapper> - ); - } - - case 'connected': { - const displayStyle = pq ? SecuredDisplayStyle.securedPq : SecuredDisplayStyle.secured; - return ( - <Wrapper> - <Body> - <Secured displayStyle={displayStyle} /> - <Location> - {this.renderCountry()} - {this.renderCity()} - </Location> - <ConnectionPanelContainer /> - </Body> - <Footer> - <AppButton.ButtonGroup> - {this.switchLocationButton()} - <MultiButton mainButton={this.disconnectButton} sideButton={this.reconnectButton} /> - </AppButton.ButtonGroup> - </Footer> - </Wrapper> - ); - } - - case 'error': - if ( - this.props.tunnelState.state === 'error' && - this.props.tunnelState.details.blockingError - ) { - return ( - <Wrapper> - <Body> - <Secured displayStyle={SecuredDisplayStyle.failedToSecure} /> - </Body> - <Footer> - <AppButton.ButtonGroup> - {this.switchLocationButton()} - <MultiButton mainButton={this.dismissButton} sideButton={this.reconnectButton} /> - </AppButton.ButtonGroup> - </Footer> - </Wrapper> - ); - } else { - return ( - <Wrapper> - <Body> - <Secured displayStyle={SecuredDisplayStyle.blocked} /> - </Body> - <Footer> - <AppButton.ButtonGroup> - {this.switchLocationButton()} - <MultiButton mainButton={this.cancelButton} sideButton={this.reconnectButton} /> - </AppButton.ButtonGroup> - </Footer> - </Wrapper> - ); - } - - case 'disconnecting': - return ( - <Wrapper> - <Body> - <Secured displayStyle={SecuredDisplayStyle.unsecuring} /> - <Location> - {this.renderCountry()} - <LocationRow /> - </Location> - </Body> - <Footer> - <AppButton.ButtonGroup> - {this.selectLocationButton()} - {this.connectButton()} - </AppButton.ButtonGroup> - </Footer> - </Wrapper> - ); - - case 'disconnected': { - const displayStyle = this.props.blockWhenDisconnected - ? SecuredDisplayStyle.blocked - : SecuredDisplayStyle.unsecured; - return ( - <Wrapper> - <Body> - <Secured displayStyle={displayStyle} /> - <Location> - {this.renderCountry()} - <LocationRow /> - </Location> - </Body> - <Footer> - <AppButton.ButtonGroup> - {this.selectLocationButton()} - {this.connectButton()} - </AppButton.ButtonGroup> - </Footer> - </Wrapper> - ); - } - - default: - throw new Error(`Unknown TunnelState: ${this.props.tunnelState}`); - } - } - - private renderCity() { - const city = this.props.city === undefined ? '' : relayLocations.gettext(this.props.city); - return ( - <LocationRow> - <StyledMarquee data-testid="city">{city}</StyledMarquee> - </LocationRow> - ); - } - - private renderCountry() { - const country = - this.props.country === undefined ? '' : relayLocations.gettext(this.props.country); - return ( - <LocationRow> - <StyledMarquee data-testid="country">{country}</StyledMarquee> - </LocationRow> - ); - } - - private switchLocationButton() { - return ( - <AppButton.TransparentButton onClick={this.props.onSelectLocation}> - {messages.pgettext('tunnel-control', 'Switch location')} - </AppButton.TransparentButton> - ); - } - - private selectLocationButton() { - return ( - <AppButton.TransparentButton - onClick={this.props.onSelectLocation} - aria-label={sprintf( - messages.pgettext('accessibility', 'Select location. Current location is %(location)s'), - { location: this.props.selectedRelayName }, - )}> - <AppButton.Label>{this.props.selectedRelayName}</AppButton.Label> - <SelectedLocationChevron height={12} width={7} source="icon-chevron" /> - </AppButton.TransparentButton> - ); - } - - private connectButton() { - return ( - <AppButton.GreenButton onClick={this.props.onConnect}> - {messages.pgettext('tunnel-control', 'Secure my connection')} - </AppButton.GreenButton> - ); - } - - private disconnectButton = (props: AppButton.IProps) => { - return ( - <AppButton.RedTransparentButton onClick={this.props.onDisconnect} {...props}> - {messages.gettext('Disconnect')} - </AppButton.RedTransparentButton> - ); - }; - - private cancelButton = (props: AppButton.IProps) => { - return ( - <AppButton.RedTransparentButton onClick={this.props.onDisconnect} {...props}> - {messages.gettext('Cancel')} - </AppButton.RedTransparentButton> - ); - }; - - private dismissButton = (props: AppButton.IProps) => { - return ( - <AppButton.RedTransparentButton onClick={this.props.onDisconnect} {...props}> - {messages.gettext('Dismiss')} - </AppButton.RedTransparentButton> - ); - }; - - private reconnectButton = (props: AppButton.IProps) => { - return ( - <AppButton.RedTransparentButton - onClick={this.props.onReconnect} - aria-label={messages.gettext('Reconnect')} - {...props}> - <AppButton.Label> - <ImageView height={22} width={22} source="icon-reload" tintColor="white" /> - </AppButton.Label> - </AppButton.RedTransparentButton> - ); - }; -} diff --git a/gui/src/renderer/components/main-view/ConnectionActionButton.tsx b/gui/src/renderer/components/main-view/ConnectionActionButton.tsx new file mode 100644 index 0000000000..ad579da671 --- /dev/null +++ b/gui/src/renderer/components/main-view/ConnectionActionButton.tsx @@ -0,0 +1,63 @@ +import { useCallback } from 'react'; +import styled from 'styled-components'; + +import { messages } from '../../../shared/gettext'; +import log from '../../../shared/logging'; +import { useAppContext } from '../../context'; +import { useSelector } from '../../redux/store'; +import { SmallButton, SmallButtonColor } from '../SmallButton'; + +const StyledConnectionButton = styled(SmallButton)({ + margin: 0, +}); + +export default function ConnectionActionButton() { + const tunnelState = useSelector((state) => state.connection.status.state); + + if (tunnelState === 'disconnected' || tunnelState === 'disconnecting') { + return <ConnectButton disabled={tunnelState === 'disconnecting'} />; + } else { + return <DisconnectButton />; + } +} + +function ConnectButton(props: Partial<Parameters<typeof SmallButton>[0]>) { + const { connectTunnel } = useAppContext(); + + const onConnect = useCallback(async () => { + try { + await connectTunnel(); + } catch (e) { + const error = e as Error; + log.error(`Failed to connect the tunnel: ${error.message}`); + } + }, []); + + return ( + <StyledConnectionButton color={SmallButtonColor.green} onClick={onConnect} {...props}> + {messages.pgettext('tunnel-control', 'Connect')} + </StyledConnectionButton> + ); +} + +function DisconnectButton() { + const { disconnectTunnel } = useAppContext(); + const tunnelState = useSelector((state) => state.connection.status.state); + + const onDisconnect = useCallback(async () => { + try { + await disconnectTunnel(); + } catch (e) { + const error = e as Error; + log.error(`Failed to disconnect the tunnel: ${error.message}`); + } + }, []); + + const displayAsCancel = tunnelState !== 'connected'; + + return ( + <StyledConnectionButton color={SmallButtonColor.red} onClick={onDisconnect}> + {displayAsCancel ? messages.gettext('Cancel') : messages.gettext('Disconnect')} + </StyledConnectionButton> + ); +} diff --git a/gui/src/renderer/components/main-view/ConnectionDetails.tsx b/gui/src/renderer/components/main-view/ConnectionDetails.tsx new file mode 100644 index 0000000000..c6bff32ef3 --- /dev/null +++ b/gui/src/renderer/components/main-view/ConnectionDetails.tsx @@ -0,0 +1,202 @@ +import { useEffect, useState } from 'react'; +import styled from 'styled-components'; + +import { colors } from '../../../config.json'; +import { + EndpointObfuscationType, + ITunnelEndpoint, + parseSocketAddress, + ProxyType, + RelayProtocol, + TunnelState, + TunnelType, + tunnelTypeToString, +} from '../../../shared/daemon-rpc-types'; +import { messages } from '../../../shared/gettext'; +import { useSelector } from '../../redux/store'; +import { tinyText } from '../common-styles'; + +interface Endpoint { + ip: string; + port: number; + protocol: RelayProtocol; +} + +interface InAddress extends Endpoint { + tunnelType: TunnelType; +} + +interface BridgeData extends Endpoint { + bridgeType: ProxyType; +} + +interface ObfuscationData extends Endpoint { + obfuscationType: EndpointObfuscationType; +} + +const StyledConnectionDetailsHeading = styled.h2(tinyText, { + margin: '0 0 4px', + fontSize: '10px', + lineHeight: '15px', + color: colors.white60, +}); + +const StyledConnectionDetailsContainer = styled.div({ + marginTop: '16px', + marginBottom: '16px', +}); + +const StyledIpTable = styled.div({ + display: 'grid', + gridTemplateColumns: 'minmax(48px, min-content) auto', +}); + +const StyledIpLabelContainer = styled.div({ + display: 'flex', + flexDirection: 'column', +}); + +const StyledConnectionDetailsLabel = styled.span(tinyText, { + display: 'block', + color: colors.white, + fontWeight: '400', + minHeight: '1em', +}); + +const StyledConnectionDetailsTitle = styled(StyledConnectionDetailsLabel)({ + color: colors.white60, + whiteSpace: 'nowrap', +}); + +export default function ConnectionDetails() { + const reduxConnection = useSelector((state) => state.connection); + const [connection, setConnection] = useState(reduxConnection); + + const tunnelState = connection.status; + + useEffect(() => { + if ( + reduxConnection.status.state === 'connected' || + reduxConnection.status.state === 'connecting' + ) { + setConnection(reduxConnection); + } + }, [reduxConnection, tunnelState.state]); + + const entry = getEntryPoint(tunnelState); + + const showDetails = tunnelState.state === 'connected' || tunnelState.state === 'connecting'; + const hasEntry = showDetails && entry !== undefined; + + return ( + <StyledConnectionDetailsContainer> + <StyledConnectionDetailsHeading> + {messages.pgettext('connect-view', 'Connection details')} + </StyledConnectionDetailsHeading> + <StyledConnectionDetailsLabel data-testid="tunnel-protocol"> + {showDetails && + tunnelState.details !== undefined && + tunnelTypeToString(tunnelState.details.endpoint.tunnelType)} + </StyledConnectionDetailsLabel> + <StyledIpTable> + <StyledConnectionDetailsTitle> + {messages.pgettext('connection-info', 'In')} + </StyledConnectionDetailsTitle> + <StyledConnectionDetailsLabel data-testid="in-ip"> + {hasEntry ? `${entry.ip}:${entry.port} ${entry.protocol.toUpperCase()}` : ''} + </StyledConnectionDetailsLabel> + <StyledConnectionDetailsTitle> + {messages.pgettext('connection-info', 'Out')} + </StyledConnectionDetailsTitle> + <StyledIpLabelContainer> + {connection.ipv4 && ( + <StyledConnectionDetailsLabel>{connection.ipv4}</StyledConnectionDetailsLabel> + )} + {connection.ipv6 && ( + <StyledConnectionDetailsLabel>{connection.ipv6}</StyledConnectionDetailsLabel> + )} + </StyledIpLabelContainer> + </StyledIpTable> + </StyledConnectionDetailsContainer> + ); +} + +function getEntryPoint(tunnelState: TunnelState): Endpoint | undefined { + if ( + (tunnelState.state !== 'connected' && tunnelState.state !== 'connecting') || + tunnelState.details === undefined + ) { + return undefined; + } + + const endpoint = tunnelState.details.endpoint; + const inAddress = tunnelEndpointToRelayInAddress(endpoint); + const entryLocationInAddress = tunnelEndpointToEntryLocationInAddress(endpoint); + const bridgeInfo = tunnelEndpointToBridgeData(endpoint); + const obfuscationEndpoint = tunnelEndpointToObfuscationEndpoint(endpoint); + + if (obfuscationEndpoint) { + return obfuscationEndpoint; + } else if (entryLocationInAddress && inAddress) { + return entryLocationInAddress; + } else if (bridgeInfo && inAddress) { + return bridgeInfo; + } else { + return inAddress; + } +} + +function tunnelEndpointToRelayInAddress(tunnelEndpoint: ITunnelEndpoint): InAddress { + const socketAddr = parseSocketAddress(tunnelEndpoint.address); + return { + ip: socketAddr.host, + port: socketAddr.port, + protocol: tunnelEndpoint.protocol, + tunnelType: tunnelEndpoint.tunnelType, + }; +} + +function tunnelEndpointToEntryLocationInAddress( + tunnelEndpoint: ITunnelEndpoint, +): InAddress | undefined { + if (!tunnelEndpoint.entryEndpoint) { + return undefined; + } + + const socketAddr = parseSocketAddress(tunnelEndpoint.entryEndpoint.address); + return { + ip: socketAddr.host, + port: socketAddr.port, + protocol: tunnelEndpoint.entryEndpoint.transportProtocol, + tunnelType: tunnelEndpoint.tunnelType, + }; +} + +function tunnelEndpointToBridgeData(endpoint: ITunnelEndpoint): BridgeData | undefined { + if (!endpoint.proxy) { + return undefined; + } + + const socketAddr = parseSocketAddress(endpoint.proxy.address); + return { + ip: socketAddr.host, + port: socketAddr.port, + protocol: endpoint.proxy.protocol, + bridgeType: endpoint.proxy.proxyType, + }; +} + +function tunnelEndpointToObfuscationEndpoint( + endpoint: ITunnelEndpoint, +): ObfuscationData | undefined { + if (!endpoint.obfuscationEndpoint) { + return undefined; + } + + return { + ip: endpoint.obfuscationEndpoint.address, + port: endpoint.obfuscationEndpoint.port, + protocol: endpoint.obfuscationEndpoint.protocol, + obfuscationType: endpoint.obfuscationEndpoint.obfuscationType, + }; +} diff --git a/gui/src/renderer/components/main-view/ConnectionPanel.tsx b/gui/src/renderer/components/main-view/ConnectionPanel.tsx new file mode 100644 index 0000000000..29c4139703 --- /dev/null +++ b/gui/src/renderer/components/main-view/ConnectionPanel.tsx @@ -0,0 +1,113 @@ +import { useCallback, useEffect } from 'react'; +import styled from 'styled-components'; + +import { useBoolean } from '../../lib/utilityHooks'; +import { useSelector } from '../../redux/store'; +import CustomScrollbars from '../CustomScrollbars'; +import ConnectionActionButton from './ConnectionActionButton'; +import ConnectionDetails from './ConnectionDetails'; +import ConnectionPanelChevron from './ConnectionPanelChevron'; +import ConnectionStatus from './ConnectionStatus'; +import FeatureIndicators from './FeatureIndicators'; +import Hostname from './Hostname'; +import Location from './Location'; +import SelectLocationButton from './SelectLocationButton'; +import { ConnectionPanelAccordion } from './styles'; + +const PANEL_MARGIN = '16px'; + +const StyledAccordion = styled(ConnectionPanelAccordion)({ + flexShrink: 0, +}); + +const StyledConnectionPanel = styled.div<{ $expanded: boolean }>((props) => ({ + position: 'relative', + display: 'flex', + flexDirection: 'column', + maxHeight: `calc(100% - 2 * ${PANEL_MARGIN})`, + margin: `auto ${PANEL_MARGIN} ${PANEL_MARGIN}`, + padding: '16px', + justifySelf: 'flex-end', + borderRadius: '12px', + backgroundColor: `rgba(16, 24, 35, ${props.$expanded ? 0.8 : 0.4})`, + backdropFilter: 'blur(6px)', + + transition: 'background-color 300ms ease-out', +})); + +const StyledConnectionButtonContainer = styled.div({ + transition: 'margin-top 300ms ease-out', + display: 'flex', + flexDirection: 'column', + gap: '16px', + marginTop: '16px', +}); + +const StyledCustomScrollbars = styled(CustomScrollbars)({ + flexShrink: 1, +}); + +const StyledConnectionPanelChevron = styled(ConnectionPanelChevron)({ + position: 'absolute', + top: '16px', + right: '16px', + width: 'fit-content', +}); + +const StyledConnectionStatusContainer = styled.div<{ + $expanded: boolean; + $hasFeatureIndicators: boolean; +}>((props) => ({ + paddingBottom: props.$hasFeatureIndicators || props.$expanded ? '16px' : 0, + marginBottom: props.$expanded && props.$hasFeatureIndicators ? '16px' : 0, + borderBottom: props.$expanded ? '1px rgba(255, 255, 255, 0.2) solid' : 'none', + transitionProperty: 'margin-bottom, padding-bottom', + transitionDuration: '300ms', + transitionTimingFunction: 'ease-out', +})); + +export default function ConnectionPanel() { + const [expanded, expandImpl, collapse, toggleExpanded] = useBoolean(); + const tunnelState = useSelector((state) => state.connection.status); + + const allowExpand = tunnelState.state === 'connected' || tunnelState.state === 'connecting'; + + const expand = useCallback(() => { + if (allowExpand) { + expandImpl(); + } + }, [allowExpand, expandImpl]); + + const hasFeatureIndicators = + allowExpand && + tunnelState.featureIndicators !== undefined && + tunnelState.featureIndicators.length > 0; + + useEffect(collapse, [tunnelState, collapse]); + + return ( + <StyledConnectionPanel $expanded={expanded}> + {allowExpand && ( + <StyledConnectionPanelChevron pointsUp={!expanded} onToggle={toggleExpanded} /> + )} + <StyledConnectionStatusContainer + $expanded={expanded} + $hasFeatureIndicators={hasFeatureIndicators} + onClick={toggleExpanded}> + <ConnectionStatus /> + <Location /> + <Hostname /> + </StyledConnectionStatusContainer> + <StyledCustomScrollbars> + <FeatureIndicators expanded={expanded} expandIsland={expand} /> + <StyledAccordion expanded={expanded}> + <ConnectionDetails /> + </StyledAccordion> + </StyledCustomScrollbars> + <StyledConnectionButtonContainer> + <SelectLocationButton /> + <ConnectionActionButton /> + </StyledConnectionButtonContainer> + </StyledConnectionPanel> + ); +} diff --git a/gui/src/renderer/components/main-view/ConnectionPanelChevron.tsx b/gui/src/renderer/components/main-view/ConnectionPanelChevron.tsx new file mode 100644 index 0000000000..d62ec6402d --- /dev/null +++ b/gui/src/renderer/components/main-view/ConnectionPanelChevron.tsx @@ -0,0 +1,37 @@ +import styled from 'styled-components'; + +import { colors } from '../../../config.json'; +import ImageView from '../ImageView'; + +const Container = styled.button({ + display: 'flex', + alignItems: 'center', + width: '100%', + background: 'none', + border: 'none', +}); + +const Chevron = styled(ImageView)({ + [Container + ':hover &&']: { + backgroundColor: colors.white80, + }, +}); + +interface IProps { + pointsUp: boolean; + onToggle?: () => void; + className?: string; +} + +export default function ConnectionPanelChevron(props: IProps) { + return ( + <Container className={props.className} onClick={props.onToggle}> + <Chevron + source={props.pointsUp ? 'icon-chevron-up' : 'icon-chevron-down'} + width={24} + height={24} + tintColor={colors.white} + /> + </Container> + ); +} diff --git a/gui/src/renderer/components/main-view/ConnectionStatus.tsx b/gui/src/renderer/components/main-view/ConnectionStatus.tsx new file mode 100644 index 0000000000..1745fdaf11 --- /dev/null +++ b/gui/src/renderer/components/main-view/ConnectionStatus.tsx @@ -0,0 +1,58 @@ +import styled from 'styled-components'; + +import { colors } from '../../../config.json'; +import { TunnelState } from '../../../shared/daemon-rpc-types'; +import { messages } from '../../../shared/gettext'; +import { useSelector } from '../../redux/store'; +import { largeText } from '../common-styles'; + +const StyledConnectionStatus = styled.span<{ $color: string }>(largeText, (props) => ({ + minHeight: '24px', + color: props.$color, + marginBottom: '4px', +})); + +export default function ConnectionStatus() { + const tunnelState = useSelector((state) => state.connection.status); + const lockdownMode = useSelector((state) => state.settings.blockWhenDisconnected); + + const color = getConnectionSTatusLabelColor(tunnelState, lockdownMode); + const text = getConnectionStatusLabelText(tunnelState); + + return ( + <StyledConnectionStatus role="status" $color={color}> + {text} + </StyledConnectionStatus> + ); +} + +function getConnectionSTatusLabelColor(tunnelState: TunnelState, lockdownMode: boolean) { + switch (tunnelState.state) { + case 'connected': + return colors.green; + case 'connecting': + case 'disconnecting': + return colors.white; + case 'disconnected': + return lockdownMode ? colors.white : colors.red; + case 'error': + return tunnelState.details.blockingError ? colors.red : colors.white; + } +} + +function getConnectionStatusLabelText(tunnelState: TunnelState) { + switch (tunnelState.state) { + case 'connected': + return messages.gettext('CONNECTED'); + case 'connecting': + return messages.gettext('CONNECTING...'); + case 'disconnecting': + return messages.gettext('DISCONNECTING...'); + case 'disconnected': + return messages.gettext('DISCONNECTED'); + case 'error': + return tunnelState.details.blockingError + ? messages.gettext('FAILED TO SECURE CONNECTION') + : messages.gettext('BLOCKED CONNECTION'); + } +} diff --git a/gui/src/renderer/components/main-view/FeatureIndicators.tsx b/gui/src/renderer/components/main-view/FeatureIndicators.tsx new file mode 100644 index 0000000000..b3152c2f45 --- /dev/null +++ b/gui/src/renderer/components/main-view/FeatureIndicators.tsx @@ -0,0 +1,248 @@ +import { useLayoutEffect, useRef } from 'react'; +import { sprintf } from 'sprintf-js'; +import styled from 'styled-components'; + +import { colors, strings } from '../../../config.json'; +import { FeatureIndicator } from '../../../shared/daemon-rpc-types'; +import { messages } from '../../../shared/gettext'; +import { useStyledRef } from '../../lib/utilityHooks'; +import { useSelector } from '../../redux/store'; +import { tinyText } from '../common-styles'; +import { ConnectionPanelAccordion } from './styles'; + +const LINE_HEIGHT = 22; +const GAP = 8; + +const StyledAccordion = styled(ConnectionPanelAccordion)({ + flexShrink: 0, +}); + +const StyledFeatureIndicatorsContainer = styled.div<{ $expanded: boolean }>((props) => ({ + marginTop: '0px', + marginBottom: props.$expanded ? '8px' : 0, + transition: 'margin-bottom 300ms ease-out', +})); + +const StyledTitle = styled.h2(tinyText, { + margin: '0 0 2px', + fontSize: '10px', + lineHeight: '15px', + color: colors.white60, +}); + +const StyledFeatureIndicators = styled.div({ + position: 'relative', +}); + +const StyledFeatureIndicatorsWrapper = styled.div<{ $expanded: boolean }>((props) => ({ + display: 'flex', + flexWrap: 'wrap', + gap: `${GAP}px`, + maxHeight: props.$expanded ? 'fit-content' : '52px', + overflow: 'hidden', +})); + +const StyledFeatureIndicatorLabel = styled.span<{ $expanded: boolean }>(tinyText, (props) => ({ + display: 'inline', + padding: '2px 8px', + justifyContent: 'center', + alignItems: 'center', + borderRadius: '4px', + background: colors.darkerBlue, + color: colors.white, + fontWeight: 400, + whiteSpace: 'nowrap', + visibility: props.$expanded ? 'visible' : 'hidden', +})); + +const StyledBaseEllipsis = styled.span(tinyText, { + position: 'absolute', + bottom: 0, + color: colors.white, + padding: '2px 8px 2px 16px', +}); + +const StyledEllipsisSpacer = styled(StyledBaseEllipsis)({ + right: 0, + opacity: 0, +}); + +const StyledEllipsis = styled(StyledBaseEllipsis)({ + visibility: 'hidden', +}); + +interface FeatureIndicatorsProps { + expanded: boolean; + expandIsland: () => void; +} + +// This component needs to render a maximum of two lines of feature indicators and then ellipsis +// with the text "N more...". This poses two challenges: +// 1. We can't know the size of the content beforehand or how many indicators should be hidden +// 2. The ellipsis string doesn't have a fixed width, the amount can change. +// +// To solve this the indicators are first rendered hidden along with a invisible "placeholder" +// ellipsis at the end of the second row. Then after render, all indicators that either is placed +// after the second row or overlaps with the invisible ellipsis text will be set to invisible. Then +// we can count those and add another ellipsis element which is visible and place it after the last +// visible indicator. +export default function FeatureIndicators(props: FeatureIndicatorsProps) { + const tunnelState = useSelector((state) => state.connection.status); + const ellipsisRef = useStyledRef<HTMLSpanElement>(); + const ellipsisSpacerRef = useStyledRef<HTMLSpanElement>(); + const featureIndicatorsContainerRef = useStyledRef<HTMLDivElement>(); + + const featureIndicatorsVisible = + tunnelState.state === 'connected' || tunnelState.state === 'connecting'; + const featureIndicators = useRef( + featureIndicatorsVisible ? tunnelState.featureIndicators ?? [] : [], + ); + + if (featureIndicatorsVisible && tunnelState.featureIndicators) { + featureIndicators.current = tunnelState.featureIndicators; + } + + const ellipsis = messages.gettext('%(amount)d more...'); + + useLayoutEffect(() => { + if ( + !props.expanded && + featureIndicatorsContainerRef.current && + ellipsisSpacerRef.current && + ellipsisRef.current + ) { + // Get all feature indicator elements. + const indicatorElements = Array.from( + featureIndicatorsContainerRef.current.getElementsByTagName('span'), + ); + let lastVisibleIndex = 0; + let hasHidden = false; + indicatorElements.forEach((indicatorElement, i) => { + if ( + !indicatorShouldBeHidden( + featureIndicatorsContainerRef.current!, + indicatorElement, + ellipsisSpacerRef.current!, + ) + ) { + // If an indicator should be visible we set its visibility and increment the variable + // containing the last visible index. + indicatorElement.style.visibility = 'visible'; + lastVisibleIndex = i; + } else { + // If it should be visible we store that there exists hidden indicators. + hasHidden = true; + } + }); + + if (hasHidden) { + const lastVisibleIndicatorRect = + indicatorElements[lastVisibleIndex].getBoundingClientRect(); + const containerRect = featureIndicatorsContainerRef.current.getBoundingClientRect(); + + // Place the ellipsis at the end of the last visible indicator. + const left = lastVisibleIndicatorRect.right - containerRect.left; + ellipsisRef.current.style.left = `${left}px`; + ellipsisRef.current.style.visibility = 'visible'; + + // Add the ellipsis text to the ellipsis. + ellipsisRef.current.textContent = sprintf(ellipsis, { + amount: indicatorElements.length - (lastVisibleIndex + 1), + }); + } + } + }); + + return ( + <StyledAccordion expanded={featureIndicatorsVisible && featureIndicators.current.length > 0}> + <StyledFeatureIndicatorsContainer onClick={props.expandIsland} $expanded={props.expanded}> + <StyledAccordion expanded={props.expanded}> + <StyledTitle>{messages.pgettext('connect-view', 'Active features')}</StyledTitle> + </StyledAccordion> + <StyledFeatureIndicators> + <StyledFeatureIndicatorsWrapper + ref={featureIndicatorsContainerRef} + $expanded={props.expanded}> + {featureIndicators.current.sort().map((indicator) => ( + <StyledFeatureIndicatorLabel key={indicator.toString()} $expanded={props.expanded}> + {getFeatureIndicatorLabel(indicator)} + </StyledFeatureIndicatorLabel> + ))} + </StyledFeatureIndicatorsWrapper> + {!props.expanded && ( + <> + <StyledEllipsis ref={ellipsisRef} /> + <StyledEllipsisSpacer ref={ellipsisSpacerRef}> + { + // Mock amount for the spacer ellipsis. This needs to be wider than the real + // ellipsis will ever be. + sprintf(ellipsis, { amount: 222 }) + } + </StyledEllipsisSpacer> + </> + )} + </StyledFeatureIndicators> + </StyledFeatureIndicatorsContainer> + </StyledAccordion> + ); +} + +function indicatorShouldBeHidden( + container: HTMLElement, + indicator: HTMLElement, + ellipsisSpacer: HTMLElement, +): boolean { + const indicatorRect = indicator.getBoundingClientRect(); + const ellipsisSpacerRect = ellipsisSpacer.getBoundingClientRect(); + + // If 2 or less lines are required to display the indicators all should be visible. This is + // calculated based on the scroll height. + if (container.scrollHeight <= 2 * LINE_HEIGHT + GAP) { + return false; + } + + // An indicator should be hidden if it's placed farther down than the spacer ellipsis, or if it + // overlaps it. + return ( + indicatorRect.top >= ellipsisSpacerRect.bottom || + (indicatorRect.top === ellipsisSpacerRect.top && indicatorRect.right >= ellipsisSpacerRect.left) + ); +} + +function getFeatureIndicatorLabel(indicator: FeatureIndicator) { + switch (indicator) { + case FeatureIndicator.daita: + return strings.daita; + case FeatureIndicator.udp2tcp: + case FeatureIndicator.shadowsocks: + return messages.pgettext('wireguard-settings-view', 'Obfuscation'); + case FeatureIndicator.multihop: + // TRANSLATORS: This refers to the multihop setting in the VPN settings view. This is + // TRANSLATORS: displayed when the feature is on. + return messages.gettext('Multihop'); + case FeatureIndicator.customDns: + // TRANSLATORS: This refers to the Custom DNS setting in the VPN settings view. This is + // TRANSLATORS: displayed when the feature is on. + return messages.gettext('Custom DNS'); + case FeatureIndicator.customMtu: + return messages.pgettext('wireguard-settings-view', 'MTU'); + case FeatureIndicator.bridgeMode: + return messages.pgettext('openvpn-settings-view', 'Bridge mode'); + case FeatureIndicator.lanSharing: + return messages.pgettext('vpn-settings-view', 'Local network sharing'); + case FeatureIndicator.customMssFix: + return messages.pgettext('openvpn-settings-view', 'Mssfix'); + case FeatureIndicator.lockdownMode: + return messages.pgettext('vpn-settings-view', 'Lockdown mode'); + case FeatureIndicator.splitTunneling: + return strings.splitTunneling; + case FeatureIndicator.serverIpOverride: + return messages.pgettext('settings-import', 'Server IP override'); + case FeatureIndicator.quantumResistance: + // TRANSLATORS: This refers to the quantum resistance setting in the WireGuard settings view. + // TRANSLATORS: This is displayed when the feature is on. + return messages.gettext('Quantum resistance'); + case FeatureIndicator.dnsContentBlockers: + return messages.pgettext('vpn-settings-view', 'DNS content blockers'); + } +} diff --git a/gui/src/renderer/components/main-view/Hostname.tsx b/gui/src/renderer/components/main-view/Hostname.tsx new file mode 100644 index 0000000000..097e3b291b --- /dev/null +++ b/gui/src/renderer/components/main-view/Hostname.tsx @@ -0,0 +1,69 @@ +import { sprintf } from 'sprintf-js'; +import styled from 'styled-components'; + +import { colors } from '../../../config.json'; +import { messages } from '../../../shared/gettext'; +import { IConnectionReduxState } from '../../redux/connection/reducers'; +import { useSelector } from '../../redux/store'; +import { smallText } from '../common-styles'; +import Marquee from '../Marquee'; +import { ConnectionPanelAccordion } from './styles'; + +const StyledAccordion = styled(ConnectionPanelAccordion)({ + flexShrink: 0, +}); + +const StyledHostname = styled.span(smallText, { + color: colors.white60, + fontWeight: '400', + flexShrink: 0, + minHeight: '1em', +}); + +export default function Hostname() { + const tunnelState = useSelector((state) => state.connection.status.state); + const connection = useSelector((state) => state.connection); + const text = getHostnameText(connection); + + return ( + <StyledAccordion expanded={tunnelState === 'connecting' || tunnelState === 'connected'}> + <StyledHostname data-testid="hostname-line"> + <Marquee>{text}</Marquee> + </StyledHostname> + </StyledAccordion> + ); +} + +function getHostnameText(connection: IConnectionReduxState) { + let hostname = ''; + + if (connection.hostname && connection.bridgeHostname) { + hostname = sprintf(messages.pgettext('connection-info', '%(relay)s via %(entry)s'), { + relay: connection.hostname, + entry: connection.bridgeHostname, + }); + } else if (connection.hostname && connection.entryHostname) { + hostname = sprintf( + // TRANSLATORS: The hostname line displayed below the country on the main screen + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(relay)s - the relay hostname + // TRANSLATORS: %(entry)s - the entry relay hostname + messages.pgettext('connection-info', '%(relay)s via %(entry)s'), + { + relay: connection.hostname, + entry: connection.entryHostname, + }, + ); + } else if ( + (connection.status.state === 'connecting' || connection.status.state === 'connected') && + connection.status.details?.endpoint.proxy !== undefined + ) { + hostname = sprintf(messages.pgettext('connection-info', '%(relay)s via Custom bridge'), { + relay: connection.hostname, + }); + } else if (connection.hostname) { + hostname = connection.hostname; + } + + return hostname; +} diff --git a/gui/src/renderer/components/main-view/Location.tsx b/gui/src/renderer/components/main-view/Location.tsx new file mode 100644 index 0000000000..2685394158 --- /dev/null +++ b/gui/src/renderer/components/main-view/Location.tsx @@ -0,0 +1,41 @@ +import styled from 'styled-components'; + +import { colors } from '../../../config.json'; +import { TunnelState } from '../../../shared/daemon-rpc-types'; +import { useSelector } from '../../redux/store'; +import { largeText } from '../common-styles'; +import Marquee from '../Marquee'; +import { ConnectionPanelAccordion } from './styles'; + +const StyledLocation = styled.span(largeText, { + color: colors.white, + flexShrink: 0, +}); + +export default function Location() { + const connection = useSelector((state) => state.connection); + const text = getLocationText(connection.status, connection.country, connection.city); + + return ( + <ConnectionPanelAccordion expanded={connection.status.state !== 'error'}> + <StyledLocation> + <Marquee>{text}</Marquee> + </StyledLocation> + </ConnectionPanelAccordion> + ); +} + +function getLocationText(tunnelState: TunnelState, country?: string, city?: string): string { + country = country ?? ''; + + switch (tunnelState.state) { + case 'connected': + case 'connecting': + return city ? `${country}, ${city}` : country; + case 'disconnecting': + case 'disconnected': + return country; + case 'error': + return ''; + } +} diff --git a/gui/src/renderer/components/main-view/MainView.tsx b/gui/src/renderer/components/main-view/MainView.tsx new file mode 100644 index 0000000000..9327094a51 --- /dev/null +++ b/gui/src/renderer/components/main-view/MainView.tsx @@ -0,0 +1,67 @@ +import styled from 'styled-components'; + +import { useSelector } from '../../redux/store'; +import { calculateHeaderBarStyle, DefaultHeaderBar } from '../HeaderBar'; +import ImageView from '../ImageView'; +import { Container, Layout } from '../Layout'; +import Map from '../Map'; +import NotificationArea from '../NotificationArea'; +import ConnectionPanel from './ConnectionPanel'; + +const StyledContainer = styled(Container)({ + position: 'relative', +}); + +const Content = styled.div({ + display: 'flex', + flex: 1, + flexDirection: 'column', + position: 'relative', // need this for z-index to work to cover the map + zIndex: 1, + maxHeight: '100%', +}); + +const StatusIcon = styled(ImageView)({ + position: 'absolute', + alignSelf: 'center', + marginTop: 94, +}); + +const StyledNotificationArea = styled(NotificationArea)({ + position: 'absolute', + left: 0, + top: 0, + right: 0, +}); + +const StyledMain = styled.main({ + display: 'flex', + flexDirection: 'column', + flex: 1, + maxHeight: '100%', +}); + +export default function MainView() { + const connection = useSelector((state) => state.connection); + + const showSpinner = + connection.status.state === 'connecting' || connection.status.state === 'disconnecting'; + + return ( + <Layout> + <DefaultHeaderBar barStyle={calculateHeaderBarStyle(connection.status)} /> + <StyledContainer> + <Map /> + <Content> + <StyledNotificationArea /> + + <StyledMain> + {showSpinner ? <StatusIcon source="icon-spinner" height={60} width={60} /> : null} + + <ConnectionPanel /> + </StyledMain> + </Content> + </StyledContainer> + </Layout> + ); +} diff --git a/gui/src/renderer/components/Connect.tsx b/gui/src/renderer/components/main-view/SelectLocationButton.tsx index 1a557ffa3d..30b132a84e 100644 --- a/gui/src/renderer/components/Connect.tsx +++ b/gui/src/renderer/components/main-view/SelectLocationButton.tsx @@ -2,127 +2,66 @@ import { useCallback, useMemo } from 'react'; import { sprintf } from 'sprintf-js'; import styled from 'styled-components'; -import { ICustomList } from '../../shared/daemon-rpc-types'; -import { messages, relayLocations } from '../../shared/gettext'; -import log from '../../shared/logging'; -import { useAppContext } from '../context'; -import { transitions, useHistory } from '../lib/history'; -import { RoutePath } from '../lib/routes'; -import { IRelayLocationCountryRedux, 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 from './Map'; -import NotificationArea from './NotificationArea'; -import TunnelControl from './TunnelControl'; +import { ICustomList } from '../../../shared/daemon-rpc-types'; +import { messages, relayLocations } from '../../../shared/gettext'; +import log from '../../../shared/logging'; +import { useAppContext } from '../../context'; +import { transitions, useHistory } from '../../lib/history'; +import { RoutePath } from '../../lib/routes'; +import { IRelayLocationCountryRedux, RelaySettingsRedux } from '../../redux/settings/reducers'; +import { useSelector } from '../../redux/store'; +import ImageView from '../ImageView'; +import { MultiButton, MultiButtonCompatibleProps } from '../MultiButton'; +import { SmallButton, SmallButtonColor } from '../SmallButton'; -const StyledContainer = styled(Container)({ - position: 'relative', +const StyledSmallButton = styled(SmallButton)({ + margin: 0, }); -const Content = styled.div({ - display: 'flex', - flex: 1, - flexDirection: 'column', - position: 'relative', // need this for z-index to work to cover the map - zIndex: 1, +const StyledReconnectButton = styled(StyledSmallButton)({ + padding: '4px 8px 4px 8px', }); -const StatusIcon = styled(ImageView)({ - position: 'absolute', - alignSelf: 'center', - marginTop: 94, -}); - -const StyledNotificationArea = styled(NotificationArea)({ - position: 'absolute', - left: 0, - top: 0, - right: 0, -}); +export default function SelectLocationButtons() { + const tunnelState = useSelector((state) => state.connection.status.state); -const StyledMain = styled.main({ - display: 'flex', - flexDirection: 'column', - flex: 1, -}); + if (tunnelState === 'connecting' || tunnelState === 'connected') { + return <MultiButton mainButton={SelectLocationButton} sideButton={ReconnectButton} />; + } else { + return <SelectLocationButton />; + } +} -export default function Connect() { +function SelectLocationButton(props: MultiButtonCompatibleProps) { const history = useHistory(); - const { connectTunnel, disconnectTunnel, reconnectTunnel } = useAppContext(); - const connection = useSelector((state) => state.connection); - const blockWhenDisconnected = useSelector((state) => state.settings.blockWhenDisconnected); + const tunnelState = useSelector((state) => state.connection.status.state); const relaySettings = useSelector((state) => state.settings.relaySettings); const relayLocations = useSelector((state) => state.settings.relayLocations); const customLists = useSelector((state) => state.settings.customLists); - const showSpinner = - connection.status.state === 'connecting' || connection.status.state === 'disconnecting'; - - const onSelectLocation = useCallback(() => { - history.push(RoutePath.selectLocation, { transition: transitions.show }); - }, [history.push]); - const selectedRelayName = useMemo( () => getRelayName(relaySettings, customLists, relayLocations), [relaySettings, customLists, relayLocations], ); - const onConnect = useCallback(async () => { - try { - await connectTunnel(); - } catch (e) { - const error = e as Error; - log.error(`Failed to connect the tunnel: ${error.message}`); - } - }, []); - - const onDisconnect = useCallback(async () => { - try { - await disconnectTunnel(); - } catch (e) { - const error = e as Error; - log.error(`Failed to disconnect the tunnel: ${error.message}`); - } - }, []); - - const onReconnect = useCallback(async () => { - try { - await reconnectTunnel(); - } catch (e) { - const error = e as Error; - log.error(`Failed to reconnect the tunnel: ${error.message}`); - } - }, []); + const onSelectLocation = useCallback(() => { + history.push(RoutePath.selectLocation, { transition: transitions.show }); + }, [history.push]); return ( - <Layout> - <DefaultHeaderBar barStyle={calculateHeaderBarStyle(connection.status)} /> - <StyledContainer> - <Map /> - <Content> - <StyledNotificationArea /> - - <StyledMain> - {showSpinner ? <StatusIcon source="icon-spinner" height={60} width={60} /> : null} - - <TunnelControl - tunnelState={connection.status} - blockWhenDisconnected={blockWhenDisconnected} - selectedRelayName={selectedRelayName} - city={connection.city} - country={connection.country} - onConnect={onConnect} - onDisconnect={onDisconnect} - onReconnect={onReconnect} - onSelectLocation={onSelectLocation} - /> - </StyledMain> - </Content> - </StyledContainer> - </Layout> + <StyledSmallButton + color={SmallButtonColor.blue} + onClick={onSelectLocation} + aria-label={sprintf( + messages.pgettext('accessibility', 'Select location. Current location is %(location)s'), + { location: selectedRelayName }, + )} + {...props}> + {tunnelState === 'disconnected' + ? selectedRelayName + : messages.pgettext('tunnel-control', 'Switch location')} + </StyledSmallButton> ); } @@ -179,3 +118,26 @@ function getRelayName( throw new Error('Unsupported relay settings.'); } } + +function ReconnectButton(props: MultiButtonCompatibleProps) { + const { reconnectTunnel } = useAppContext(); + + const onReconnect = useCallback(async () => { + try { + await reconnectTunnel(); + } catch (e) { + const error = e as Error; + log.error(`Failed to reconnect the tunnel: ${error.message}`); + } + }, []); + + return ( + <StyledReconnectButton + color={SmallButtonColor.blue} + onClick={onReconnect} + aria-label={messages.gettext('Reconnect')} + {...props}> + <ImageView height={24} width={24} source="icon-reload" tintColor="white" /> + </StyledReconnectButton> + ); +} diff --git a/gui/src/renderer/components/main-view/styles.ts b/gui/src/renderer/components/main-view/styles.ts new file mode 100644 index 0000000000..58c6e85eb2 --- /dev/null +++ b/gui/src/renderer/components/main-view/styles.ts @@ -0,0 +1,7 @@ +import styled from 'styled-components'; + +import Accordion from '../Accordion'; + +export const ConnectionPanelAccordion = styled(Accordion)({ + transition: 'height 300ms ease-out', +}); diff --git a/gui/src/renderer/containers/ConnectionPanelContainer.tsx b/gui/src/renderer/containers/ConnectionPanelContainer.tsx deleted file mode 100644 index b12902bc5d..0000000000 --- a/gui/src/renderer/containers/ConnectionPanelContainer.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { connect } from 'react-redux'; -import { bindActionCreators } from 'redux'; - -import { ITunnelEndpoint, parseSocketAddress } from '../../shared/daemon-rpc-types'; -import ConnectionPanel, { - IBridgeData, - IInAddress, - IObfuscationData, - IOutAddress, -} from '../components/ConnectionPanel'; -import { IReduxState, ReduxDispatch } from '../redux/store'; -import userInterfaceActions from '../redux/userinterface/actions'; - -function tunnelEndpointToRelayInAddress(tunnelEndpoint: ITunnelEndpoint): IInAddress { - const socketAddr = parseSocketAddress(tunnelEndpoint.address); - return { - ip: socketAddr.host, - port: socketAddr.port, - protocol: tunnelEndpoint.protocol, - tunnelType: tunnelEndpoint.tunnelType, - }; -} - -function tunnelEndpointToEntryLocationInAddress( - tunnelEndpoint: ITunnelEndpoint, -): IInAddress | undefined { - if (!tunnelEndpoint.entryEndpoint) { - return undefined; - } - - const socketAddr = parseSocketAddress(tunnelEndpoint.entryEndpoint.address); - return { - ip: socketAddr.host, - port: socketAddr.port, - protocol: tunnelEndpoint.entryEndpoint.transportProtocol, - tunnelType: tunnelEndpoint.tunnelType, - }; -} - -function tunnelEndpointToBridgeData(endpoint: ITunnelEndpoint): IBridgeData | undefined { - if (!endpoint.proxy) { - return undefined; - } - - const socketAddr = parseSocketAddress(endpoint.proxy.address); - return { - ip: socketAddr.host, - port: socketAddr.port, - protocol: endpoint.proxy.protocol, - bridgeType: endpoint.proxy.proxyType, - }; -} - -function tunnelEndpointToObfuscationEndpoint( - endpoint: ITunnelEndpoint, -): IObfuscationData | undefined { - if (!endpoint.obfuscationEndpoint) { - return undefined; - } - - return { - ip: endpoint.obfuscationEndpoint.address, - port: endpoint.obfuscationEndpoint.port, - protocol: endpoint.obfuscationEndpoint.protocol, - obfuscationType: endpoint.obfuscationEndpoint.obfuscationType, - }; -} - -const mapStateToProps = (state: IReduxState) => { - const status = state.connection.status; - - const outAddress: IOutAddress = { - ipv4: state.connection.ipv4, - ipv6: state.connection.ipv6, - }; - - const inAddress: IInAddress | undefined = - (status.state === 'connecting' || status.state === 'connected') && status.details - ? tunnelEndpointToRelayInAddress(status.details.endpoint) - : undefined; - - const entryLocationInAddress: IInAddress | undefined = - (status.state === 'connecting' || status.state === 'connected') && status.details - ? tunnelEndpointToEntryLocationInAddress(status.details.endpoint) - : undefined; - - const bridgeInfo: IBridgeData | undefined = - (status.state === 'connecting' || status.state === 'connected') && status.details - ? tunnelEndpointToBridgeData(status.details.endpoint) - : undefined; - - const obfuscationEndpoint: IObfuscationData | undefined = - (status.state === 'connecting' || status.state === 'connected') && status.details - ? tunnelEndpointToObfuscationEndpoint(status.details.endpoint) - : undefined; - - const daita = - ((status.state === 'connected' || status.state === 'connecting') && - status.details?.endpoint.daita) ?? - false; - - return { - isOpen: state.userInterface.connectionPanelVisible, - hostname: state.connection.hostname, - bridgeHostname: state.connection.bridgeHostname, - entryHostname: state.connection.entryHostname, - inAddress, - entryLocationInAddress, - bridgeInfo, - outAddress, - obfuscationEndpoint, - daita, - }; -}; - -const mapDispatchToProps = (dispatch: ReduxDispatch) => { - const userInterface = bindActionCreators(userInterfaceActions, dispatch); - - return { - onToggle: userInterface.toggleConnectionPanel, - }; -}; - -export default connect(mapStateToProps, mapDispatchToProps)(ConnectionPanel); |
