summaryrefslogtreecommitdiffhomepage
path: root/gui/src/renderer/components/main-view
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/renderer/components/main-view
parent86da8428eae571b22dee94a99e5ae00812516f7e (diff)
downloadmullvadvpn-c9050739874b153d14efb9dfd363fd140278390f.tar.xz
mullvadvpn-c9050739874b153d14efb9dfd363fd140278390f.zip
Implement main view connection island
Diffstat (limited to 'gui/src/renderer/components/main-view')
-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.tsx143
-rw-r--r--gui/src/renderer/components/main-view/styles.ts7
11 files changed, 1048 insertions, 0 deletions
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/main-view/SelectLocationButton.tsx b/gui/src/renderer/components/main-view/SelectLocationButton.tsx
new file mode 100644
index 0000000000..30b132a84e
--- /dev/null
+++ b/gui/src/renderer/components/main-view/SelectLocationButton.tsx
@@ -0,0 +1,143 @@
+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 ImageView from '../ImageView';
+import { MultiButton, MultiButtonCompatibleProps } from '../MultiButton';
+import { SmallButton, SmallButtonColor } from '../SmallButton';
+
+const StyledSmallButton = styled(SmallButton)({
+ margin: 0,
+});
+
+const StyledReconnectButton = styled(StyledSmallButton)({
+ padding: '4px 8px 4px 8px',
+});
+
+export default function SelectLocationButtons() {
+ const tunnelState = useSelector((state) => state.connection.status.state);
+
+ if (tunnelState === 'connecting' || tunnelState === 'connected') {
+ return <MultiButton mainButton={SelectLocationButton} sideButton={ReconnectButton} />;
+ } else {
+ return <SelectLocationButton />;
+ }
+}
+
+function SelectLocationButton(props: MultiButtonCompatibleProps) {
+ const history = useHistory();
+
+ 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 selectedRelayName = useMemo(
+ () => getRelayName(relaySettings, customLists, relayLocations),
+ [relaySettings, customLists, relayLocations],
+ );
+
+ const onSelectLocation = useCallback(() => {
+ history.push(RoutePath.selectLocation, { transition: transitions.show });
+ }, [history.push]);
+
+ return (
+ <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>
+ );
+}
+
+function getRelayName(
+ relaySettings: RelaySettingsRedux,
+ customLists: Array<ICustomList>,
+ locations: IRelayLocationCountryRedux[],
+): string {
+ if ('normal' in relaySettings) {
+ const location = relaySettings.normal.location;
+
+ if (location === 'any') {
+ return 'Automatic';
+ } else if ('customList' in location) {
+ return customLists.find((list) => list.id === location.customList)?.name ?? 'Unknown';
+ } else if ('hostname' in location) {
+ const country = locations.find(({ code }) => code === location.country);
+ if (country) {
+ const city = country.cities.find(({ code }) => code === location.city);
+ if (city) {
+ return sprintf(
+ // TRANSLATORS: The selected location label displayed on the main view, when a user selected a specific host to connect to.
+ // TRANSLATORS: Example: Malmö (se-mma-001)
+ // TRANSLATORS: Available placeholders:
+ // TRANSLATORS: %(city)s - a city name
+ // TRANSLATORS: %(hostname)s - a hostname
+ messages.pgettext('connect-container', '%(city)s (%(hostname)s)'),
+ {
+ city: relayLocations.gettext(city.name),
+ hostname: location.hostname,
+ },
+ );
+ }
+ }
+ } else if ('city' in location) {
+ const country = locations.find(({ code }) => code === location.country);
+ if (country) {
+ const city = country.cities.find(({ code }) => code === location.city);
+ if (city) {
+ return relayLocations.gettext(city.name);
+ }
+ }
+ } else if ('country' in location) {
+ const country = locations.find(({ code }) => code === location.country);
+ if (country) {
+ return relayLocations.gettext(country.name);
+ }
+ }
+
+ return 'Unknown';
+ } else if (relaySettings.customTunnelEndpoint) {
+ return 'Custom';
+ } else {
+ 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',
+});