summaryrefslogtreecommitdiffhomepage
path: root/gui/src/renderer/components
diff options
context:
space:
mode:
Diffstat (limited to 'gui/src/renderer/components')
-rw-r--r--gui/src/renderer/components/AppRouter.tsx2
-rw-r--r--gui/src/renderer/components/ConnectionPanel.tsx16
-rw-r--r--gui/src/renderer/components/EditApiAccessMethod.tsx453
-rw-r--r--gui/src/renderer/components/EditCustomBridge.tsx116
-rw-r--r--gui/src/renderer/components/OpenVpnSettings.tsx86
-rw-r--r--gui/src/renderer/components/ProxyForm.tsx532
-rw-r--r--gui/src/renderer/components/RelayStatusIndicator.tsx12
-rw-r--r--gui/src/renderer/components/SmallButton.tsx11
-rw-r--r--gui/src/renderer/components/cell/Selector.tsx3
-rw-r--r--gui/src/renderer/components/select-location/CustomLists.tsx2
-rw-r--r--gui/src/renderer/components/select-location/LocationRow.tsx135
-rw-r--r--gui/src/renderer/components/select-location/LocationRowStyles.tsx150
-rw-r--r--gui/src/renderer/components/select-location/RelayListContext.tsx27
-rw-r--r--gui/src/renderer/components/select-location/SelectLocation.tsx50
-rw-r--r--gui/src/renderer/components/select-location/SelectLocationContainer.tsx14
-rw-r--r--gui/src/renderer/components/select-location/SpecialLocationList.tsx130
-rw-r--r--gui/src/renderer/components/select-location/custom-list-helpers.ts12
-rw-r--r--gui/src/renderer/components/select-location/select-location-hooks.ts44
-rw-r--r--gui/src/renderer/components/select-location/select-location-types.ts11
19 files changed, 1120 insertions, 686 deletions
diff --git a/gui/src/renderer/components/AppRouter.tsx b/gui/src/renderer/components/AppRouter.tsx
index 9e4a13adc8..0f85601a92 100644
--- a/gui/src/renderer/components/AppRouter.tsx
+++ b/gui/src/renderer/components/AppRouter.tsx
@@ -12,6 +12,7 @@ import Connect from './Connect';
import Debug from './Debug';
import { DeviceRevokedView } from './DeviceRevokedView';
import { EditApiAccessMethod } from './EditApiAccessMethod';
+import { EditCustomBridge } from './EditCustomBridge';
import {
SetupFinished,
TimeAdded,
@@ -92,6 +93,7 @@ export default function AppRouter() {
<Route exact path={RoutePath.problemReport} component={ProblemReport} />
<Route exact path={RoutePath.debug} component={Debug} />
<Route exact path={RoutePath.selectLocation} component={SelectLocation} />
+ <Route exact path={RoutePath.editCustomBridge} component={EditCustomBridge} />
<Route exact path={RoutePath.filter} component={Filter} />
</Switch>
</TransitionView>
diff --git a/gui/src/renderer/components/ConnectionPanel.tsx b/gui/src/renderer/components/ConnectionPanel.tsx
index 07547363a0..a99140bb43 100644
--- a/gui/src/renderer/components/ConnectionPanel.tsx
+++ b/gui/src/renderer/components/ConnectionPanel.tsx
@@ -6,7 +6,6 @@ import { colors } from '../../config.json';
import {
EndpointObfuscationType,
ProxyType,
- proxyTypeToString,
RelayProtocol,
TunnelType,
tunnelTypeToString,
@@ -164,10 +163,9 @@ export default class ConnectionPanel extends React.Component<IProps> {
entry: this.props.entryHostname,
},
);
- } else if (this.props.bridgeInfo?.ip) {
- return sprintf(messages.pgettext('connection-info', '%(relay)s via %(entry)s'), {
+ } else if (this.props.bridgeInfo !== undefined) {
+ return sprintf(messages.pgettext('connection-info', '%(relay)s via Custom bridge'), {
relay: this.props.hostname,
- entry: this.props.bridgeInfo.ip,
});
} else {
return this.props.hostname || '';
@@ -181,7 +179,7 @@ export default class ConnectionPanel extends React.Component<IProps> {
const tunnelType = tunnelTypeToString(inAddress.tunnelType);
if (bridgeInfo) {
- const bridgeType = proxyTypeToString(bridgeInfo.bridgeType);
+ const bridgeType = this.bridgeType();
return sprintf(
// TRANSLATORS: The tunnel type line displayed below the hostname line on the main screen
@@ -201,4 +199,12 @@ export default class ConnectionPanel extends React.Component<IProps> {
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/EditApiAccessMethod.tsx b/gui/src/renderer/components/EditApiAccessMethod.tsx
index b5b3bd6d48..59a5f2ff37 100644
--- a/gui/src/renderer/components/EditApiAccessMethod.tsx
+++ b/gui/src/renderer/components/EditApiAccessMethod.tsx
@@ -1,38 +1,27 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef } from 'react';
import { useParams } from 'react-router';
import { sprintf } from 'sprintf-js';
import {
- AccessMethod,
- AccessMethodSetting,
CustomProxy,
+ NamedCustomProxy,
NewAccessMethodSetting,
- RelayProtocol,
- ShadowsocksAccessMethod,
- Socks5LocalAccessMethod,
- Socks5RemoteAccessMethod,
} from '../../shared/daemon-rpc-types';
import { messages } from '../../shared/gettext';
import { useScheduler } from '../../shared/scheduler';
import { useAppContext } from '../context';
import { useApiAccessMethodTest } from '../lib/api-access-methods';
import { useHistory } from '../lib/history';
-import { IpAddress } from '../lib/ip';
import { useSelector } from '../redux/store';
-import * as Cell from './cell';
-import { SettingsForm, useSettingsFormSubmittable } from './cell/SettingsForm';
-import { SettingsGroup } from './cell/SettingsGroup';
-import { SettingsRadioGroup } from './cell/SettingsRadioGroup';
-import { SettingsRow } from './cell/SettingsRow';
-import { SettingsSelect, SettingsSelectItem } from './cell/SettingsSelect';
-import { SettingsNumberInput, SettingsTextInput } from './cell/SettingsTextInput';
+import { SettingsForm } from './cell/SettingsForm';
import { BackAction } from './KeyboardNavigation';
import { Layout, SettingsContainer } from './Layout';
import { ModalAlert, ModalAlertType } from './Modal';
import { NavigationBar, NavigationContainer, NavigationItems, TitleBarItem } from './NavigationBar';
+import { NamedProxyForm } from './ProxyForm';
import SettingsHeader, { HeaderSubTitle, HeaderTitle } from './SettingsHeader';
import { StyledContent, StyledNavigationScrollbars, StyledSettingsContent } from './SettingsStyles';
-import { SmallButton, SmallButtonGroup } from './SmallButton';
+import { SmallButton } from './SmallButton';
export function EditApiAccessMethod() {
return (
@@ -45,7 +34,7 @@ export function EditApiAccessMethod() {
function AccessMethodForm() {
const history = useHistory();
const { addApiAccessMethod, updateApiAccessMethod } = useAppContext();
- const methods = useSelector((state) => state.settings.apiAccessMethods);
+ const methods = useSelector((state) => state.settings.apiAccessMethods.custom);
const [testing, testResult, testApiAccessMethod, resetTestResult] = useApiAccessMethodTest(
false,
@@ -55,19 +44,9 @@ function AccessMethodForm() {
// Use id in url to figure out which method is to be edited. undefined means this is a new method.
const { id } = useParams<{ id: string | undefined }>();
- // Ugly way of iterating over all access methods, but it works.
- const method = [methods.direct, methods.mullvadBridges, ...methods.custom].find(
- (method) => method.id === id,
- );
-
- const updatedMethod = useRef<NewAccessMethodSetting | undefined>(method);
- const updateMethod = useCallback(
- (method: NewAccessMethodSetting) => (updatedMethod.current = method),
- [],
- );
+ const method = methods.find((method) => method.id === id);
- // Contains form submittability to know whether or not to enable the Add/Save button.
- const formSubmittable = useSettingsFormSubmittable();
+ const updatedMethod = useRef<NewAccessMethodSetting<CustomProxy> | undefined>(method);
const save = useCallback(() => {
if (updatedMethod.current !== undefined) {
@@ -81,15 +60,20 @@ function AccessMethodForm() {
}
}, [updatedMethod.current, id]);
- const onSave = useCallback(async () => {
- if (
- updatedMethod.current !== undefined &&
- (await testApiAccessMethod(updatedMethod.current as CustomProxy))
- ) {
- // Hide the save dialog after 1.5 seconds.
- saveScheduler.schedule(save, 1500);
- }
- }, [updatedMethod, save, history.pop]);
+ const onSave = useCallback(
+ async (newMethod: NamedCustomProxy) => {
+ const enabled = id === undefined ? true : method?.enabled ?? true;
+ updatedMethod.current = { ...newMethod, enabled };
+ if (
+ updatedMethod.current !== undefined &&
+ (await testApiAccessMethod(updatedMethod.current as CustomProxy))
+ ) {
+ // Hide the save dialog after 1.5 seconds.
+ saveScheduler.schedule(save, 1500);
+ }
+ },
+ [updatedMethod, save, history.pop],
+ );
const title = getTitle(id === undefined);
const subtitle = getSubtitle(id === undefined);
@@ -116,15 +100,8 @@ function AccessMethodForm() {
{id !== undefined && method === undefined ? (
<span>Failed to open method</span>
) : (
- <AccessMethodFormImpl method={method} updateMethod={updateMethod} />
+ <NamedProxyForm proxy={method} onSave={onSave} onCancel={history.pop} />
)}
-
- <SmallButtonGroup>
- <SmallButton onClick={history.pop}>{messages.gettext('Cancel')}</SmallButton>
- <SmallButton onClick={onSave} disabled={!formSubmittable}>
- {id === undefined ? messages.gettext('Add') : messages.gettext('Save')}
- </SmallButton>
- </SmallButtonGroup>
</StyledSettingsContent>
<TestingDialog
@@ -257,387 +234,3 @@ function getTestingDialogButtons(type: ModalAlertType, save: () => void, cancel:
return [cancelButton];
}
}
-
-interface EditApiAccessMethodImplProps {
- method?: AccessMethodSetting;
- updateMethod: (method: NewAccessMethodSetting) => void;
-}
-
-function AccessMethodFormImpl(props: EditApiAccessMethodImplProps) {
- // Available method types.
- const types = useMemo<Array<SettingsSelectItem<AccessMethod['type']>>>(
- () => [
- { value: 'shadowsocks', label: 'Shadowsocks' },
- {
- value: 'socks5-remote',
- label: messages.pgettext('api-access-methods-view', 'SOCKS5 remote'),
- },
- {
- value: 'socks5-local',
- label: messages.pgettext('api-access-methods-view', 'SOCKS5 local'),
- },
- ],
- [],
- );
- const [type, setType] = useState(props.method?.type ?? 'shadowsocks');
-
- // State for the name input.
- const name = useRef(props.method?.name ?? '');
- const method = useRef<AccessMethod | undefined>(props.method);
-
- // When the form makes up a valid method the parent is updated.
- const onUpdate = useCallback(() => {
- if (method.current !== undefined && name.current !== '') {
- props.updateMethod({ ...method.current, name: name.current, enabled: true });
- }
- }, []);
-
- const updateName = useCallback(
- (value: string) => {
- name.current = value;
- onUpdate();
- },
- [onUpdate],
- );
-
- const updateMethod = useCallback(
- (value: AccessMethod) => {
- method.current = value;
- onUpdate();
- },
- [onUpdate],
- );
-
- return (
- <>
- <SettingsRow label={messages.gettext('Name')}>
- <SettingsTextInput
- defaultValue={name.current}
- placeholder={messages.pgettext('api-access-methods-view', 'Enter name')}
- onUpdate={updateName}
- />
- </SettingsRow>
-
- <SettingsRow label={messages.gettext('Type')}>
- <SettingsSelect defaultValue={type} onUpdate={setType} items={types} />
- </SettingsRow>
-
- {type === 'shadowsocks' && (
- <EditShadowsocks
- onUpdate={updateMethod}
- method={props.method?.type === 'shadowsocks' ? props.method : undefined}
- />
- )}
- {type === 'socks5-remote' && (
- <EditSocks5Remote
- onUpdate={updateMethod}
- method={props.method?.type === 'socks5-remote' ? props.method : undefined}
- />
- )}
- {type === 'socks5-local' && (
- <EditSocks5Local
- onUpdate={updateMethod}
- method={props.method?.type === 'socks5-local' ? props.method : undefined}
- />
- )}
- </>
- );
-}
-
-interface EditMethodProps<T> {
- method?: T;
- onUpdate: (method: AccessMethod) => void;
-}
-
-function EditShadowsocks(props: EditMethodProps<ShadowsocksAccessMethod>) {
- const [ip, setIp] = useState(props.method?.ip ?? '');
- const [port, setPort] = useState(props.method?.port);
- const [password, setPassword] = useState(props.method?.password ?? '');
- const [cipher, setCipher] = useState(props.method?.cipher);
-
- const ciphers = useMemo(
- () =>
- [
- { value: 'aes-128-cfb', label: 'aes-128-cfb' },
- { value: 'aes-128-cfb1', label: 'aes-128-cfb1' },
- { value: 'aes-128-cfb8', label: 'aes-128-cfb8' },
- { value: 'aes-128-cfb128', label: 'aes-128-cfb128' },
- { value: 'aes-256-cfb', label: 'aes-256-cfb' },
- { value: 'aes-256-cfb1', label: 'aes-256-cfb1' },
- { value: 'aes-256-cfb8', label: 'aes-256-cfb8' },
- { value: 'aes-256-cfb128', label: 'aes-256-cfb128' },
- { value: 'rc4', label: 'rc4' },
- { value: 'rc4-md5', label: 'rc4-md5' },
- { value: 'chacha20', label: 'chacha20' },
- { value: 'salsa20', label: 'salsa20' },
- { value: 'chacha20-ietf', label: 'chacha20-ietf' },
- { value: 'aes-128-gcm', label: 'aes-128-gcm' },
- { value: 'aes-256-gcm', label: 'aes-256-gcm' },
- { value: 'chacha20-ietf-poly1305', label: 'chacha20-ietf-poly1305' },
- { value: 'xchacha20-ietf-poly1305', label: 'xchacha20-ietf-poly1305' },
- { value: 'aes-128-pmac-siv', label: 'aes-128-pmac-siv' },
- { value: 'aes-256-pmac-siv', label: 'aes-256-pmac-siv' },
- ].sort((a, b) => a.label.localeCompare(b.label)),
- [],
- );
-
- // Report back to form component with the method values when all required values are set.
- useEffect(() => {
- if (ip !== '' && port !== undefined && cipher !== undefined) {
- props.onUpdate({
- type: 'shadowsocks',
- ip,
- port,
- password,
- cipher,
- });
- }
- }, [ip, port, password, cipher]);
-
- return (
- <SettingsGroup title={messages.pgettext('api-access-methods-view', 'Server details')}>
- <SettingsRow
- label={messages.pgettext('api-access-methods-view', 'Server')}
- errorMessage={messages.pgettext(
- 'api-access-methods-view',
- 'Please enter a valid IPv4 or IPv6 address.',
- )}>
- <SettingsTextInput
- value={ip}
- placeholder={messages.pgettext('api-access-methods-view', 'Enter IP')}
- onUpdate={setIp}
- validate={validateIp}
- />
- </SettingsRow>
-
- <SettingsRow
- label={messages.gettext('Port')}
- errorMessage={messages.pgettext(
- 'api-access-methods-view',
- 'Please enter a valid remote server port.',
- )}>
- <SettingsNumberInput
- value={port ?? ''}
- placeholder={messages.pgettext('api-access-methods-view', 'Enter port')}
- onUpdate={setPort}
- validate={validatePort}
- />
- </SettingsRow>
-
- <SettingsRow label={messages.gettext('Password')}>
- <SettingsTextInput
- value={password}
- placeholder={messages.gettext('Optional')}
- onUpdate={setPassword}
- optionalInForm
- />
- </SettingsRow>
-
- <SettingsRow label={messages.gettext('Cipher')}>
- <SettingsSelect
- data-testid="ciphers"
- direction="up"
- defaultValue={cipher}
- onUpdate={setCipher}
- items={ciphers}
- />
- </SettingsRow>
- </SettingsGroup>
- );
-}
-
-function EditSocks5Remote(props: EditMethodProps<Socks5RemoteAccessMethod>) {
- const [ip, setIp] = useState(props.method?.ip ?? '');
- const [port, setPort] = useState(props.method?.port);
- const [authentication, setAuthentication] = useState(props.method?.authentication !== undefined);
- const [username, setUsername] = useState(props.method?.authentication?.username ?? '');
- const [password, setPassword] = useState(props.method?.authentication?.password ?? '');
-
- // Report back to form component with the method values when all required values are set.
- useEffect(() => {
- if (
- ip !== '' &&
- port !== undefined &&
- (!authentication || (username !== '' && password !== ''))
- ) {
- props.onUpdate({
- type: 'socks5-remote',
- ip,
- port,
- authentication: authentication ? { username, password } : undefined,
- });
- }
- }, [ip, port, username, password]);
-
- return (
- <SettingsGroup title={messages.pgettext('api-access-methods-view', 'Remote Server')}>
- <SettingsRow
- label={messages.pgettext('api-access-methods-view', 'Server')}
- errorMessage={messages.pgettext(
- 'api-access-methods-view',
- 'Please enter a valid IPv4 or IPv6 address.',
- )}>
- <SettingsTextInput
- value={ip}
- placeholder={messages.pgettext('api-access-methods-view', 'Enter IP')}
- onUpdate={setIp}
- validate={validateIp}
- />
- </SettingsRow>
-
- <SettingsRow
- label={messages.gettext('Port')}
- errorMessage={messages.pgettext(
- 'api-access-methods-view',
- 'Please enter a valid remote server port.',
- )}>
- <SettingsNumberInput
- value={port ?? ''}
- placeholder={messages.pgettext('api-access-methods-view', 'Enter port')}
- onUpdate={setPort}
- validate={validatePort}
- />
- </SettingsRow>
-
- <SettingsRow label={messages.pgettext('api-access-methods-view', 'Authentication')}>
- <Cell.Switch isOn={authentication} onChange={setAuthentication} />
- </SettingsRow>
-
- {authentication && (
- <>
- <SettingsRow label={messages.gettext('Username')}>
- <SettingsTextInput
- value={username}
- placeholder={messages.gettext('Required')}
- onUpdate={setUsername}
- />
- </SettingsRow>
-
- <SettingsRow label={messages.gettext('Password')}>
- <SettingsTextInput
- value={password}
- placeholder={messages.gettext('Required')}
- onUpdate={setPassword}
- />
- </SettingsRow>
- </>
- )}
- </SettingsGroup>
- );
-}
-
-function EditSocks5Local(props: EditMethodProps<Socks5LocalAccessMethod>) {
- const [remoteIp, setRemoteIp] = useState(props.method?.remoteIp ?? '');
- const [remotePort, setRemotePort] = useState(props.method?.remotePort);
- const [remoteTransportProtocol, setRemoteTransportProtocol] = useState<RelayProtocol>(
- props.method?.remoteTransportProtocol ?? 'tcp',
- );
- const [localPort, setLocalPort] = useState(props.method?.localPort);
-
- const remoteTransportProtocols = useMemo<Array<SettingsSelectItem<RelayProtocol>>>(
- () => [
- { value: 'tcp', label: 'TCP' },
- { value: 'udp', label: 'UDP' },
- ],
- [],
- );
-
- useEffect(() => {
- if (remoteIp !== '' && remotePort !== undefined && localPort !== undefined) {
- props.onUpdate({
- type: 'socks5-local',
- remoteIp,
- remotePort,
- remoteTransportProtocol,
- localPort,
- });
- }
- }, [remoteIp, remotePort, localPort, remoteTransportProtocol]);
-
- return (
- <>
- <SettingsGroup
- title={messages.pgettext('api-access-methods-view', 'Local SOCKS5 server')}
- infoMessage={messages.pgettext(
- 'api-access-methods-view',
- 'The TCP port where your local SOCKS5 server is listening.',
- )}>
- <SettingsRow
- label={messages.gettext('Port')}
- errorMessage={messages.pgettext(
- 'api-access-methods-view',
- 'Please enter a valid localhost port.',
- )}>
- <SettingsNumberInput
- value={localPort}
- placeholder={messages.pgettext('api-access-methods-view', 'Enter port')}
- onUpdate={setLocalPort}
- validate={validatePort}
- />
- </SettingsRow>
- </SettingsGroup>
-
- <SettingsGroup
- title={messages.pgettext('api-access-methods-view', 'Remote Server')}
- infoMessage={[
- messages.pgettext(
- 'api-access-methods-view',
- 'The app needs the remote server details, where your local SOCKS5 server will forward your traffic.',
- ),
- messages.pgettext(
- 'api-access-methods-view',
- 'This is needed so our app can allow that traffic in the firewall.',
- ),
- ]}>
- <SettingsRow
- label={messages.pgettext('api-access-methods-view', 'Server')}
- errorMessage={messages.pgettext(
- 'api-access-methods-view',
- 'Please enter a valid IPv4 or IPv6 address.',
- )}>
- <SettingsTextInput
- value={remoteIp}
- placeholder={messages.pgettext('api-access-methods-view', 'Enter IP')}
- onUpdate={setRemoteIp}
- validate={validateIp}
- />
- </SettingsRow>
-
- <SettingsRow
- label={messages.gettext('Port')}
- errorMessage={messages.pgettext(
- 'api-access-methods-view',
- 'Please enter a valid remote server port.',
- )}>
- <SettingsNumberInput
- value={remotePort ?? ''}
- placeholder={messages.pgettext('api-access-methods-view', 'Enter port')}
- onUpdate={setRemotePort}
- validate={validatePort}
- />
- </SettingsRow>
-
- <SettingsRow label={messages.pgettext('api-access-methods-view', 'Transport protocol')}>
- <SettingsRadioGroup<'tcp' | 'udp'>
- defaultValue={remoteTransportProtocol}
- onUpdate={setRemoteTransportProtocol}
- items={remoteTransportProtocols}
- />
- </SettingsRow>
- </SettingsGroup>
- </>
- );
-}
-
-function validateIp(ip: string): boolean {
- try {
- void IpAddress.fromString(ip);
- return true;
- } catch {
- return false;
- }
-}
-
-function validatePort(port: number): boolean {
- return port > 0 && port <= 65535;
-}
diff --git a/gui/src/renderer/components/EditCustomBridge.tsx b/gui/src/renderer/components/EditCustomBridge.tsx
new file mode 100644
index 0000000000..2692217ea0
--- /dev/null
+++ b/gui/src/renderer/components/EditCustomBridge.tsx
@@ -0,0 +1,116 @@
+import { useCallback } from 'react';
+
+import { CustomProxy } from '../../shared/daemon-rpc-types';
+import { messages } from '../../shared/gettext';
+import { useBridgeSettingsUpdater } from '../lib/constraint-updater';
+import { useHistory } from '../lib/history';
+import { useBoolean } from '../lib/utilityHooks';
+import { useSelector } from '../redux/store';
+import { SettingsForm } from './cell/SettingsForm';
+import { BackAction } from './KeyboardNavigation';
+import { Layout, SettingsContainer } from './Layout';
+import { ModalAlert, ModalAlertType } from './Modal';
+import { NavigationBar, NavigationContainer, NavigationItems, TitleBarItem } from './NavigationBar';
+import { ProxyForm } from './ProxyForm';
+import SettingsHeader, { HeaderTitle } from './SettingsHeader';
+import { StyledContent, StyledNavigationScrollbars, StyledSettingsContent } from './SettingsStyles';
+import { SmallButton, SmallButtonColor } from './SmallButton';
+
+export function EditCustomBridge() {
+ return (
+ <SettingsForm>
+ <CustomBridgeForm />
+ </SettingsForm>
+ );
+}
+
+function CustomBridgeForm() {
+ const history = useHistory();
+ const bridgeSettingsUpdater = useBridgeSettingsUpdater();
+ const bridgeSettings = useSelector((state) => state.settings.bridgeSettings);
+
+ const [deleteDialogVisible, showDeleteDialog, hideDeleteDialog] = useBoolean();
+
+ // If there are no custom bridge settings, we should prompt the user to add a custom bridge.
+ // Otherwise, we should prompt them to edit the existing custom bridge settings.
+ const title =
+ bridgeSettings.custom === undefined
+ ? messages.pgettext('custom-bridge', 'Add custom bridge')
+ : messages.pgettext('custom-bridge', 'Edit custom bridge');
+
+ const onSave = useCallback(
+ (newBridge: CustomProxy) => {
+ void bridgeSettingsUpdater((bridgeSettings) => {
+ bridgeSettings.type = 'custom';
+ bridgeSettings.custom = newBridge;
+ return bridgeSettings;
+ });
+ history.pop();
+ },
+ [bridgeSettingsUpdater, history.pop],
+ );
+
+ const onDelete = useCallback(() => {
+ if (bridgeSettings.custom !== undefined) {
+ hideDeleteDialog();
+ void bridgeSettingsUpdater((bridgeSettings) => {
+ bridgeSettings.type = 'normal';
+ delete bridgeSettings.custom;
+ return bridgeSettings;
+ });
+ history.pop();
+ }
+ }, [bridgeSettingsUpdater, history.pop]);
+
+ return (
+ <BackAction action={history.pop}>
+ <Layout>
+ <SettingsContainer>
+ <NavigationContainer>
+ <NavigationBar>
+ <NavigationItems>
+ <TitleBarItem>{title}</TitleBarItem>
+ </NavigationItems>
+ </NavigationBar>
+
+ <StyledNavigationScrollbars fillContainer>
+ <StyledContent>
+ <SettingsHeader>
+ <HeaderTitle>{title}</HeaderTitle>
+ </SettingsHeader>
+
+ <StyledSettingsContent>
+ <ProxyForm
+ proxy={bridgeSettings.custom}
+ onSave={onSave}
+ onCancel={history.pop}
+ onDelete={bridgeSettings.custom === undefined ? undefined : showDeleteDialog}
+ />
+ </StyledSettingsContent>
+
+ <ModalAlert
+ isOpen={deleteDialogVisible}
+ type={ModalAlertType.warning}
+ gridButtons={[
+ <SmallButton key="cancel" onClick={hideDeleteDialog}>
+ {messages.gettext('Cancel')}
+ </SmallButton>,
+ <SmallButton key="delete" color={SmallButtonColor.red} onClick={onDelete}>
+ {messages.gettext('Delete')}
+ </SmallButton>,
+ ]}
+ close={hideDeleteDialog}
+ title={messages.pgettext('custom-bridge', 'Delete custom bridge?')}
+ message={messages.pgettext(
+ 'custom-bridge',
+ 'Deleting the custom bridge will take you back to the select location view and the Automatic option will be selected instead.',
+ )}
+ />
+ </StyledContent>
+ </StyledNavigationScrollbars>
+ </NavigationContainer>
+ </SettingsContainer>
+ </Layout>
+ </BackAction>
+ );
+}
diff --git a/gui/src/renderer/components/OpenVpnSettings.tsx b/gui/src/renderer/components/OpenVpnSettings.tsx
index 679794d831..dee1e64089 100644
--- a/gui/src/renderer/components/OpenVpnSettings.tsx
+++ b/gui/src/renderer/components/OpenVpnSettings.tsx
@@ -18,13 +18,12 @@ import { useHistory } from '../lib/history';
import { formatHtml } from '../lib/html-formatter';
import { useBoolean } from '../lib/utilityHooks';
import { useSelector } from '../redux/store';
-import * as AppButton from './AppButton';
import { AriaDescription, AriaInput, AriaInputGroup, AriaLabel } from './AriaGroup';
import * as Cell from './cell';
import Selector, { SelectorItem } from './cell/Selector';
import { BackAction } from './KeyboardNavigation';
import { Layout, SettingsContainer } from './Layout';
-import { ModalAlert, ModalAlertType } from './Modal';
+import { ModalAlert, ModalAlertType, ModalMessage } from './Modal';
import {
NavigationBar,
NavigationContainer,
@@ -33,6 +32,7 @@ import {
TitleBarItem,
} from './NavigationBar';
import SettingsHeader, { HeaderTitle } from './SettingsHeader';
+import { SmallButton } from './SmallButton';
const MIN_MSSFIX_VALUE = 1000;
const MAX_MSSFIX_VALUE = 1450;
@@ -308,6 +308,8 @@ function BridgeModeSelector() {
await setBridgeState('on');
}, [hideConfirmationDialog, setBridgeState]);
+ const footerText = bridgeModeFooterText(bridgeState === 'on', tunnelProtocol, transportProtocol);
+
return (
<>
<AriaInputGroup>
@@ -317,31 +319,57 @@ function BridgeModeSelector() {
// TRANSLATORS: The title for the shadowsocks bridge selector section.
messages.pgettext('openvpn-settings-view', 'Bridge mode')
}
+ infoTitle={messages.pgettext('openvpn-settings-view', 'Bridge mode')}
+ details={
+ <>
+ <ModalMessage>
+ {sprintf(
+ // TRANSLATORS: This is used as a description for the bridge mode
+ // TRANSLATORS: setting.
+ // TRANSLATORS: Available placeholders:
+ // TRANSLATORS: %(openvpn)s - will be replaced with OpenVPN
+ messages.pgettext(
+ 'openvpn-settings-view',
+ 'Helps circumvent censorship, by routing your traffic through a bridge server before reaching an %(openvpn)s server. Obfuscation is added to make fingerprinting harder.',
+ ),
+ { openvpn: strings.openvpn },
+ )}
+ </ModalMessage>
+ <ModalMessage>
+ {messages.gettext('This setting increases latency. Use only if needed.')}
+ </ModalMessage>
+ </>
+ }
items={options}
value={bridgeState}
onSelect={onSelectBridgeState}
automaticValue={'auto' as const}
/>
</StyledSelectorContainer>
- <Cell.CellFooter>
- <AriaDescription>
- <Cell.CellFooterText>
- {bridgeModeFooterText(tunnelProtocol, transportProtocol)}
- </Cell.CellFooterText>
- </AriaDescription>
- </Cell.CellFooter>
+ {footerText !== undefined && (
+ <Cell.CellFooter>
+ <AriaDescription>
+ <Cell.CellFooterText>{footerText}</Cell.CellFooterText>
+ </AriaDescription>
+ </Cell.CellFooter>
+ )}
</AriaInputGroup>
<ModalAlert
isOpen={confirmationDialogVisible}
type={ModalAlertType.caution}
- message={messages.gettext('This setting increases latency. Use only if needed.')}
- buttons={[
- <AppButton.RedButton key="confirm" onClick={confirmBridgeState}>
- {messages.gettext('Enable anyway')}
- </AppButton.RedButton>,
- <AppButton.BlueButton key="back" onClick={hideConfirmationDialog}>
- {messages.gettext('Back')}
- </AppButton.BlueButton>,
+ title={messages.pgettext('openvpn-settings-view', 'Enable bridge mode?')}
+ message={
+ // TRANSLATORS: Warning shown in dialog to users when they enable setting that increases
+ // TRANSLATORS: network latency (decreases performance).
+ messages.gettext('This setting increases latency. Use only if needed.')
+ }
+ gridButtons={[
+ <SmallButton key="cancel" onClick={hideConfirmationDialog}>
+ {messages.gettext('Cancel')}
+ </SmallButton>,
+ <SmallButton key="confirm" onClick={confirmBridgeState}>
+ {messages.gettext('Enable')}
+ </SmallButton>,
]}
close={hideConfirmationDialog}
/>
@@ -350,10 +378,18 @@ function BridgeModeSelector() {
}
function bridgeModeFooterText(
+ bridgeModeOn: boolean,
tunnelProtocol: TunnelProtocol | null,
transportProtocol: RelayProtocol | null,
-) {
- if (tunnelProtocol !== 'openvpn') {
+): React.ReactNode | void {
+ if (bridgeModeOn) {
+ // TRANSLATORS: This text is shown beneath the bridge mode setting to instruct users how to
+ // TRANSLATORS: configure the feature further.
+ return messages.pgettext(
+ 'openvpn-settings-view',
+ 'To select a specific bridge server, go to the Select location view.',
+ );
+ } else if (tunnelProtocol !== 'openvpn') {
return formatHtml(
sprintf(
// TRANSLATORS: This is used to instruct users how to make the bridge mode setting
@@ -391,18 +427,6 @@ function bridgeModeFooterText(
},
),
);
- } else {
- return sprintf(
- // TRANSLATORS: This is used as a description for the bridge mode
- // TRANSLATORS: setting.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(openvpn)s - will be replaced with OpenVPN
- messages.pgettext(
- 'openvpn-settings-view',
- 'Helps circumvent censorship, by routing your traffic through a bridge server before reaching an %(openvpn)s server. Obfuscation is added to make fingerprinting harder.',
- ),
- { openvpn: strings.openvpn },
- );
}
}
diff --git a/gui/src/renderer/components/ProxyForm.tsx b/gui/src/renderer/components/ProxyForm.tsx
new file mode 100644
index 0000000000..5fbcf34d0f
--- /dev/null
+++ b/gui/src/renderer/components/ProxyForm.tsx
@@ -0,0 +1,532 @@
+import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
+import React from 'react';
+
+import {
+ CustomProxy,
+ NamedCustomProxy,
+ RelayProtocol,
+ ShadowsocksCustomProxy,
+ Socks5LocalCustomProxy,
+ Socks5RemoteCustomProxy,
+} from '../../shared/daemon-rpc-types';
+import { messages } from '../../shared/gettext';
+import { IpAddress } from '../lib/ip';
+import * as Cell from './cell';
+import { SettingsForm, useSettingsFormSubmittable } from './cell/SettingsForm';
+import { SettingsGroup } from './cell/SettingsGroup';
+import { SettingsRadioGroup } from './cell/SettingsRadioGroup';
+import { SettingsRow } from './cell/SettingsRow';
+import { SettingsSelect, SettingsSelectItem } from './cell/SettingsSelect';
+import { SettingsNumberInput, SettingsTextInput } from './cell/SettingsTextInput';
+import {
+ SmallButton,
+ SmallButtonColor,
+ SmallButtonGroup,
+ SmallButtonGroupStart,
+} from './SmallButton';
+
+interface ProxyFormContext {
+ proxy?: CustomProxy;
+ setProxy: (proxy: CustomProxy) => void;
+ onSave: () => void;
+ onCancel: () => void;
+ onDelete?: () => void;
+}
+
+const proxyFormContext = React.createContext<ProxyFormContext>({
+ get proxy(): CustomProxy {
+ throw new Error('Missing ProxyFromContext provider');
+ },
+ setProxy(): void {
+ throw new Error('Missing ProxyFromContext provider');
+ },
+ onSave(): void {
+ throw new Error('Missing ProxyFromContext provider');
+ },
+ onCancel(): void {
+ throw new Error('Missing ProxyFromContext provider');
+ },
+ onDelete(): void {
+ throw new Error('Missing ProxyFromContext provider');
+ },
+});
+
+interface ProxyFormContextProviderProps {
+ proxy?: CustomProxy;
+ onSave: (proxy: CustomProxy) => void;
+ onCancel: () => void;
+ onDelete?: () => void;
+}
+
+function ProxyFormContextProvider(props: React.PropsWithChildren<ProxyFormContextProviderProps>) {
+ const [proxy, setProxy] = useState<CustomProxy | undefined>(props.proxy);
+
+ const onSave = useCallback(() => {
+ if (proxy !== undefined) {
+ props.onSave(proxy);
+ }
+ }, [proxy, props.onSave]);
+
+ const value = useMemo(
+ () => ({ proxy, setProxy, onSave, onCancel: props.onCancel, onDelete: props.onDelete }),
+ [proxy, onSave, props.onCancel, props.onDelete],
+ );
+
+ return <proxyFormContext.Provider value={value}>{props.children}</proxyFormContext.Provider>;
+}
+
+export function ProxyForm(props: ProxyFormContextProviderProps) {
+ return (
+ <ProxyFormContextProvider {...props}>
+ <SettingsForm>
+ <ProxyFormInner />
+ <ProxyFormButtons />
+ </SettingsForm>
+ </ProxyFormContextProvider>
+ );
+}
+
+interface NamedProxyFormContext {
+ name?: string;
+ setName: (name: string) => void;
+}
+
+const namedProxyFormContext = React.createContext<NamedProxyFormContext>({
+ get name(): string {
+ throw new Error('Missing NamedProxyFromContext provider');
+ },
+ setName(): void {
+ throw new Error('Missing NamedProxyFromContext provider');
+ },
+});
+
+interface NamedProxyFormContainerProps
+ extends Omit<ProxyFormContextProviderProps, 'proxy' | 'onSave'> {
+ proxy?: NamedCustomProxy;
+ onSave: (proxy: NamedCustomProxy) => void;
+}
+
+export function NamedProxyForm(props: NamedProxyFormContainerProps) {
+ const { onSave, ...otherProps } = props;
+
+ const [name, setName] = useState<string>(props.proxy?.name ?? '');
+
+ const save = useCallback(
+ (proxy: CustomProxy) => {
+ if (name !== '') {
+ onSave({ ...proxy, name });
+ }
+ },
+ [name, onSave],
+ );
+
+ const nameContextValue = useMemo(() => ({ name, setName }), [name]);
+
+ return (
+ <namedProxyFormContext.Provider value={nameContextValue}>
+ <ProxyFormContextProvider {...otherProps} onSave={save}>
+ <SettingsForm>
+ <ProxyFormNameField />
+ <ProxyFormInner />
+ <ProxyFormButtons />
+ </SettingsForm>
+ </ProxyFormContextProvider>
+ </namedProxyFormContext.Provider>
+ );
+}
+
+function ProxyFormNameField() {
+ const { name, setName } = useContext(namedProxyFormContext);
+
+ return (
+ <SettingsRow label={messages.gettext('Name')}>
+ <SettingsTextInput
+ defaultValue={name}
+ placeholder={messages.pgettext('api-access-methods-view', 'Enter name')}
+ onUpdate={setName}
+ />
+ </SettingsRow>
+ );
+}
+
+export function ProxyFormButtons() {
+ const { proxy, onSave, onCancel, onDelete } = useContext(proxyFormContext);
+
+ // Contains form submittability to know whether or not to enable the Add/Save button.
+ const formSubmittable = useSettingsFormSubmittable();
+
+ return (
+ <SmallButtonGroup>
+ {onDelete !== undefined && (
+ <SmallButtonGroupStart>
+ <SmallButton color={SmallButtonColor.red} onClick={onDelete}>
+ {messages.gettext('Delete')}
+ </SmallButton>
+ </SmallButtonGroupStart>
+ )}
+ <SmallButton onClick={onCancel}>{messages.gettext('Cancel')}</SmallButton>
+ <SmallButton onClick={onSave} disabled={!formSubmittable}>
+ {proxy === undefined ? messages.gettext('Add') : messages.gettext('Save')}
+ </SmallButton>
+ </SmallButtonGroup>
+ );
+}
+
+function ProxyFormInner() {
+ const { proxy, setProxy } = useContext(proxyFormContext);
+
+ // Available custom proxies
+ const types = useMemo<Array<SettingsSelectItem<CustomProxy['type']>>>(
+ () => [
+ { value: 'shadowsocks', label: 'Shadowsocks' },
+ {
+ value: 'socks5-remote',
+ label: messages.pgettext('api-access-methods-view', 'SOCKS5 remote'),
+ },
+ {
+ value: 'socks5-local',
+ label: messages.pgettext('api-access-methods-view', 'SOCKS5 local'),
+ },
+ ],
+ [],
+ );
+ const [type, setType] = useState(proxy?.type ?? 'shadowsocks');
+ const proxyRef = useRef<CustomProxy | undefined>(proxy);
+
+ const updateProxy = useCallback(
+ (value: CustomProxy) => {
+ proxyRef.current = value;
+
+ // When the form makes up a valid proxy the parent is updated.
+ if (proxyRef.current !== undefined) {
+ setProxy(proxyRef.current);
+ }
+ },
+ [setProxy],
+ );
+
+ return (
+ <>
+ <SettingsRow label={messages.gettext('Type')}>
+ <SettingsSelect defaultValue={type} onUpdate={setType} items={types} />
+ </SettingsRow>
+
+ {type === 'shadowsocks' && (
+ <EditShadowsocks
+ onUpdate={updateProxy}
+ proxy={proxy?.type === 'shadowsocks' ? proxy : undefined}
+ />
+ )}
+ {type === 'socks5-remote' && (
+ <EditSocks5Remote
+ onUpdate={updateProxy}
+ proxy={proxy?.type === 'socks5-remote' ? proxy : undefined}
+ />
+ )}
+ {type === 'socks5-local' && (
+ <EditSocks5Local
+ onUpdate={updateProxy}
+ proxy={proxy?.type === 'socks5-local' ? proxy : undefined}
+ />
+ )}
+ </>
+ );
+}
+
+interface EditProxyProps<T> {
+ proxy?: T;
+ onUpdate: (proxy: CustomProxy) => void;
+}
+
+function EditShadowsocks(props: EditProxyProps<ShadowsocksCustomProxy>) {
+ const [ip, setIp] = useState(props.proxy?.ip ?? '');
+ const [port, setPort] = useState(props.proxy?.port);
+ const [password, setPassword] = useState(props.proxy?.password ?? '');
+ const [cipher, setCipher] = useState(props.proxy?.cipher);
+
+ const ciphers = useMemo(
+ () =>
+ [
+ { value: 'aes-128-cfb', label: 'aes-128-cfb' },
+ { value: 'aes-128-cfb1', label: 'aes-128-cfb1' },
+ { value: 'aes-128-cfb8', label: 'aes-128-cfb8' },
+ { value: 'aes-128-cfb128', label: 'aes-128-cfb128' },
+ { value: 'aes-256-cfb', label: 'aes-256-cfb' },
+ { value: 'aes-256-cfb1', label: 'aes-256-cfb1' },
+ { value: 'aes-256-cfb8', label: 'aes-256-cfb8' },
+ { value: 'aes-256-cfb128', label: 'aes-256-cfb128' },
+ { value: 'rc4', label: 'rc4' },
+ { value: 'rc4-md5', label: 'rc4-md5' },
+ { value: 'chacha20', label: 'chacha20' },
+ { value: 'salsa20', label: 'salsa20' },
+ { value: 'chacha20-ietf', label: 'chacha20-ietf' },
+ { value: 'aes-128-gcm', label: 'aes-128-gcm' },
+ { value: 'aes-256-gcm', label: 'aes-256-gcm' },
+ { value: 'chacha20-ietf-poly1305', label: 'chacha20-ietf-poly1305' },
+ { value: 'xchacha20-ietf-poly1305', label: 'xchacha20-ietf-poly1305' },
+ { value: 'aes-128-pmac-siv', label: 'aes-128-pmac-siv' },
+ { value: 'aes-256-pmac-siv', label: 'aes-256-pmac-siv' },
+ ].sort((a, b) => a.label.localeCompare(b.label)),
+ [],
+ );
+
+ // Report back to form component with the proxy values when all required values are set.
+ useEffect(() => {
+ if (ip !== '' && port !== undefined && cipher !== undefined) {
+ props.onUpdate({
+ type: 'shadowsocks',
+ ip,
+ port,
+ password,
+ cipher,
+ });
+ }
+ }, [ip, port, password, cipher]);
+
+ return (
+ <SettingsGroup title={messages.pgettext('api-access-methods-view', 'Server details')}>
+ <SettingsRow
+ label={messages.pgettext('api-access-methods-view', 'Server')}
+ errorMessage={messages.pgettext(
+ 'api-access-methods-view',
+ 'Please enter a valid IPv4 or IPv6 address.',
+ )}>
+ <SettingsTextInput
+ value={ip}
+ placeholder={messages.pgettext('api-access-methods-view', 'Enter IP')}
+ onUpdate={setIp}
+ validate={validateIp}
+ />
+ </SettingsRow>
+
+ <SettingsRow
+ label={messages.gettext('Port')}
+ errorMessage={messages.pgettext(
+ 'api-access-methods-view',
+ 'Please enter a valid remote server port.',
+ )}>
+ <SettingsNumberInput
+ value={port ?? ''}
+ placeholder={messages.pgettext('api-access-methods-view', 'Enter port')}
+ onUpdate={setPort}
+ validate={validatePort}
+ />
+ </SettingsRow>
+
+ <SettingsRow label={messages.gettext('Password')}>
+ <SettingsTextInput
+ value={password}
+ placeholder={messages.gettext('Optional')}
+ onUpdate={setPassword}
+ optionalInForm
+ />
+ </SettingsRow>
+
+ <SettingsRow label={messages.gettext('Cipher')}>
+ <SettingsSelect
+ data-testid="ciphers"
+ direction="up"
+ defaultValue={cipher}
+ onUpdate={setCipher}
+ items={ciphers}
+ />
+ </SettingsRow>
+ </SettingsGroup>
+ );
+}
+
+function EditSocks5Remote(props: EditProxyProps<Socks5RemoteCustomProxy>) {
+ const [ip, setIp] = useState(props.proxy?.ip ?? '');
+ const [port, setPort] = useState(props.proxy?.port);
+ const [authentication, setAuthentication] = useState(props.proxy?.authentication !== undefined);
+ const [username, setUsername] = useState(props.proxy?.authentication?.username ?? '');
+ const [password, setPassword] = useState(props.proxy?.authentication?.password ?? '');
+
+ // Report back to form component with the proxy values when all required values are set.
+ useEffect(() => {
+ if (
+ ip !== '' &&
+ port !== undefined &&
+ (!authentication || (username !== '' && password !== ''))
+ ) {
+ props.onUpdate({
+ type: 'socks5-remote',
+ ip,
+ port,
+ authentication: authentication ? { username, password } : undefined,
+ });
+ }
+ }, [ip, port, username, password]);
+
+ return (
+ <SettingsGroup title={messages.pgettext('api-access-methods-view', 'Remote Server')}>
+ <SettingsRow
+ label={messages.pgettext('api-access-methods-view', 'Server')}
+ errorMessage={messages.pgettext(
+ 'api-access-methods-view',
+ 'Please enter a valid IPv4 or IPv6 address.',
+ )}>
+ <SettingsTextInput
+ value={ip}
+ placeholder={messages.pgettext('api-access-methods-view', 'Enter IP')}
+ onUpdate={setIp}
+ validate={validateIp}
+ />
+ </SettingsRow>
+
+ <SettingsRow
+ label={messages.gettext('Port')}
+ errorMessage={messages.pgettext(
+ 'api-access-methods-view',
+ 'Please enter a valid remote server port.',
+ )}>
+ <SettingsNumberInput
+ value={port ?? ''}
+ placeholder={messages.pgettext('api-access-methods-view', 'Enter port')}
+ onUpdate={setPort}
+ validate={validatePort}
+ />
+ </SettingsRow>
+
+ <SettingsRow label={messages.pgettext('api-access-methods-view', 'Authentication')}>
+ <Cell.Switch isOn={authentication} onChange={setAuthentication} />
+ </SettingsRow>
+
+ {authentication && (
+ <>
+ <SettingsRow label={messages.gettext('Username')}>
+ <SettingsTextInput
+ value={username}
+ placeholder={messages.gettext('Required')}
+ onUpdate={setUsername}
+ />
+ </SettingsRow>
+
+ <SettingsRow label={messages.gettext('Password')}>
+ <SettingsTextInput
+ value={password}
+ placeholder={messages.gettext('Required')}
+ onUpdate={setPassword}
+ />
+ </SettingsRow>
+ </>
+ )}
+ </SettingsGroup>
+ );
+}
+
+function EditSocks5Local(props: EditProxyProps<Socks5LocalCustomProxy>) {
+ const [remoteIp, setRemoteIp] = useState(props.proxy?.remoteIp ?? '');
+ const [remotePort, setRemotePort] = useState(props.proxy?.remotePort);
+ const [remoteTransportProtocol, setRemoteTransportProtocol] = useState<RelayProtocol>(
+ props.proxy?.remoteTransportProtocol ?? 'tcp',
+ );
+ const [localPort, setLocalPort] = useState(props.proxy?.localPort);
+
+ const remoteTransportProtocols = useMemo<Array<SettingsSelectItem<RelayProtocol>>>(
+ () => [
+ { value: 'tcp', label: 'TCP' },
+ { value: 'udp', label: 'UDP' },
+ ],
+ [],
+ );
+
+ useEffect(() => {
+ if (remoteIp !== '' && remotePort !== undefined && localPort !== undefined) {
+ props.onUpdate({
+ type: 'socks5-local',
+ remoteIp,
+ remotePort,
+ remoteTransportProtocol,
+ localPort,
+ });
+ }
+ }, [remoteIp, remotePort, localPort, remoteTransportProtocol]);
+
+ return (
+ <>
+ <SettingsGroup
+ title={messages.pgettext('api-access-methods-view', 'Local SOCKS5 server')}
+ infoMessage={messages.pgettext(
+ 'api-access-methods-view',
+ 'The TCP port where your local SOCKS5 server is listening.',
+ )}>
+ <SettingsRow
+ label={messages.gettext('Port')}
+ errorMessage={messages.pgettext(
+ 'api-access-methods-view',
+ 'Please enter a valid localhost port.',
+ )}>
+ <SettingsNumberInput
+ value={localPort}
+ placeholder={messages.pgettext('api-access-methods-view', 'Enter port')}
+ onUpdate={setLocalPort}
+ validate={validatePort}
+ />
+ </SettingsRow>
+ </SettingsGroup>
+
+ <SettingsGroup
+ title={messages.pgettext('api-access-methods-view', 'Remote Server')}
+ infoMessage={[
+ messages.pgettext(
+ 'api-access-methods-view',
+ 'The app needs the remote server details, where your local SOCKS5 server will forward your traffic.',
+ ),
+ messages.pgettext(
+ 'api-access-methods-view',
+ 'This is needed so our app can allow that traffic in the firewall.',
+ ),
+ ]}>
+ <SettingsRow
+ label={messages.pgettext('api-access-methods-view', 'Server')}
+ errorMessage={messages.pgettext(
+ 'api-access-methods-view',
+ 'Please enter a valid IPv4 or IPv6 address.',
+ )}>
+ <SettingsTextInput
+ value={remoteIp}
+ placeholder={messages.pgettext('api-access-methods-view', 'Enter IP')}
+ onUpdate={setRemoteIp}
+ validate={validateIp}
+ />
+ </SettingsRow>
+
+ <SettingsRow
+ label={messages.gettext('Port')}
+ errorMessage={messages.pgettext(
+ 'api-access-methods-view',
+ 'Please enter a valid remote server port.',
+ )}>
+ <SettingsNumberInput
+ value={remotePort ?? ''}
+ placeholder={messages.pgettext('api-access-methods-view', 'Enter port')}
+ onUpdate={setRemotePort}
+ validate={validatePort}
+ />
+ </SettingsRow>
+
+ <SettingsRow label={messages.pgettext('api-access-methods-view', 'Transport protocol')}>
+ <SettingsRadioGroup<'tcp' | 'udp'>
+ defaultValue={remoteTransportProtocol}
+ onUpdate={setRemoteTransportProtocol}
+ items={remoteTransportProtocols}
+ />
+ </SettingsRow>
+ </SettingsGroup>
+ </>
+ );
+}
+
+function validateIp(ip: string): boolean {
+ try {
+ void IpAddress.fromString(ip);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function validatePort(port: number): boolean {
+ return port > 0 && port <= 65535;
+}
diff --git a/gui/src/renderer/components/RelayStatusIndicator.tsx b/gui/src/renderer/components/RelayStatusIndicator.tsx
index 549fec4e70..1060121b9e 100644
--- a/gui/src/renderer/components/RelayStatusIndicator.tsx
+++ b/gui/src/renderer/components/RelayStatusIndicator.tsx
@@ -1,13 +1,19 @@
import styled from 'styled-components';
+import { Styles } from 'styled-components/dist/types';
import { colors } from '../../config.json';
import * as Cell from './cell';
-const StyledRelayStatus = styled.div<{ $active: boolean }>((props) => ({
+const indicatorStyles: Styles<
+ React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
+> = {
width: '16px',
height: '16px',
borderRadius: '8px',
margin: '0 12px 0 4px',
+};
+
+const StyledRelayStatus = styled.div<{ $active: boolean }>(indicatorStyles, (props) => ({
backgroundColor: props.$active ? colors.green90 : colors.red95,
}));
@@ -28,3 +34,7 @@ export default function RelayStatusIndicator(props: IProps) {
<StyledRelayStatus $active={props.active} />
);
}
+
+export const SpecialLocationIndicator = styled.div(indicatorStyles, {
+ backgroundColor: colors.white90,
+});
diff --git a/gui/src/renderer/components/SmallButton.tsx b/gui/src/renderer/components/SmallButton.tsx
index 181a557fd2..ba82f15cd4 100644
--- a/gui/src/renderer/components/SmallButton.tsx
+++ b/gui/src/renderer/components/SmallButton.tsx
@@ -37,6 +37,11 @@ const StyledSmallButton = styled.button<{ $color?: SmallButtonColor; disabled?:
borderRadius: '4px',
marginLeft: '12px',
+ [`${SmallButtonGroupStart} &&`]: {
+ marginLeft: 0,
+ marginRight: '12px',
+ },
+
[`${StyledSmallButtonGrid} &&`]: {
marginLeft: 0,
},
@@ -67,6 +72,12 @@ export const SmallButtonGroup = styled.div<{ $noMarginTop?: boolean }>((props) =
marginTop: props.$noMarginTop ? 0 : '30px',
}));
+export const SmallButtonGroupStart = styled(SmallButtonGroup)({
+ flex: 1,
+ justifyContent: 'start',
+ margin: 0,
+});
+
const StyledSmallButtonGrid = styled.div<{ $columns: number }>((props) => ({
display: 'grid',
gridTemplateColumns: `repeat(${props.$columns}, 1fr)`,
diff --git a/gui/src/renderer/components/cell/Selector.tsx b/gui/src/renderer/components/cell/Selector.tsx
index debcdbef38..e527017435 100644
--- a/gui/src/renderer/components/cell/Selector.tsx
+++ b/gui/src/renderer/components/cell/Selector.tsx
@@ -25,6 +25,7 @@ interface CommonSelectorProps<T, U> {
value: T | U;
selectedCellRef?: React.Ref<HTMLElement>;
className?: string;
+ infoTitle?: string;
details?: React.ReactElement;
expandable?: { expandable: boolean; id: string };
disabled?: boolean;
@@ -82,7 +83,7 @@ export default function Selector<T, U>(props: SelectorProps<T, U>) {
</AriaLabel>
{props.details && (
<AriaDetails>
- <InfoButton>{props.details}</InfoButton>
+ <InfoButton title={props.infoTitle}>{props.details}</InfoButton>
</AriaDetails>
)}
</>
diff --git a/gui/src/renderer/components/select-location/CustomLists.tsx b/gui/src/renderer/components/select-location/CustomLists.tsx
index 737d66bf89..ca4638360c 100644
--- a/gui/src/renderer/components/select-location/CustomLists.tsx
+++ b/gui/src/renderer/components/select-location/CustomLists.tsx
@@ -12,7 +12,7 @@ import * as Cell from '../cell';
import { measurements } from '../common-styles';
import { BackAction } from '../KeyboardNavigation';
import SimpleInput from '../SimpleInput';
-import { StyledLocationRowIcon } from './LocationRow';
+import { StyledLocationRowIcon } from './LocationRowStyles';
import { useRelayListContext } from './RelayListContext';
import RelayLocationList from './RelayLocationList';
import { useScrollPositionContext } from './ScrollPositionContext';
diff --git a/gui/src/renderer/components/select-location/LocationRow.tsx b/gui/src/renderer/components/select-location/LocationRow.tsx
index 79b44d372a..f76b5d3f61 100644
--- a/gui/src/renderer/components/select-location/LocationRow.tsx
+++ b/gui/src/renderer/components/select-location/LocationRow.tsx
@@ -1,8 +1,6 @@
import React, { useCallback, useRef } from 'react';
import { sprintf } from 'sprintf-js';
-import styled from 'styled-components';
-import { colors } from '../../../config.json';
import {
compareRelayLocation,
compareRelayLocationGeographical,
@@ -16,11 +14,18 @@ import { useSelector } from '../../redux/store';
import Accordion from '../Accordion';
import * as Cell from '../cell';
import ChevronButton from '../ChevronButton';
-import { measurements, normalText } from '../common-styles';
-import ImageView from '../ImageView';
import RelayStatusIndicator from '../RelayStatusIndicator';
import { AddToListDialog, DeleteConfirmDialog, EditListDialog } from './CustomListDialogs';
import {
+ getButtonColor,
+ StyledHoverIcon,
+ StyledHoverIconButton,
+ StyledLocationRowButton,
+ StyledLocationRowContainer,
+ StyledLocationRowIcon,
+ StyledLocationRowLabel,
+} from './LocationRowStyles';
+import {
CitySpecification,
CountrySpecification,
getLocationChildren,
@@ -28,110 +33,6 @@ import {
RelaySpecification,
} from './select-location-types';
-interface IButtonColorProps {
- $backgroundColor: string;
- $backgroundColorHover: string;
-}
-
-const buttonColor = (props: IButtonColorProps) => {
- return {
- backgroundColor: props.$backgroundColor,
- '&&:not(:disabled):hover': {
- backgroundColor: props.$backgroundColorHover,
- },
- };
-};
-
-export const StyledLocationRowContainer = styled(Cell.Container)({
- display: 'flex',
- padding: 0,
- background: 'none',
-});
-
-export const StyledLocationRowButton = styled(Cell.Row)<IButtonColorProps & { $level: number }>(
- buttonColor,
- (props) => {
- const paddingLeft = (props.$level + 1) * 16 + 2;
-
- return {
- display: 'flex',
- flex: 1,
- overflow: 'hidden',
- border: 'none',
- padding: `0 10px 0 ${paddingLeft}px`,
- margin: 0,
- };
- },
-);
-
-export const StyledLocationRowIcon = styled.button<IButtonColorProps>(buttonColor, {
- position: 'relative',
- alignSelf: 'stretch',
- paddingLeft: measurements.viewMargin,
- paddingRight: measurements.viewMargin,
-
- '&&::before': {
- content: '""',
- position: 'absolute',
- margin: 'auto',
- top: 0,
- left: 0,
- bottom: 0,
- height: '50%',
- width: '1px',
- backgroundColor: colors.darkBlue,
- },
-});
-
-export const StyledLocationRowLabel = styled(Cell.Label)(normalText, {
- flex: 1,
- minWidth: 0,
- fontWeight: 400,
- lineHeight: '24px',
- overflow: 'hidden',
- textOverflow: 'ellipsis',
- whiteSpace: 'nowrap',
-});
-
-const StyledHoverIconButton = styled.button<IButtonColorProps & { $isLast?: boolean }>(
- buttonColor,
- (props) => ({
- flex: 0,
- display: 'none',
- padding: '0 10px',
- paddingRight: props.$isLast ? '17px' : '10px',
- margin: 0,
- border: 0,
- height: measurements.rowMinHeight,
- appearance: 'none',
-
- '&&:last-child': {
- paddingRight: '25px',
- },
-
- '&&:not(:disabled):hover': {
- backgroundColor: props.$backgroundColor,
- },
- [`${StyledLocationRowContainer}:hover &&`]: {
- display: 'block',
- },
- [`${StyledLocationRowButton}:hover ~ &&`]: {
- backgroundColor: props.$backgroundColorHover,
- },
- }),
-);
-
-const StyledHoverIcon = styled(ImageView).attrs({
- width: 18,
- height: 18,
- tintColor: colors.white60,
- tintHoverColor: colors.white,
-})({
- [`${StyledHoverIconButton}:hover &&`]: {
- backgroundColor: colors.white,
- },
-});
-
interface IProps<C extends LocationSpecification> {
source: C;
level: number;
@@ -336,24 +237,6 @@ function LocationRow<C extends LocationSpecification>(props: IProps<C>) {
// a lot more work than necessary
export default React.memo(LocationRow, compareProps);
-export function getButtonColor(selected: boolean, level: number, disabled?: boolean) {
- let backgroundColor = colors.blue60;
- if (selected) {
- backgroundColor = colors.green;
- } else if (level === 1) {
- backgroundColor = colors.blue40;
- } else if (level === 2) {
- backgroundColor = colors.blue20;
- } else if (level === 3) {
- backgroundColor = colors.blue10;
- }
-
- return {
- $backgroundColor: backgroundColor,
- $backgroundColorHover: selected || disabled ? backgroundColor : colors.blue80,
- };
-}
-
function compareProps<C extends LocationSpecification>(
oldProps: IProps<C>,
nextProps: IProps<C>,
diff --git a/gui/src/renderer/components/select-location/LocationRowStyles.tsx b/gui/src/renderer/components/select-location/LocationRowStyles.tsx
new file mode 100644
index 0000000000..6a159f4054
--- /dev/null
+++ b/gui/src/renderer/components/select-location/LocationRowStyles.tsx
@@ -0,0 +1,150 @@
+import styled from 'styled-components';
+import { Styles } from 'styled-components/dist/types';
+
+import { colors } from '../../../config.json';
+import * as Cell from '../cell';
+import { measurements, normalText } from '../common-styles';
+import ImageView from '../ImageView';
+import InfoButton from '../InfoButton';
+
+interface ButtonColorProps {
+ $backgroundColor: string;
+ $backgroundColorHover: string;
+}
+
+export const buttonColor = (props: ButtonColorProps) => {
+ return {
+ backgroundColor: props.$backgroundColor,
+ '&&:not(:disabled):hover': {
+ backgroundColor: props.$backgroundColorHover,
+ },
+ };
+};
+
+export const StyledLocationRowContainer = styled(Cell.Container)({
+ display: 'flex',
+ padding: 0,
+ background: 'none',
+});
+
+export const StyledLocationRowContainerWithMargin = styled(StyledLocationRowContainer)({
+ marginBottom: 1,
+});
+
+export const StyledLocationRowLabel = styled(Cell.Label)(normalText, {
+ flex: 1,
+ minWidth: 0,
+ fontWeight: 400,
+ lineHeight: '24px',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+});
+
+export const StyledLocationRowButton = styled(Cell.Row)<ButtonColorProps & { $level: number }>(
+ buttonColor,
+ (props) => {
+ const paddingLeft = (props.$level + 1) * 16 + 2;
+
+ return {
+ display: 'flex',
+ flex: 1,
+ overflow: 'hidden',
+ border: 'none',
+ padding: `0 10px 0 ${paddingLeft}px`,
+ margin: 0,
+ };
+ },
+);
+
+export const StyledLocationRowIcon = styled.button<ButtonColorProps>(buttonColor, {
+ position: 'relative',
+ alignSelf: 'stretch',
+ paddingLeft: measurements.viewMargin,
+ paddingRight: measurements.viewMargin,
+ border: 0,
+
+ '&&::before': {
+ content: '""',
+ position: 'absolute',
+ margin: 'auto',
+ top: 0,
+ left: 0,
+ bottom: 0,
+ height: '50%',
+ width: '1px',
+ backgroundColor: colors.darkBlue,
+ },
+});
+
+interface HoverButtonProps {
+ $isLast?: boolean;
+}
+
+const hoverButton = (
+ props: ButtonColorProps & HoverButtonProps,
+): Styles<
+ React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>
+> => ({
+ flex: 0,
+ display: 'none',
+ padding: '0 10px',
+ paddingRight: props.$isLast ? '17px' : '10px',
+ margin: 0,
+ border: 0,
+ height: measurements.rowMinHeight,
+ appearance: 'none',
+
+ '&&:last-child': {
+ paddingRight: '25px',
+ },
+
+ '&&:not(:disabled):hover': {
+ backgroundColor: props.$backgroundColor,
+ },
+ [`${StyledLocationRowContainer}:hover &&`]: {
+ display: 'block',
+ },
+ [`${StyledLocationRowButton}:hover ~ &&`]: {
+ backgroundColor: props.$backgroundColorHover,
+ },
+});
+
+export const StyledHoverIconButton = styled.button<ButtonColorProps & HoverButtonProps>(
+ buttonColor,
+ hoverButton,
+);
+
+export const StyledHoverIcon = styled(ImageView).attrs({
+ width: 18,
+ height: 18,
+ tintColor: colors.white60,
+ tintHoverColor: colors.white,
+})({
+ [`${StyledHoverIconButton}:hover &&`]: {
+ backgroundColor: colors.white,
+ },
+});
+
+export const StyledHoverInfoButton = styled(InfoButton)<ButtonColorProps & HoverButtonProps>(
+ buttonColor,
+ hoverButton,
+);
+
+export function getButtonColor(selected: boolean, level: number, disabled?: boolean) {
+ let backgroundColor = colors.blue60;
+ if (selected) {
+ backgroundColor = colors.green;
+ } else if (level === 1) {
+ backgroundColor = colors.blue40;
+ } else if (level === 2) {
+ backgroundColor = colors.blue20;
+ } else if (level === 3) {
+ backgroundColor = colors.blue10;
+ }
+
+ return {
+ $backgroundColor: backgroundColor,
+ $backgroundColorHover: selected || disabled ? backgroundColor : colors.blue80,
+ };
+}
diff --git a/gui/src/renderer/components/select-location/RelayListContext.tsx b/gui/src/renderer/components/select-location/RelayListContext.tsx
index d0ee5d6e85..86dd7e5467 100644
--- a/gui/src/renderer/components/select-location/RelayListContext.tsx
+++ b/gui/src/renderer/components/select-location/RelayListContext.tsx
@@ -135,6 +135,15 @@ function useRelayList(
const selectedLocation = useSelectedLocation();
const disabledLocation = useDisabledLocation();
+ const preventDueToCustomBridgeSelected = usePreventDueToCustomBridgeSelected();
+
+ const isLocationSelected = useCallback(
+ (location: RelayLocation) => {
+ return preventDueToCustomBridgeSelected ? false : isSelected(location, selectedLocation);
+ },
+ [preventDueToCustomBridgeSelected, selectedLocation],
+ );
+
return useMemo(() => {
return relayList
.map((country) => {
@@ -149,7 +158,7 @@ function useRelayList(
disabled: countryDisabledReason !== undefined,
disabledReason: countryDisabledReason,
expanded: isExpanded(countryLocation, expandedLocations),
- selected: isSelected(countryLocation, selectedLocation),
+ selected: isLocationSelected(countryLocation),
cities: country.cities
.map((city) => {
const cityLocation: RelayLocation = { country: country.code, city: city.code };
@@ -164,7 +173,7 @@ function useRelayList(
disabled: cityDisabledReason !== undefined,
disabledReason: cityDisabledReason,
expanded: isExpanded(cityLocation, expandedLocations),
- selected: isSelected(cityLocation, selectedLocation),
+ selected: isLocationSelected(cityLocation),
relays: city.relays
.map((relay) => {
const relayLocation: RelayLocation = {
@@ -183,7 +192,7 @@ function useRelayList(
location: relayLocation,
disabled: relayDisabledReason !== undefined,
disabledReason: relayDisabledReason,
- selected: isSelected(relayLocation, selectedLocation),
+ selected: isLocationSelected(relayLocation),
};
})
.sort((a, b) => a.hostname.localeCompare(b.hostname, locale, { numeric: true })),
@@ -193,7 +202,17 @@ function useRelayList(
};
})
.sort((a, b) => a.label.localeCompare(b.label, locale));
- }, [locale, expandedLocations, relayList, selectedLocation, disabledLocation]);
+ }, [locale, expandedLocations, relayList, disabledLocation, isLocationSelected]);
+}
+
+export function usePreventDueToCustomBridgeSelected(): boolean {
+ const relaySettings = useNormalRelaySettings();
+ const { locationType } = useSelectLocationContext();
+ const bridgeSettings = useSelector((state) => state.settings.bridgeSettings);
+ const isBridgeSelection =
+ relaySettings?.tunnelProtocol === 'openvpn' && locationType === LocationType.entry;
+
+ return isBridgeSelection && bridgeSettings.type === 'custom';
}
// Return all RelayLocations that should be expanded
diff --git a/gui/src/renderer/components/select-location/SelectLocation.tsx b/gui/src/renderer/components/select-location/SelectLocation.tsx
index 6bf0fcff6f..5ba0203939 100644
--- a/gui/src/renderer/components/select-location/SelectLocation.tsx
+++ b/gui/src/renderer/components/select-location/SelectLocation.tsx
@@ -9,7 +9,7 @@ import { filterSpecialLocations } from '../../lib/filter-locations';
import { useHistory } from '../../lib/history';
import { formatHtml } from '../../lib/html-formatter';
import { RoutePath } from '../../lib/routes';
-import { useNormalBridgeSettings, useNormalRelaySettings } from '../../lib/utilityHooks';
+import { useNormalRelaySettings } from '../../lib/utilityHooks';
import { useSelector } from '../../redux/store';
import * as Cell from '../cell';
import { useFilteredProviders } from '../Filter';
@@ -34,12 +34,7 @@ import {
useOnSelectEntryLocation,
useOnSelectExitLocation,
} from './select-location-hooks';
-import {
- LocationType,
- SpecialBridgeLocationType,
- SpecialLocation,
- SpecialLocationIcon,
-} from './select-location-types';
+import { LocationType, SpecialBridgeLocationType, SpecialLocation } from './select-location-types';
import { useSelectLocationContext } from './SelectLocationContainer';
import {
StyledClearFilterButton,
@@ -54,6 +49,11 @@ import {
StyledSearchBar,
} from './SelectLocationStyles';
import { SpacePreAllocationView } from './SpacePreAllocationView';
+import {
+ AutomaticLocationRow,
+ CustomBridgeLocationRow,
+ CustomExitLocationRow,
+} from './SpecialLocationList';
export default function SelectLocation() {
const history = useHistory();
@@ -260,22 +260,21 @@ function SelectLocationContent() {
const [onSelectBridgeRelay, onSelectBridgeSpecial] = useOnSelectBridgeLocation();
const relaySettings = useNormalRelaySettings();
- const bridgeSettings = useNormalBridgeSettings();
+ const bridgeSettings = useSelector((state) => state.settings.bridgeSettings);
const allowAddToCustomList = useSelector((state) => state.settings.customLists.length > 0);
if (locationType === LocationType.exit) {
// Add "Custom" item if a custom relay is selected
- const specialList: Array<SpecialLocation<undefined>> =
- relaySettings === undefined
- ? [
- {
- label: messages.gettext('Custom'),
- value: undefined,
- selected: true,
- },
- ]
- : [];
+ const specialList: Array<SpecialLocation<undefined>> = [];
+ if (relaySettings === undefined) {
+ specialList.push({
+ label: messages.gettext('Custom'),
+ value: undefined,
+ selected: true,
+ component: CustomExitLocationRow,
+ });
+ }
const specialLocations = filterSpecialLocations(searchTerm, specialList);
return (
@@ -320,14 +319,17 @@ function SelectLocationContent() {
// Add the "Automatic" item
const specialList: Array<SpecialLocation<SpecialBridgeLocationType>> = [
{
+ label: messages.pgettext('select-location-view', 'Custom bridge'),
+ value: SpecialBridgeLocationType.custom,
+ selected: bridgeSettings?.type === 'custom',
+ disabled: bridgeSettings?.custom === undefined,
+ component: CustomBridgeLocationRow,
+ },
+ {
label: messages.gettext('Automatic'),
- icon: SpecialLocationIcon.geoLocation,
- info: messages.pgettext(
- 'select-location-view',
- 'The app selects a random bridge server, but servers have a higher probability the closer they are to you.',
- ),
value: SpecialBridgeLocationType.closestToExit,
- selected: bridgeSettings?.location === 'any',
+ selected: bridgeSettings?.type === 'normal' && bridgeSettings.normal?.location === 'any',
+ component: AutomaticLocationRow,
},
];
diff --git a/gui/src/renderer/components/select-location/SelectLocationContainer.tsx b/gui/src/renderer/components/select-location/SelectLocationContainer.tsx
index 5843d5e6f4..1d0e5251f7 100644
--- a/gui/src/renderer/components/select-location/SelectLocationContainer.tsx
+++ b/gui/src/renderer/components/select-location/SelectLocationContainer.tsx
@@ -1,5 +1,8 @@
import React, { useContext, useMemo, useState } from 'react';
+import useActions from '../../lib/actionsHook';
+import { useSelector } from '../../redux/store';
+import userInterface from '../../redux/userinterface/actions';
import { RelayListContextProvider } from './RelayListContext';
import { ScrollPositionContextProvider } from './ScrollPositionContext';
import { LocationType } from './select-location-types';
@@ -20,13 +23,14 @@ export function useSelectLocationContext() {
}
export default function SelectLocationContainer() {
- const [locationType, setLocationType] = useState(LocationType.exit);
+ const locationType = useSelector((state) => state.userInterface.selectLocationView);
+ const { setSelectLocationView } = useActions(userInterface);
const [searchTerm, setSearchTerm] = useState('');
- const value = useMemo(() => ({ locationType, setLocationType, searchTerm, setSearchTerm }), [
- locationType,
- searchTerm,
- ]);
+ const value = useMemo(
+ () => ({ locationType, setLocationType: setSelectLocationView, searchTerm, setSearchTerm }),
+ [locationType, searchTerm],
+ );
return (
<selectLocationContext.Provider value={value}>
diff --git a/gui/src/renderer/components/select-location/SpecialLocationList.tsx b/gui/src/renderer/components/select-location/SpecialLocationList.tsx
index 030520fc73..9579a6b4b5 100644
--- a/gui/src/renderer/components/select-location/SpecialLocationList.tsx
+++ b/gui/src/renderer/components/select-location/SpecialLocationList.tsx
@@ -3,16 +3,21 @@ import styled from 'styled-components';
import { colors } from '../../../config.json';
import { messages } from '../../../shared/gettext';
-import * as Cell from '../cell';
+import { useHistory } from '../../lib/history';
+import { RoutePath } from '../../lib/routes';
+import { useSelector } from '../../redux/store';
+import ImageView from '../ImageView';
import InfoButton from '../InfoButton';
+import { SpecialLocationIndicator } from '../RelayStatusIndicator';
import {
getButtonColor,
+ StyledHoverInfoButton,
StyledLocationRowButton,
- StyledLocationRowContainer,
+ StyledLocationRowContainerWithMargin,
StyledLocationRowIcon,
StyledLocationRowLabel,
-} from './LocationRow';
-import { SpecialLocation } from './select-location-types';
+} from './LocationRowStyles';
+import { SpecialBridgeLocationType, SpecialLocation } from './select-location-types';
interface SpecialLocationsProps<T> {
source: Array<SpecialLocation<T>>;
@@ -30,21 +35,8 @@ export default function SpecialLocationList<T>({ source, ...props }: SpecialLoca
);
}
-const StyledLocationRowContainerWithMargin = styled(StyledLocationRowContainer)({
- marginBottom: 1,
-});
-
-const StyledSpecialLocationIcon = styled(Cell.Icon)({
- flex: 0,
- marginLeft: '2px',
- marginRight: '8px',
-});
-
-const StyledSpecialLocationInfoButton = styled(InfoButton)({
- margin: 0,
- padding: '0 25px',
- backgroundColor: colors.blue,
-});
+const StyledSpecialLocationInfoButton = styled(InfoButton)({ padding: '0 25px', margin: 0 });
+const StyledSpecialLocationSideButton = styled(ImageView)({ padding: '0 3px' });
interface SpecialLocationRowProps<T> {
source: SpecialLocation<T>;
@@ -59,30 +51,98 @@ function SpecialLocationRow<T>(props: SpecialLocationRowProps<T>) {
}
}, [props.source.selected, props.onSelect, props.source.value]);
- const icon = props.source.selected ? 'icon-tick' : props.source.icon ?? undefined;
+ const innerProps: SpecialLocationRowInnerProps<T> = {
+ ...props,
+ onSelect,
+ };
+ return <props.source.component {...innerProps} />;
+}
+
+export interface SpecialLocationRowInnerProps<T>
+ extends Omit<SpecialLocationRowProps<T>, 'onSelect'> {
+ onSelect: () => void;
+}
+
+export function AutomaticLocationRow(
+ props: SpecialLocationRowInnerProps<SpecialBridgeLocationType>,
+) {
const selectedRef = props.source.selected ? props.selectedElementRef : undefined;
const background = getButtonColor(props.source.selected, 0, props.source.disabled);
return (
<StyledLocationRowContainerWithMargin ref={selectedRef}>
- <StyledLocationRowButton onClick={onSelect} $level={0} {...background}>
- {icon && (
- <StyledSpecialLocationIcon
- source={icon}
- tintColor={colors.white}
- height={22}
- width={22}
- />
+ <StyledLocationRowButton onClick={props.onSelect} $level={0} {...background}>
+ <SpecialLocationIndicator />
+ <StyledLocationRowLabel>{props.source.label}</StyledLocationRowLabel>
+ </StyledLocationRowButton>
+ <StyledLocationRowIcon
+ as={StyledSpecialLocationInfoButton}
+ title={messages.gettext('Automatic')}
+ message={messages.pgettext(
+ 'select-location-view',
+ 'The app selects a random bridge server, but servers have a higher probability the closer they are to you.',
)}
+ aria-label={messages.pgettext('accessibility', 'info')}
+ {...background}
+ />
+ </StyledLocationRowContainerWithMargin>
+ );
+}
+
+export function CustomExitLocationRow(props: SpecialLocationRowInnerProps<undefined>) {
+ const selectedRef = props.source.selected ? props.selectedElementRef : undefined;
+ const background = getButtonColor(props.source.selected, 0, props.source.disabled);
+ return (
+ <StyledLocationRowContainerWithMargin ref={selectedRef}>
+ <StyledLocationRowButton $level={0} {...background}>
<StyledLocationRowLabel>{props.source.label}</StyledLocationRowLabel>
</StyledLocationRowButton>
- {props.source.info && (
- <StyledLocationRowIcon
- as={StyledSpecialLocationInfoButton}
- message={props.source.info}
- aria-label={messages.pgettext('accessibility', 'info')}
- {...background}
+ </StyledLocationRowContainerWithMargin>
+ );
+}
+
+const StyledInfoButton = styled(StyledHoverInfoButton)({ display: 'block' });
+
+export function CustomBridgeLocationRow(
+ props: SpecialLocationRowInnerProps<SpecialBridgeLocationType>,
+) {
+ const history = useHistory();
+
+ const bridgeSettings = useSelector((state) => state.settings.bridgeSettings);
+ const icon = bridgeSettings.custom !== undefined ? 'icon-edit' : 'icon-add';
+
+ const selectedRef = props.source.selected ? props.selectedElementRef : undefined;
+ const background = getButtonColor(props.source.selected, 0, props.source.disabled);
+
+ const navigate = useCallback(() => history.push(RoutePath.editCustomBridge), [history.push]);
+
+ return (
+ <StyledLocationRowContainerWithMargin ref={selectedRef} disabled={props.source.disabled}>
+ <StyledLocationRowButton
+ as="button"
+ onClick={props.onSelect}
+ $level={0}
+ disabled={props.source.disabled}
+ {...background}>
+ <SpecialLocationIndicator />
+ <StyledLocationRowLabel>{props.source.label}</StyledLocationRowLabel>
+ </StyledLocationRowButton>
+ <StyledInfoButton
+ {...background}
+ $isLast
+ title={messages.pgettext('select-location-view', 'Custom bridge')}
+ message={messages.pgettext(
+ 'select-location-view',
+ 'A custom bridge server can be used to circumvent censorship when regular Mullvad bridge servers don’t work.',
+ )}
+ />
+ <StyledLocationRowIcon {...background} onClick={navigate}>
+ <StyledSpecialLocationSideButton
+ source={icon}
+ width={18}
+ tintColor={colors.white}
+ tintHoverColor={colors.white80}
/>
- )}
+ </StyledLocationRowIcon>
</StyledLocationRowContainerWithMargin>
);
}
diff --git a/gui/src/renderer/components/select-location/custom-list-helpers.ts b/gui/src/renderer/components/select-location/custom-list-helpers.ts
index 799deb8ed3..5cb6d695b8 100644
--- a/gui/src/renderer/components/select-location/custom-list-helpers.ts
+++ b/gui/src/renderer/components/select-location/custom-list-helpers.ts
@@ -4,7 +4,11 @@ import { ICustomList, RelayLocation } from '../../../shared/daemon-rpc-types';
import { hasValue } from '../../../shared/utils';
import { searchMatch } from '../../lib/filter-locations';
import { useSelector } from '../../redux/store';
-import { useDisabledLocation, useSelectedLocation } from './RelayListContext';
+import {
+ useDisabledLocation,
+ usePreventDueToCustomBridgeSelected,
+ useSelectedLocation,
+} from './RelayListContext';
import { isCustomListDisabled, isExpanded, isSelected } from './select-location-helpers';
import {
CitySpecification,
@@ -26,6 +30,8 @@ export function useCustomListsRelayList(
const { searchTerm } = useSelectLocationContext();
const customLists = useSelector((state) => state.settings.customLists);
+ const preventDueToCustomBridgeSelected = usePreventDueToCustomBridgeSelected();
+
// Populate all custom lists with the real location trees for the list locations.
return useMemo(
() =>
@@ -34,6 +40,7 @@ export function useCustomListsRelayList(
list,
relayList,
searchTerm,
+ preventDueToCustomBridgeSelected,
selectedLocation,
disabledLocation,
expandedLocations,
@@ -48,6 +55,7 @@ function prepareCustomList(
list: ICustomList,
fullRelayList: GeographicalRelayList,
searchTerm: string,
+ preventDueToCustomBridgeSelected: boolean,
selectedLocation?: RelayLocation,
disabledLocation?: { location: RelayLocation; reason: DisabledReason },
expandedLocations?: Array<RelayLocation>,
@@ -64,7 +72,7 @@ function prepareCustomList(
disabled: disabledReason !== undefined,
disabledReason,
expanded: isExpanded(location, expandedLocations),
- selected: isSelected(location, selectedLocation),
+ selected: preventDueToCustomBridgeSelected ? false : isSelected(location, selectedLocation),
visible: searchMatch(searchTerm, list.name),
locations,
};
diff --git a/gui/src/renderer/components/select-location/select-location-hooks.ts b/gui/src/renderer/components/select-location/select-location-hooks.ts
index b795f4a74f..08ed2dccf3 100644
--- a/gui/src/renderer/components/select-location/select-location-hooks.ts
+++ b/gui/src/renderer/components/select-location/select-location-hooks.ts
@@ -1,6 +1,5 @@
import { useCallback } from 'react';
-import BridgeSettingsBuilder from '../../../shared/bridge-settings-builder';
import {
BridgeSettings,
RelayLocation,
@@ -10,8 +9,8 @@ import {
import log from '../../../shared/logging';
import { useAppContext } from '../../context';
import { useRelaySettingsModifier } from '../../lib/constraint-updater';
+import { useBridgeSettingsModifier } from '../../lib/constraint-updater';
import { useHistory } from '../../lib/history';
-import { useSelector } from '../../redux/store';
import { LocationType, SpecialBridgeLocationType } from './select-location-types';
import { useSelectLocationContext } from './SelectLocationContainer';
@@ -89,7 +88,7 @@ function useOnSelectLocation() {
export function useOnSelectBridgeLocation() {
const { updateBridgeSettings } = useAppContext();
const { setLocationType } = useSelectLocationContext();
- const bridgeSettings = useSelector((state) => state.settings.bridgeSettings);
+ const bridgeSettingsModifier = useBridgeSettingsModifier();
const setLocation = useCallback(async (bridgeUpdate: BridgeSettings) => {
if (bridgeUpdate) {
@@ -105,21 +104,38 @@ export function useOnSelectBridgeLocation() {
const onSelectRelay = useCallback(
(location: RelayLocation) => {
- const bridgeUpdate = new BridgeSettingsBuilder().location.fromRaw(location).build();
- bridgeUpdate.custom = bridgeSettings.custom;
- return setLocation(bridgeUpdate);
+ return setLocation(
+ bridgeSettingsModifier((bridgeSettings) => {
+ bridgeSettings.type = 'normal';
+ bridgeSettings.normal.location = wrapConstraint(location);
+ return bridgeSettings;
+ }),
+ );
},
- [bridgeSettings],
+ [bridgeSettingsModifier],
);
- const onSelectSpecial = useCallback((location: SpecialBridgeLocationType) => {
- switch (location) {
- case SpecialBridgeLocationType.closestToExit: {
- const bridgeUpdate = new BridgeSettingsBuilder().location.any().build();
- return setLocation(bridgeUpdate);
+ const onSelectSpecial = useCallback(
+ (location: SpecialBridgeLocationType) => {
+ switch (location) {
+ case SpecialBridgeLocationType.closestToExit:
+ return setLocation(
+ bridgeSettingsModifier((bridgeSettings) => {
+ bridgeSettings.normal.location = 'any';
+ return bridgeSettings;
+ }),
+ );
+ case SpecialBridgeLocationType.custom:
+ return setLocation(
+ bridgeSettingsModifier((bridgeSettings) => {
+ bridgeSettings.type = 'custom';
+ return bridgeSettings;
+ }),
+ );
}
- }
- }, []);
+ },
+ [bridgeSettingsModifier],
+ );
return [onSelectRelay, onSelectSpecial] as const;
}
diff --git a/gui/src/renderer/components/select-location/select-location-types.ts b/gui/src/renderer/components/select-location/select-location-types.ts
index c42cc45f6b..ca6afecf74 100644
--- a/gui/src/renderer/components/select-location/select-location-types.ts
+++ b/gui/src/renderer/components/select-location/select-location-types.ts
@@ -11,6 +11,7 @@ import {
IRelayLocationCountryRedux,
IRelayLocationRelayRedux,
} from '../../redux/settings/reducers';
+import { SpecialLocationRowInnerProps } from './SpecialLocationList';
export enum LocationType {
entry = 0,
@@ -21,11 +22,8 @@ export type RelayList = GeographicalRelayList | Array<CustomListSpecification>;
export type GeographicalRelayList = Array<CountrySpecification>;
export enum SpecialBridgeLocationType {
- closestToExit = 0,
-}
-
-export enum SpecialLocationIcon {
- geoLocation = 'icon-nearest',
+ closestToExit,
+ custom,
}
export interface LocationVisibility {
@@ -40,9 +38,8 @@ interface CommonLocationSpecification {
}
export interface SpecialLocation<T> extends CommonLocationSpecification {
- icon?: SpecialLocationIcon;
- info?: string;
value: T;
+ component: React.ComponentType<SpecialLocationRowInnerProps<T>>;
}
type GeographicalLocationSpecification =