summaryrefslogtreecommitdiffhomepage
path: root/gui/src
diff options
context:
space:
mode:
authorOskar <oskar@mullvad.net>2024-08-16 13:45:54 +0200
committerOskar <oskar@mullvad.net>2024-08-21 17:05:50 +0200
commitc9050739874b153d14efb9dfd363fd140278390f (patch)
treecbc6ee293fa7368c6812dbdc1276ef92ce4a9b0f /gui/src
parent86da8428eae571b22dee94a99e5ae00812516f7e (diff)
downloadmullvadvpn-c9050739874b153d14efb9dfd363fd140278390f.tar.xz
mullvadvpn-c9050739874b153d14efb9dfd363fd140278390f.zip
Implement main view connection island
Diffstat (limited to 'gui/src')
-rw-r--r--gui/src/renderer/components/AppRouter.tsx4
-rw-r--r--gui/src/renderer/components/ConnectionPanel.tsx227
-rw-r--r--gui/src/renderer/components/ConnectionPanelDisclosure.tsx49
-rw-r--r--gui/src/renderer/components/TunnelControl.tsx305
-rw-r--r--gui/src/renderer/components/main-view/ConnectionActionButton.tsx63
-rw-r--r--gui/src/renderer/components/main-view/ConnectionDetails.tsx202
-rw-r--r--gui/src/renderer/components/main-view/ConnectionPanel.tsx113
-rw-r--r--gui/src/renderer/components/main-view/ConnectionPanelChevron.tsx37
-rw-r--r--gui/src/renderer/components/main-view/ConnectionStatus.tsx58
-rw-r--r--gui/src/renderer/components/main-view/FeatureIndicators.tsx248
-rw-r--r--gui/src/renderer/components/main-view/Hostname.tsx69
-rw-r--r--gui/src/renderer/components/main-view/Location.tsx41
-rw-r--r--gui/src/renderer/components/main-view/MainView.tsx67
-rw-r--r--gui/src/renderer/components/main-view/SelectLocationButton.tsx (renamed from gui/src/renderer/components/Connect.tsx)164
-rw-r--r--gui/src/renderer/components/main-view/styles.ts7
-rw-r--r--gui/src/renderer/containers/ConnectionPanelContainer.tsx124
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);