diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2024-04-11 18:11:33 +0200 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2024-04-11 18:11:33 +0200 |
| commit | 9aaebecc3c6d1838722a66875b5a330ca7967020 (patch) | |
| tree | c877bcc16c9eb3e325aecdd5f2861b9e6f5824a5 /gui/src/renderer/components | |
| parent | d2e198cd8ce15225d4329cbb61f4561e293ea239 (diff) | |
| parent | 36322705705857c549fa90616884bc64740cbbef (diff) | |
| download | mullvadvpn-9aaebecc3c6d1838722a66875b5a330ca7967020.tar.xz mullvadvpn-9aaebecc3c6d1838722a66875b5a330ca7967020.zip | |
Merge branch 'add-custom-bridge-to-gui-des-431'
Diffstat (limited to 'gui/src/renderer/components')
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 = |
