summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2021-08-25 09:11:00 +0200
committerOskar Nyberg <oskar@mullvad.net>2021-08-30 14:28:36 +0200
commitb720305a57eb1c6d4f7eb4d4ccd58dd2990bd2d6 (patch)
treec9ab60f01d9c03813d6675cc621536cee9ed20e9
parent35ad04d1188ac2f618f252bd6edaeb523e0245bc (diff)
downloadmullvadvpn-b720305a57eb1c6d4f7eb4d4ccd58dd2990bd2d6.tar.xz
mullvadvpn-b720305a57eb1c6d4f7eb4d4ccd58dd2990bd2d6.zip
Refactor custom DNS and make editable
-rw-r--r--gui/src/renderer/components/AdvancedSettings.tsx243
-rw-r--r--gui/src/renderer/components/AdvancedSettingsStyles.tsx26
-rw-r--r--gui/src/renderer/components/CustomDnsSettings.tsx382
-rw-r--r--gui/src/renderer/components/CustomDnsSettingsStyles.tsx69
-rw-r--r--gui/src/renderer/components/cell/Input.tsx17
-rw-r--r--gui/src/renderer/components/cell/List.tsx123
-rw-r--r--gui/src/renderer/containers/AdvancedSettingsPage.tsx7
-rw-r--r--gui/src/renderer/lib/utilityHooks.ts12
8 files changed, 479 insertions, 400 deletions
diff --git a/gui/src/renderer/components/AdvancedSettings.tsx b/gui/src/renderer/components/AdvancedSettings.tsx
index 142477d951..e3d9134979 100644
--- a/gui/src/renderer/components/AdvancedSettings.tsx
+++ b/gui/src/renderer/components/AdvancedSettings.tsx
@@ -1,9 +1,7 @@
import * as React from 'react';
import { sprintf } from 'sprintf-js';
-import { colors } from '../../config.json';
-import { IDnsOptions, TunnelProtocol } from '../../shared/daemon-rpc-types';
+import { TunnelProtocol } from '../../shared/daemon-rpc-types';
import { messages } from '../../shared/gettext';
-import { IpAddress } from '../lib/ip';
import { WgKeyState } from '../redux/settings/reducers';
import {
StyledNavigationScrollbars,
@@ -11,15 +9,11 @@ import {
StyledNoWireguardKeyErrorContainer,
StyledSelectorForFooter,
StyledTunnelProtocolContainer,
- StyledCustomDnsSwitchContainer,
- StyledCustomDnsFooter,
- StyledAddCustomDnsLabel,
- StyledAddCustomDnsButton,
} from './AdvancedSettingsStyles';
import * as AppButton from './AppButton';
import { AriaDescription, AriaInput, AriaInputGroup, AriaLabel } from './AriaGroup';
import * as Cell from './cell';
-import CellList, { ICellListItem } from './cell/List';
+import CustomDnsSettings from './CustomDnsSettings';
import { Layout, SettingsContainer } from './Layout';
import { ModalAlert, ModalAlertType, ModalContainer, ModalMessage } from './Modal';
import {
@@ -31,8 +25,6 @@ import {
} from './NavigationBar';
import { ISelectorItem } from './cell/Selector';
import SettingsHeader, { HeaderTitle } from './SettingsHeader';
-import Accordion from './Accordion';
-import { formatMarkdown } from '../markdown-formatter';
type OptionalTunnelProtocol = TunnelProtocol | undefined;
@@ -41,11 +33,9 @@ interface IProps {
blockWhenDisconnected: boolean;
tunnelProtocol?: TunnelProtocol;
wireguardKeyState: WgKeyState;
- dns: IDnsOptions;
setEnableIpv6: (value: boolean) => void;
setBlockWhenDisconnected: (value: boolean) => void;
setTunnelProtocol: (value: OptionalTunnelProtocol) => void;
- setDnsOptions: (dns: IDnsOptions) => Promise<void>;
onViewWireguardSettings: () => void;
onViewOpenVpnSettings: () => void;
onViewSplitTunneling: () => void;
@@ -54,23 +44,13 @@ interface IProps {
interface IState {
showConfirmBlockWhenDisconnectedAlert: boolean;
- showAddCustomDns: boolean;
- invalidDnsIp: boolean;
- publicDnsIpToConfirm?: string;
}
export default class AdvancedSettings extends React.Component<IProps, IState> {
public state = {
showConfirmBlockWhenDisconnectedAlert: false,
- showAddCustomDns: false,
- invalidDnsIp: false,
- publicDnsIpToConfirm: undefined,
};
- private customDnsSwitchRef = React.createRef<HTMLDivElement>();
- private customDnsAddButtonRef = React.createRef<HTMLButtonElement>();
- private customDnsInputContainerRef = React.createRef<HTMLDivElement>();
-
public render() {
const hasWireguardKey = this.props.wireguardKeyState.type === 'key-set';
@@ -209,74 +189,7 @@ export default class AdvancedSettings extends React.Component<IProps, IState> {
</Cell.CellButton>
</Cell.CellButtonGroup>
- <StyledCustomDnsSwitchContainer disabled={!this.customDnsAvailable()}>
- <AriaInputGroup>
- <AriaLabel>
- <Cell.InputLabel>
- {messages.pgettext('advanced-settings-view', 'Use custom DNS server')}
- </Cell.InputLabel>
- </AriaLabel>
- <AriaInput>
- <Cell.Switch
- ref={this.customDnsSwitchRef}
- isOn={this.props.dns.state === 'custom' || this.state.showAddCustomDns}
- onChange={this.setCustomDnsEnabled}
- />
- </AriaInput>
- </AriaInputGroup>
- </StyledCustomDnsSwitchContainer>
- <Accordion
- expanded={
- this.customDnsAvailable() &&
- (this.props.dns.state === 'custom' || this.state.showAddCustomDns)
- }>
- <CellList items={this.customDnsItems()} onRemove={this.removeDnsAddress} />
-
- {this.state.showAddCustomDns && (
- <div ref={this.customDnsInputContainerRef}>
- <Cell.RowInput
- placeholder={messages.pgettext('advanced-settings-view', 'Enter IP')}
- onSubmit={this.addDnsAddress}
- onChange={this.addDnsInputChange}
- invalid={this.state.invalidDnsIp}
- paddingLeft={32}
- onBlur={this.customDnsInputBlur}
- autofocus
- />
- </div>
- )}
-
- <StyledAddCustomDnsButton
- ref={this.customDnsAddButtonRef}
- onClick={this.showAddCustomDnsRow}
- disabled={this.state.showAddCustomDns}
- tabIndex={-1}>
- <StyledAddCustomDnsLabel tabIndex={-1}>
- {messages.pgettext('advanced-settings-view', 'Add a server')}
- </StyledAddCustomDnsLabel>
- <Cell.Icon
- source="icon-add"
- width={22}
- height={22}
- tintColor={colors.white40}
- tintHoverColor={colors.white60}
- tabIndex={-1}
- />
- </StyledAddCustomDnsButton>
- </Accordion>
-
- <StyledCustomDnsFooter>
- <Cell.FooterText>
- {this.customDnsAvailable() ? (
- messages.pgettext(
- 'advanced-settings-view',
- 'Enable to add at least one DNS server.',
- )
- ) : (
- <CustomDnsDisabledMessage />
- )}
- </Cell.FooterText>
- </StyledCustomDnsFooter>
+ <CustomDnsSettings />
</StyledNavigationScrollbars>
</NavigationContainer>
</SettingsContainer>
@@ -284,114 +197,10 @@ export default class AdvancedSettings extends React.Component<IProps, IState> {
{this.state.showConfirmBlockWhenDisconnectedAlert &&
this.renderConfirmBlockWhenDisconnectedAlert()}
- {this.state.publicDnsIpToConfirm && this.renderCustomDnsConfirmationDialog()}
</ModalContainer>
);
}
- private customDnsAvailable(): boolean {
- return (
- this.props.dns.state === 'custom' ||
- (!this.props.dns.defaultOptions.blockAds && !this.props.dns.defaultOptions.blockTrackers)
- );
- }
-
- private setCustomDnsEnabled = async (enabled: boolean) => {
- if (this.props.dns.customOptions.addresses.length > 0) {
- await this.props.setDnsOptions({
- ...this.props.dns,
- state: enabled ? 'custom' : 'default',
- });
- }
-
- if (enabled && this.props.dns.customOptions.addresses.length === 0) {
- this.showAddCustomDnsRow();
- }
-
- if (!enabled) {
- this.setState({ showAddCustomDns: false });
- }
- };
-
- private customDnsItems(): ICellListItem<string>[] {
- return this.props.dns.customOptions.addresses.map((address) => ({
- label: address,
- value: address,
- }));
- }
-
- private showAddCustomDnsRow = () => {
- this.setState({ showAddCustomDns: true });
- };
-
- // The input field should be hidden when it loses focus unless something on the same row or the
- // add-button is the new focused element.
- private customDnsInputBlur = (event?: React.FocusEvent<HTMLTextAreaElement>) => {
- const relatedTarget = event?.relatedTarget as Node | undefined;
- if (
- relatedTarget &&
- (this.customDnsSwitchRef.current?.contains(relatedTarget) ||
- this.customDnsAddButtonRef.current?.contains(relatedTarget) ||
- this.customDnsInputContainerRef.current?.contains(relatedTarget))
- ) {
- event?.target.focus();
- } else {
- this.hideAddCustomDnsRow();
- }
- };
-
- private hideAddCustomDnsRow() {
- if (!this.state.publicDnsIpToConfirm) {
- this.setState({ showAddCustomDns: false });
- }
- }
-
- private addDnsInputChange = (_value: string) => {
- this.setState({ invalidDnsIp: false });
- };
-
- private hideCustomDnsConfirmationDialog = () => {
- this.setState({ publicDnsIpToConfirm: undefined });
- };
-
- private confirmPublicDnsAddress = () => {
- void this.addDnsAddress(this.state.publicDnsIpToConfirm!, true);
- this.hideCustomDnsConfirmationDialog();
- };
-
- private addDnsAddress = async (address: string, confirmed?: boolean) => {
- try {
- const ipAddress = IpAddress.fromString(address);
-
- if (ipAddress.isLocal() || confirmed) {
- await this.props.setDnsOptions({
- ...this.props.dns,
- state:
- this.props.dns.state === 'custom' || this.state.showAddCustomDns ? 'custom' : 'default',
- customOptions: {
- addresses: [...this.props.dns.customOptions.addresses, address],
- },
- });
- this.hideAddCustomDnsRow();
- } else {
- this.setState({ publicDnsIpToConfirm: address });
- }
- } catch (e) {
- this.setState({ invalidDnsIp: true });
- }
- };
-
- private removeDnsAddress = (address: string) => {
- const addresses = this.props.dns.customOptions.addresses.filter((item) => item !== address);
- void this.props.setDnsOptions({
- ...this.props.dns,
- state: addresses.length > 0 && this.props.dns.state === 'custom' ? 'custom' : 'default',
- customOptions: {
- addresses,
- },
- });
- };
-
private tunnelProtocolItems = (
hasWireguardKey: boolean,
): Array<ISelectorItem<OptionalTunnelProtocol>> => {
@@ -417,26 +226,6 @@ export default class AdvancedSettings extends React.Component<IProps, IState> {
];
};
- private renderCustomDnsConfirmationDialog = () => {
- return (
- <ModalAlert
- type={ModalAlertType.info}
- buttons={[
- <AppButton.RedButton key="confirm" onClick={this.confirmPublicDnsAddress}>
- {messages.pgettext('advanced-settings-view', 'Add anyway')}
- </AppButton.RedButton>,
- <AppButton.BlueButton key="back" onClick={this.hideCustomDnsConfirmationDialog}>
- {messages.gettext('Back')}
- </AppButton.BlueButton>,
- ]}
- close={this.hideCustomDnsConfirmationDialog}
- message={messages.pgettext(
- 'advanced-settings-view',
- 'The DNS server you want to add is public and will only work with WireGuard. To ensure that it always works, set the "Tunnel protocol" (in Advanced settings) to WireGuard.',
- )}></ModalAlert>
- );
- };
-
private renderConfirmBlockWhenDisconnectedAlert = () => {
return (
<ModalAlert
@@ -489,29 +278,3 @@ export default class AdvancedSettings extends React.Component<IProps, IState> {
this.props.setTunnelProtocol(protocol);
};
}
-
-function CustomDnsDisabledMessage() {
- const blockAdsFeatureName = messages.pgettext('preferences-view', 'Block ads');
- const blockTrackersFeatureName = messages.pgettext('preferences-view', 'Block trackers');
- const preferencesPageName = messages.pgettext('preferences-nav', 'Preferences');
-
- // TRANSLATORS: This is displayed when either or both of the block ads/trackers settings are
- // TRANSLATORS: turned on which makes the custom DNS setting disabled. The text enclosed in "**"
- // TRANSLATORS: will appear bold.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(blockAdsFeatureName)s - The name displayed next to the "Block ads" toggle.
- // TRANSLATORS: %(blockTrackersFeatureName)s - The name displayed next to the "Block trackers" toggle.
- // TRANSLATORS: %(preferencesPageName)s - The page title showed on top in the preferences page.
- const customDnsDisabledMessage = messages.pgettext(
- 'preferences-view',
- 'Disable **%(blockAdsFeatureName)s** and **%(blockTrackersFeatureName)s** (under %(preferencesPageName)s) to activate this setting.',
- );
-
- return formatMarkdown(
- sprintf(customDnsDisabledMessage, {
- blockAdsFeatureName,
- blockTrackersFeatureName,
- preferencesPageName,
- }),
- );
-}
diff --git a/gui/src/renderer/components/AdvancedSettingsStyles.tsx b/gui/src/renderer/components/AdvancedSettingsStyles.tsx
index cb4ffe3425..c3b9a140d4 100644
--- a/gui/src/renderer/components/AdvancedSettingsStyles.tsx
+++ b/gui/src/renderer/components/AdvancedSettingsStyles.tsx
@@ -32,29 +32,3 @@ export const StyledNoWireguardKeyError = styled(Cell.FooterText)({
fontWeight: 800,
color: colors.red,
});
-
-export const StyledCustomDnsSwitchContainer = styled(Cell.Container)({
- marginBottom: '1px',
-});
-
-export const StyledCustomDnsFooter = styled(Cell.Footer)({
- marginBottom: '2px',
-});
-
-export const StyledAddCustomDnsButton = styled(Cell.CellButton)({
- backgroundColor: colors.blue40,
-});
-
-export const StyledAddCustomDnsLabel = styled(Cell.Label)(
- {},
- (props: { paddingLeft?: number }) => ({
- fontFamily: 'Open Sans',
- fontWeight: 'normal',
- fontSize: '16px',
- paddingLeft: (props.paddingLeft ?? 32) + 'px',
- whiteSpace: 'pre-wrap',
- overflowWrap: 'break-word',
- width: '171px',
- marginRight: '25px',
- }),
-);
diff --git a/gui/src/renderer/components/CustomDnsSettings.tsx b/gui/src/renderer/components/CustomDnsSettings.tsx
new file mode 100644
index 0000000000..9c8b952ec8
--- /dev/null
+++ b/gui/src/renderer/components/CustomDnsSettings.tsx
@@ -0,0 +1,382 @@
+import React, { useCallback, useMemo, useRef, useState } from 'react';
+import { sprintf } from 'sprintf-js';
+import { colors } from '../../config.json';
+import { messages } from '../../shared/gettext';
+import { useAppContext } from '../context';
+import { IpAddress } from '../lib/ip';
+import { useBoolean } from '../lib/utilityHooks';
+import { formatMarkdown } from '../markdown-formatter';
+import { useSelector } from '../redux/store';
+import Accordion from './Accordion';
+import * as AppButton from './AppButton';
+import {
+ AriaDescribed,
+ AriaDescription,
+ AriaDescriptionGroup,
+ AriaInput,
+ AriaInputGroup,
+ AriaLabel,
+} from './AriaGroup';
+import * as Cell from './cell';
+import {
+ StyledAddCustomDnsButton,
+ StyledAddCustomDnsLabel,
+ StyledButton,
+ StyledContainer,
+ StyledCustomDnsFooter,
+ StyledCustomDnsSwitchContainer,
+ StyledLabel,
+ StyledRemoveButton,
+ StyledRemoveIcon,
+} from './CustomDnsSettingsStyles';
+import { ModalAlert, ModalAlertType } from './Modal';
+
+export default function CustomDnsSettings() {
+ const { setDnsOptions } = useAppContext();
+ const dns = useSelector((state) => state.settings.dns);
+
+ const [inputVisible, showInput, hideInput] = useBoolean(false);
+ const [invalid, setInvalid, setValid] = useBoolean(false);
+ const [confirmAction, setConfirmAction] = useState<() => Promise<void>>();
+ const willShowConfirmationDialog = useRef(false);
+
+ const featureAvailable = useMemo(
+ () =>
+ dns.state === 'custom' || (!dns.defaultOptions.blockAds && !dns.defaultOptions.blockTrackers),
+ [dns],
+ );
+
+ const switchRef = useRef() as React.RefObject<HTMLDivElement>;
+ const addButtonRef = useRef() as React.RefObject<HTMLButtonElement>;
+ const inputContainerRef = useRef() as React.RefObject<HTMLDivElement>;
+
+ const confirm = useCallback(() => {
+ confirmAction?.();
+ setConfirmAction(undefined);
+ }, [confirmAction]);
+ const abortConfirmation = useCallback(() => {
+ setConfirmAction(undefined);
+ }, [confirmAction]);
+
+ const setCustomDnsEnabled = useCallback(async (enabled: boolean) => {
+ if (dns.customOptions.addresses.length > 0) {
+ await setDnsOptions({ ...dns, state: enabled ? 'custom' : 'default' });
+ }
+ if (enabled && dns.customOptions.addresses.length === 0) {
+ showInput();
+ }
+ if (!enabled) {
+ hideInput();
+ }
+ }, []);
+
+ // The input field should be hidden when it loses focus unless something on the same row or the
+ // add-button is the new focused element.
+ const onInputBlur = useCallback(
+ (event?: React.FocusEvent<HTMLTextAreaElement>) => {
+ const relatedTarget = event?.relatedTarget as Node | undefined;
+ if (
+ relatedTarget &&
+ (switchRef.current?.contains(relatedTarget) ||
+ addButtonRef.current?.contains(relatedTarget) ||
+ inputContainerRef.current?.contains(relatedTarget))
+ ) {
+ event?.target.focus();
+ } else if (!willShowConfirmationDialog.current) {
+ hideInput();
+ }
+ },
+ [confirmAction, willShowConfirmationDialog],
+ );
+
+ const onAdd = useCallback(
+ async (address: string) => {
+ const add = async () => {
+ await setDnsOptions({
+ ...dns,
+ state: dns.state === 'custom' || inputVisible ? 'custom' : 'default',
+ customOptions: {
+ addresses: [...dns.customOptions.addresses, address],
+ },
+ });
+
+ hideInput();
+ };
+
+ try {
+ const ipAddress = IpAddress.fromString(address);
+ if (ipAddress.isLocal()) {
+ await add();
+ } else {
+ willShowConfirmationDialog.current = true;
+ setConfirmAction(() => async () => {
+ willShowConfirmationDialog.current = false;
+ await add();
+ });
+ }
+ } catch {
+ setInvalid();
+ }
+ },
+ [inputVisible, dns, setDnsOptions],
+ );
+
+ const onEdit = useCallback(
+ (oldAddress: string, newAddress: string) => {
+ const edit = async () => {
+ const addresses = dns.customOptions.addresses.map((address) =>
+ oldAddress === address ? newAddress : address,
+ );
+ await setDnsOptions({
+ ...dns,
+ customOptions: {
+ addresses,
+ },
+ });
+ };
+
+ const ipAddress = IpAddress.fromString(newAddress);
+ return new Promise<void>((resolve) => {
+ if (ipAddress.isLocal()) {
+ void edit().then(resolve);
+ } else {
+ willShowConfirmationDialog.current = true;
+ setConfirmAction(() => async () => {
+ willShowConfirmationDialog.current = false;
+ await edit();
+ resolve();
+ });
+ }
+ });
+ },
+ [dns, setDnsOptions],
+ );
+
+ const onRemove = useCallback(
+ (address: string) => {
+ const addresses = dns.customOptions.addresses.filter((item) => item !== address);
+ void setDnsOptions({
+ ...dns,
+ state: addresses.length > 0 && dns.state === 'custom' ? 'custom' : 'default',
+ customOptions: {
+ addresses,
+ },
+ });
+ },
+ [dns, setDnsOptions],
+ );
+
+ return (
+ <>
+ <StyledCustomDnsSwitchContainer disabled={!featureAvailable}>
+ <AriaInputGroup>
+ <AriaLabel>
+ <Cell.InputLabel>
+ {messages.pgettext('advanced-settings-view', 'Use custom DNS server')}
+ </Cell.InputLabel>
+ </AriaLabel>
+ <AriaInput>
+ <Cell.Switch
+ ref={switchRef}
+ isOn={dns.state === 'custom' || inputVisible}
+ onChange={setCustomDnsEnabled}
+ />
+ </AriaInput>
+ </AriaInputGroup>
+ </StyledCustomDnsSwitchContainer>
+ <Accordion expanded={featureAvailable && (dns.state === 'custom' || inputVisible)}>
+ <Cell.Section role="listbox">
+ {dns.customOptions.addresses.map((item, i) => {
+ return (
+ <CellListItem
+ key={i}
+ onRemove={onRemove}
+ onChange={onEdit}
+ willShowConfirmationDialog={willShowConfirmationDialog}>
+ {item}
+ </CellListItem>
+ );
+ })}
+ </Cell.Section>
+
+ {inputVisible && (
+ <div ref={inputContainerRef}>
+ <Cell.RowInput
+ placeholder={messages.pgettext('advanced-settings-view', 'Enter IP')}
+ onSubmit={onAdd}
+ onChange={setValid}
+ invalid={invalid}
+ paddingLeft={32}
+ onBlur={onInputBlur}
+ autofocus
+ />
+ </div>
+ )}
+
+ <StyledAddCustomDnsButton
+ ref={addButtonRef}
+ onClick={showInput}
+ disabled={inputVisible}
+ tabIndex={-1}>
+ <StyledAddCustomDnsLabel tabIndex={-1}>
+ {messages.pgettext('advanced-settings-view', 'Add a server')}
+ </StyledAddCustomDnsLabel>
+ <Cell.Icon
+ source="icon-add"
+ width={22}
+ height={22}
+ tintColor={colors.white40}
+ tintHoverColor={colors.white60}
+ tabIndex={-1}
+ />
+ </StyledAddCustomDnsButton>
+ </Accordion>
+
+ <StyledCustomDnsFooter>
+ <Cell.FooterText>
+ {featureAvailable ? (
+ messages.pgettext('advanced-settings-view', 'Enable to add at least one DNS server.')
+ ) : (
+ <DisabledMessage />
+ )}
+ </Cell.FooterText>
+ </StyledCustomDnsFooter>
+
+ {confirmAction && <ConfirmationDialog confirm={confirm} abort={abortConfirmation} />}
+ </>
+ );
+}
+
+function DisabledMessage() {
+ const blockAdsFeatureName = messages.pgettext('preferences-view', 'Block ads');
+ const blockTrackersFeatureName = messages.pgettext('preferences-view', 'Block trackers');
+ const preferencesPageName = messages.pgettext('preferences-nav', 'Preferences');
+
+ // TRANSLATORS: This is displayed when either or both of the block ads/trackers settings are
+ // TRANSLATORS: turned on which makes the custom DNS setting disabled. The text enclosed in "**"
+ // TRANSLATORS: will appear bold.
+ // TRANSLATORS: Available placeholders:
+ // TRANSLATORS: %(blockAdsFeatureName)s - The name displayed next to the "Block ads" toggle.
+ // TRANSLATORS: %(blockTrackersFeatureName)s - The name displayed next to the "Block trackers" toggle.
+ // TRANSLATORS: %(preferencesPageName)s - The page title showed on top in the preferences page.
+ const customDnsDisabledMessage = messages.pgettext(
+ 'preferences-view',
+ 'Disable **%(blockAdsFeatureName)s** and **%(blockTrackersFeatureName)s** (under %(preferencesPageName)s) to activate this setting.',
+ );
+
+ return formatMarkdown(
+ sprintf(customDnsDisabledMessage, {
+ blockAdsFeatureName,
+ blockTrackersFeatureName,
+ preferencesPageName,
+ }),
+ );
+}
+
+interface ICellListItemProps {
+ willShowConfirmationDialog: React.RefObject<boolean>;
+ onRemove: (application: string) => void;
+ onChange: (value: string, newValue: string) => Promise<void>;
+ children: string;
+}
+
+function CellListItem(props: ICellListItemProps) {
+ const [editing, startEditing, stopEditing] = useBoolean(false);
+ const [invalid, setInvalid, setValid] = useBoolean(false);
+
+ const inputContainerRef = useRef() as React.RefObject<HTMLDivElement>;
+
+ const onRemove = useCallback(() => props.onRemove(props.children), [
+ props.onRemove,
+ props.children,
+ ]);
+
+ const onSubmit = useCallback(
+ async (value: string) => {
+ if (value === props.children) {
+ stopEditing();
+ } else {
+ try {
+ await props.onChange(props.children, value);
+ stopEditing();
+ } catch {
+ setInvalid();
+ }
+ }
+ },
+ [props.onChange, props.children, invalid],
+ );
+
+ const onBlur = useCallback((event?: React.FocusEvent<HTMLTextAreaElement>) => {
+ const relatedTarget = event?.relatedTarget as Node | undefined;
+ if (relatedTarget && inputContainerRef.current?.contains(relatedTarget)) {
+ event?.target.focus();
+ } else if (!props.willShowConfirmationDialog.current) {
+ stopEditing();
+ }
+ }, []);
+
+ return (
+ <AriaDescriptionGroup>
+ {editing ? (
+ <div ref={inputContainerRef}>
+ <Cell.RowInput
+ initialValue={props.children}
+ placeholder={messages.pgettext('advanced-settings-view', 'Enter IP')}
+ onSubmit={onSubmit}
+ onChange={setValid}
+ invalid={invalid}
+ paddingLeft={32}
+ onBlur={onBlur}
+ autofocus
+ />
+ </div>
+ ) : (
+ <StyledContainer>
+ <StyledButton onClick={startEditing}>
+ <AriaDescription>
+ <StyledLabel>{props.children}</StyledLabel>
+ </AriaDescription>
+ </StyledButton>
+ <AriaDescribed>
+ <StyledRemoveButton
+ onClick={onRemove}
+ aria-label={messages.pgettext('accessibility', 'Remove item')}>
+ <StyledRemoveIcon
+ source="icon-close"
+ width={22}
+ height={22}
+ tintColor={editing ? colors.black : colors.white40}
+ />
+ </StyledRemoveButton>
+ </AriaDescribed>
+ </StyledContainer>
+ )}
+ </AriaDescriptionGroup>
+ );
+}
+
+interface IConfirmationDialogProps {
+ confirm: () => void;
+ abort: () => void;
+}
+
+function ConfirmationDialog(props: IConfirmationDialogProps) {
+ return (
+ <ModalAlert
+ type={ModalAlertType.info}
+ buttons={[
+ <AppButton.RedButton key="confirm" onClick={props.confirm}>
+ {messages.pgettext('advanced-settings-view', 'Add anyway')}
+ </AppButton.RedButton>,
+ <AppButton.BlueButton key="back" onClick={props.abort}>
+ {messages.gettext('Back')}
+ </AppButton.BlueButton>,
+ ]}
+ close={props.abort}
+ message={messages.pgettext(
+ 'advanced-settings-view',
+ 'The DNS server you want to add is public and will only work with WireGuard. To ensure that it always works, set the "Tunnel protocol" (in Advanced settings) to WireGuard.',
+ )}></ModalAlert>
+ );
+}
diff --git a/gui/src/renderer/components/CustomDnsSettingsStyles.tsx b/gui/src/renderer/components/CustomDnsSettingsStyles.tsx
new file mode 100644
index 0000000000..55526681a7
--- /dev/null
+++ b/gui/src/renderer/components/CustomDnsSettingsStyles.tsx
@@ -0,0 +1,69 @@
+import styled from 'styled-components';
+import { colors } from '../../config.json';
+import * as Cell from './cell';
+import ImageView from './ImageView';
+
+export const StyledCustomDnsSwitchContainer = styled(Cell.Container)({
+ marginBottom: '1px',
+});
+
+export const StyledCustomDnsFooter = styled(Cell.Footer)({
+ marginBottom: '2px',
+});
+
+export const StyledAddCustomDnsButton = styled(Cell.CellButton)({
+ backgroundColor: colors.blue40,
+});
+
+export const StyledAddCustomDnsLabel = styled(Cell.Label)(
+ {},
+ (props: { paddingLeft?: number }) => ({
+ fontFamily: 'Open Sans',
+ fontWeight: 'normal',
+ fontSize: '16px',
+ paddingLeft: (props.paddingLeft ?? 32) + 'px',
+ whiteSpace: 'pre-wrap',
+ overflowWrap: 'break-word',
+ width: '171px',
+ marginRight: '25px',
+ }),
+);
+
+export const StyledContainer = styled(Cell.Container)({
+ display: 'flex',
+ marginBottom: '1px',
+ backgroundColor: colors.blue40,
+});
+
+export const StyledButton = styled.button({
+ display: 'flex',
+ alignItems: 'center',
+ flex: 1,
+ border: 'none',
+ background: 'transparent',
+ padding: 0,
+ margin: 0,
+});
+
+export const StyledLabel = styled(Cell.Label)({
+ fontFamily: 'Open Sans',
+ fontWeight: 'normal',
+ fontSize: '16px',
+ paddingLeft: '32px',
+ whiteSpace: 'pre-wrap',
+ overflowWrap: 'break-word',
+ width: '171px',
+ marginRight: '25px',
+});
+
+export const StyledRemoveButton = styled.button({
+ background: 'transparent',
+ border: 'none',
+ padding: 0,
+});
+
+export const StyledRemoveIcon = styled(ImageView)({
+ [StyledRemoveButton + ':hover &']: {
+ backgroundColor: colors.white80,
+ },
+});
diff --git a/gui/src/renderer/components/cell/Input.tsx b/gui/src/renderer/components/cell/Input.tsx
index 06553be624..1dd240b3ce 100644
--- a/gui/src/renderer/components/cell/Input.tsx
+++ b/gui/src/renderer/components/cell/Input.tsx
@@ -241,6 +241,7 @@ const StyledInputFiller = styled.div({
});
interface IRowInputProps {
+ initialValue?: string;
onChange?: (value: string) => void;
onSubmit: (value: string) => void;
onFocus?: (event: React.FocusEvent<HTMLTextAreaElement>) => void;
@@ -252,7 +253,7 @@ interface IRowInputProps {
}
export function RowInput(props: IRowInputProps) {
- const [value, setValue] = useState('');
+ const [value, setValue] = useState(props.initialValue ?? '');
const textAreaRef = useRef() as React.RefObject<HTMLTextAreaElement>;
const submit = useCallback(() => props.onSubmit(value), [props.onSubmit, value]);
@@ -284,17 +285,25 @@ export function RowInput(props: IRowInputProps) {
[props.onBlur],
);
+ const focus = useCallback(() => {
+ const input = textAreaRef.current;
+ if (input) {
+ input.focus();
+ input.selectionStart = input.selectionEnd = value.length;
+ }
+ }, [textAreaRef, value.length]);
+
useEffect(() => {
if (props.autofocus) {
- textAreaRef.current?.focus();
+ focus();
}
}, []);
useEffect(() => {
if (props.invalid) {
- textAreaRef.current?.focus();
+ focus();
}
- }, [props.invalid]);
+ }, [props.invalid, focus]);
useEffect(() => {
document.addEventListener('keydown', globalKeyListener, true);
diff --git a/gui/src/renderer/components/cell/List.tsx b/gui/src/renderer/components/cell/List.tsx
deleted file mode 100644
index 69c9fff427..0000000000
--- a/gui/src/renderer/components/cell/List.tsx
+++ /dev/null
@@ -1,123 +0,0 @@
-import React, { useCallback } from 'react';
-import styled from 'styled-components';
-import { colors } from '../../../config.json';
-import { messages } from '../../../shared/gettext';
-import { AriaDescribed, AriaDescription, AriaDescriptionGroup } from '../AriaGroup';
-import ImageView from '../ImageView';
-import * as Cell from '.';
-
-export interface ICellListItem<T> {
- label: string;
- value: T;
-}
-
-interface ICellListProps<T> {
- title?: string;
- items: Array<ICellListItem<T>>;
- onSelect?: (value: T) => void;
- onRemove?: (value: T) => void;
- className?: string;
- paddingLeft?: number;
-}
-
-export default function CellList<T>(props: ICellListProps<T>) {
- const paddingLeft = props.paddingLeft ?? 32;
-
- return (
- <Cell.Section role="listbox" className={props.className}>
- {props.title && <Cell.SectionTitle as="label">{props.title}</Cell.SectionTitle>}
- {props.items.map((item, i) => {
- return (
- <CellListItem
- key={`${i}-${item.value}`}
- value={item.value}
- onSelect={props.onSelect}
- onRemove={props.onRemove}
- paddingLeft={paddingLeft}>
- {item.label}
- </CellListItem>
- );
- })}
- </Cell.Section>
- );
-}
-
-const StyledContainer = styled(Cell.Container)({
- display: 'flex',
- marginBottom: '1px',
- backgroundColor: colors.blue40,
-});
-
-const StyledButton = styled.button({
- display: 'flex',
- alignItems: 'center',
- flex: 1,
- border: 'none',
- background: 'transparent',
- padding: 0,
- margin: 0,
-});
-
-const StyledLabel = styled(Cell.Label)({}, (props: { paddingLeft: number }) => ({
- fontFamily: 'Open Sans',
- fontWeight: 'normal',
- fontSize: '16px',
- paddingLeft: props.paddingLeft + 'px',
- whiteSpace: 'pre-wrap',
- overflowWrap: 'break-word',
- width: '171px',
- marginRight: '25px',
-}));
-
-const StyledRemoveButton = styled.button({
- background: 'transparent',
- border: 'none',
- padding: 0,
-});
-
-const StyledRemoveIcon = styled(ImageView)({
- [StyledRemoveButton + ':hover &']: {
- backgroundColor: colors.white80,
- },
-});
-
-interface ICellListItemProps<T> {
- value: T;
- onSelect?: (application: T) => void;
- onRemove?: (application: T) => void;
- paddingLeft: number;
- children: string;
-}
-
-function CellListItem<T>(props: ICellListItemProps<T>) {
- const onSelect = useCallback(() => props.onSelect?.(props.value), [props.onSelect, props.value]);
- const onRemove = useCallback(() => props.onRemove?.(props.value), [props.onRemove, props.value]);
-
- return (
- <AriaDescriptionGroup>
- <StyledContainer>
- <StyledButton
- onClick={props.onSelect ? onSelect : undefined}
- as={props.onSelect ? 'button' : 'span'}>
- <AriaDescription>
- <StyledLabel paddingLeft={props.paddingLeft}>{props.children}</StyledLabel>
- </AriaDescription>
- </StyledButton>
- {props.onRemove && (
- <AriaDescribed>
- <StyledRemoveButton
- onClick={onRemove}
- aria-label={messages.pgettext('accessibility', 'Remove item')}>
- <StyledRemoveIcon
- source="icon-close"
- width={22}
- height={22}
- tintColor={colors.white40}
- />
- </StyledRemoveButton>
- </AriaDescribed>
- )}
- </StyledContainer>
- </AriaDescriptionGroup>
- );
-}
diff --git a/gui/src/renderer/containers/AdvancedSettingsPage.tsx b/gui/src/renderer/containers/AdvancedSettingsPage.tsx
index afd6134042..8e8acf2ee9 100644
--- a/gui/src/renderer/containers/AdvancedSettingsPage.tsx
+++ b/gui/src/renderer/containers/AdvancedSettingsPage.tsx
@@ -1,5 +1,5 @@
import { connect } from 'react-redux';
-import { IDnsOptions, TunnelProtocol } from '../../shared/daemon-rpc-types';
+import { TunnelProtocol } from '../../shared/daemon-rpc-types';
import log from '../../shared/logging';
import RelaySettingsBuilder from '../../shared/relay-settings-builder';
import AdvancedSettings from '../components/AdvancedSettings';
@@ -17,7 +17,6 @@ const mapStateToProps = (state: IReduxState) => {
enableIpv6: state.settings.enableIpv6,
blockWhenDisconnected: state.settings.blockWhenDisconnected,
wireguardKeyState: state.settings.wireguardKeyState,
- dns: state.settings.dns,
tunnelProtocol,
};
};
@@ -74,10 +73,6 @@ const mapDispatchToProps = (_dispatch: ReduxDispatch, props: IHistoryProps & IAp
}
},
- setDnsOptions: (dns: IDnsOptions) => {
- return props.app.setDnsOptions(dns);
- },
-
onViewWireguardSettings: () => props.history.push(RoutePath.wireguardSettings),
onViewOpenVpnSettings: () => props.history.push(RoutePath.openVpnSettings),
onViewSplitTunneling: () => props.history.push(RoutePath.splitTunneling),
diff --git a/gui/src/renderer/lib/utilityHooks.ts b/gui/src/renderer/lib/utilityHooks.ts
index d20cb80883..ca99a76f94 100644
--- a/gui/src/renderer/lib/utilityHooks.ts
+++ b/gui/src/renderer/lib/utilityHooks.ts
@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useRef } from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
export function useMounted() {
const mountedRef = useRef(false);
@@ -43,3 +43,13 @@ export function useAsyncEffect(
};
}, dependencies);
}
+
+export function useBoolean(initialValue: boolean) {
+ const [value, setValue] = useState(initialValue);
+
+ const setTrue = useCallback(() => setValue(true), []);
+ const setFalse = useCallback(() => setValue(false), []);
+ const toggle = useCallback(() => setValue((value) => !value), []);
+
+ return [value, setTrue, setFalse, toggle] as const;
+}