diff options
| author | Tobias Järvelöv <tobias.jarvelov@mullvad.net> | 2025-09-22 12:41:42 +0200 |
|---|---|---|
| committer | Tobias Järvelöv <tobias.jarvelov@mullvad.net> | 2025-09-22 12:41:42 +0200 |
| commit | cf4f60d9414bf4eeb1cb9b00023f7547fd5a907a (patch) | |
| tree | 0b067cdfcca0222e2cbaa34cd20ef2571cfccdd8 | |
| parent | 2444347e63eb48cea554facedd8568f0ead0ff7f (diff) | |
| parent | 0a5bd6b61906892a109d91f9cf8b2405355016d8 (diff) | |
| download | mullvadvpn-cf4f60d9414bf4eeb1cb9b00023f7547fd5a907a.tar.xz mullvadvpn-cf4f60d9414bf4eeb1cb9b00023f7547fd5a907a.zip | |
Merge branch 'quick-access-to-active-feature-setting'
300 files changed, 5829 insertions, 2999 deletions
diff --git a/desktop/packages/mullvad-vpn/locales/messages.pot b/desktop/packages/mullvad-vpn/locales/messages.pot index ba8742fb5d..c6b01ba9d2 100644 --- a/desktop/packages/mullvad-vpn/locales/messages.pot +++ b/desktop/packages/mullvad-vpn/locales/messages.pot @@ -2521,7 +2521,7 @@ msgstr "" #. Available placeholders: #. %(customDnsFeatureName)s - The name displayed next to the custom DNS toggle. msgctxt "vpn-settings-view" -msgid "Disable <b>%(customDnsFeatureName)s</b> below to activate these settings." +msgid "Disable \"%(customDnsFeatureName)s\" below to activate these settings." msgstr "" #. This is displayed when either or both of the block ads/trackers settings are @@ -2714,6 +2714,7 @@ msgctxt "wireguard-settings-view" msgid "If an observer monitors these data packets, %(daita)s makes it significantly harder for them to identify which websites you are visiting or with whom you are communicating." msgstr "" +#. The title for the WireGuard IP version selector. msgctxt "wireguard-settings-view" msgid "IP version" msgstr "" @@ -2722,6 +2723,9 @@ msgctxt "wireguard-settings-view" msgid "It does this by performing an extra key exchange using a quantum safe algorithm and mixing the result into WireGuard’s regular encryption. This extra step uses approximately 500 kiB of traffic every time a new tunnel is established." msgstr "" +#. The title for the WireGuard MTU setting. MTU stands for Maximum +#. Transmission Unit and controls the maximum size of packets sent over +#. the VPN tunnel. msgctxt "wireguard-settings-view" msgid "MTU" msgstr "" @@ -2731,6 +2735,10 @@ msgid "Multihop" msgstr "" msgctxt "wireguard-settings-view" +msgid "Multihop is being used to enable DAITA for your selected location" +msgstr "" + +msgctxt "wireguard-settings-view" msgid "Multihop routes your traffic into one WireGuard server and out another, making it harder to trace. This results in increased latency but increases anonymity online." msgstr "" @@ -2743,6 +2751,7 @@ msgctxt "wireguard-settings-view" msgid "Not all our servers are %(daita)s-enabled. Therefore, we use multihop automatically to enable %(daita)s with any server." msgstr "" +#. The title for the WireGuard obfuscation selector. msgctxt "wireguard-settings-view" msgid "Obfuscation" msgstr "" @@ -2753,6 +2762,7 @@ msgctxt "wireguard-settings-view" msgid "Obfuscation hides the WireGuard traffic inside another protocol. It can be used to help circumvent censorship and other types of filtering, where a plain WireGuard connection would be blocked." msgstr "" +#. The title for the WireGuard port selector. msgctxt "wireguard-settings-view" msgid "Port" msgstr "" @@ -2815,10 +2825,6 @@ msgctxt "wireguard-settings-view" msgid "UDP-over-TCP" msgstr "" -msgctxt "wireguard-settings-view" -msgid "UDP-over-TCP port" -msgstr "" - #. Text describing the valid port range for a port selector. msgctxt "wireguard-settings-view" msgid "Valid range: %(min)s - %(max)s" @@ -3023,9 +3029,6 @@ msgstr "" msgid "Device IP version" msgstr "" -msgid "Disable \"%s\" below to activate these settings." -msgstr "" - msgid "Disable all \"%s\" above to activate this setting." msgstr "" diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx index 7c72b833f9..5f5f2c4082 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx @@ -6,7 +6,6 @@ import SelectLocation from '../components/select-location/SelectLocationContaine import { useViewTransitions } from '../lib/transition-hooks'; import Account from './Account'; import ApiAccessMethods from './ApiAccessMethods'; -import DaitaSettings from './DaitaSettings'; import Debug from './Debug'; import { DeviceRevokedView } from './DeviceRevokedView'; import { EditApiAccessMethod } from './EditApiAccessMethod'; @@ -20,29 +19,30 @@ import { import ExpiredAccountErrorView from './ExpiredAccountErrorView'; import Filter from './Filter'; import Focus, { IFocusHandle } from './Focus'; -import MainView from './main-view/MainView'; -import MultihopSettings from './MultihopSettings'; -import OpenVpnSettings from './OpenVpnSettings'; import ProblemReport from './ProblemReport'; import SelectLanguage from './SelectLanguage'; import SettingsImport from './SettingsImport'; import SettingsTextImport from './SettingsTextImport'; -import Shadowsocks from './Shadowsocks'; import Support from './Support'; import TooManyDevices from './TooManyDevices'; -import UdpOverTcp from './UdpOverTcp'; import UserInterfaceSettings from './UserInterfaceSettings'; import { AppInfoView, AppUpgradeView, ChangelogView, + DaitaSettingsView, LaunchView, LoginView, + MainView, + MultihopSettingsView, + OpenVpnSettingsView, SettingsView, + ShadowsocksSettingsView, SplitTunnelingView, + UdpOverTcpSettingsView, + VpnSettingsView, + WireguardSettingsView, } from './views'; -import VpnSettings from './VpnSettings'; -import WireguardSettings from './WireguardSettings'; export default function AppRouter() { const focusRef = useRef<IFocusHandle>(null); @@ -69,13 +69,13 @@ export default function AppRouter() { <Route exact path={RoutePath.settings} component={SettingsView} /> <Route exact path={RoutePath.selectLanguage} component={SelectLanguage} /> <Route exact path={RoutePath.userInterfaceSettings} component={UserInterfaceSettings} /> - <Route exact path={RoutePath.multihopSettings} component={MultihopSettings} /> - <Route exact path={RoutePath.vpnSettings} component={VpnSettings} /> - <Route exact path={RoutePath.wireguardSettings} component={WireguardSettings} /> - <Route exact path={RoutePath.daitaSettings} component={DaitaSettings} /> - <Route exact path={RoutePath.udpOverTcp} component={UdpOverTcp} /> - <Route exact path={RoutePath.shadowsocks} component={Shadowsocks} /> - <Route exact path={RoutePath.openVpnSettings} component={OpenVpnSettings} /> + <Route exact path={RoutePath.multihopSettings} component={MultihopSettingsView} /> + <Route exact path={RoutePath.vpnSettings} component={VpnSettingsView} /> + <Route exact path={RoutePath.wireguardSettings} component={WireguardSettingsView} /> + <Route exact path={RoutePath.daitaSettings} component={DaitaSettingsView} /> + <Route exact path={RoutePath.udpOverTcp} component={UdpOverTcpSettingsView} /> + <Route exact path={RoutePath.shadowsocks} component={ShadowsocksSettingsView} /> + <Route exact path={RoutePath.openVpnSettings} component={OpenVpnSettingsView} /> <Route exact path={RoutePath.splitTunneling} component={SplitTunnelingView} /> <Route exact path={RoutePath.apiAccessMethods} component={ApiAccessMethods} /> <Route exact path={RoutePath.settingsImport} component={SettingsImport} /> diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/Focus.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/Focus.tsx index 116c2ccb11..f7496fd8cb 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/Focus.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/Focus.tsx @@ -5,8 +5,6 @@ import styled from 'styled-components'; import { messages } from '../../shared/gettext'; -const FOCUS_FALLBACK_CLASS = 'focus-fallback'; - const PageChangeAnnouncer = styled.div({ width: 0, height: 0, @@ -33,13 +31,6 @@ function Focus(props: IFocusProps, ref: React.Ref<IFocusHandle>) { const titleElement = document.getElementsByTagName('h1')[0]; const titleContent = titleElement?.textContent ?? pageName; setTitle(titleContent); - - const focusElement = - titleElement ?? document.getElementsByClassName(FOCUS_FALLBACK_CLASS)[0]; - if (focusElement) { - focusElement.setAttribute('tabindex', '-1'); - focusElement.focus(); - } }, }), [location.pathname], @@ -64,13 +55,3 @@ function Focus(props: IFocusProps, ref: React.Ref<IFocusHandle>) { } export default React.memo(React.forwardRef(Focus)); - -interface IFocusFallbackProps { - children: React.ReactElement<React.HTMLAttributes<HTMLElement>>; -} - -export function FocusFallback(props: IFocusFallbackProps) { - return React.cloneElement(props.children, { - className: `${props.children.props.className} ${FOCUS_FALLBACK_CLASS}`, - }); -} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/MultihopSettings.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/MultihopSettings.tsx deleted file mode 100644 index 75379f579f..0000000000 --- a/desktop/packages/mullvad-vpn/src/renderer/components/MultihopSettings.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { useCallback } from 'react'; -import { sprintf } from 'sprintf-js'; - -import { strings } from '../../shared/constants'; -import { messages } from '../../shared/gettext'; -import log from '../../shared/logging'; -import { Flex } from '../lib/components'; -import { useRelaySettingsUpdater } from '../lib/constraint-updater'; -import { useHistory } from '../lib/history'; -import { useSelector } from '../redux/store'; -import { AppNavigationHeader } from './'; -import { AriaDescription, AriaInput, AriaInputGroup, AriaLabel } from './AriaGroup'; -import * as Cell from './cell'; -import { StyledIllustration } from './DaitaSettings'; -import { BackAction } from './KeyboardNavigation'; -import { Layout, SettingsContainer } from './Layout'; -import { NavigationContainer } from './NavigationContainer'; -import { NavigationScrollbars } from './NavigationScrollbars'; -import SettingsHeader, { HeaderSubTitle, HeaderTitle } from './SettingsHeader'; - -const PATH_PREFIX = process.env.NODE_ENV === 'development' ? '../' : ''; - -export default function MultihopSettings() { - const { pop } = useHistory(); - - return ( - <BackAction action={pop}> - <Layout> - <SettingsContainer> - <NavigationContainer> - <AppNavigationHeader title={messages.pgettext('wireguard-settings-view', 'Multihop')} /> - - <NavigationScrollbars> - <SettingsHeader> - <HeaderTitle> - {messages.pgettext('wireguard-settings-view', 'Multihop')} - </HeaderTitle> - <HeaderSubTitle> - <StyledIllustration - src={`${PATH_PREFIX}assets/images/multihop-illustration.svg`} - /> - {messages.pgettext( - 'wireguard-settings-view', - 'Multihop routes your traffic into one WireGuard server and out another, making it harder to trace. This results in increased latency but increases anonymity online.', - )} - </HeaderSubTitle> - </SettingsHeader> - - <Flex $flexDirection="column" $flex={1}> - <Cell.Group> - <MultihopSetting /> - </Cell.Group> - </Flex> - </NavigationScrollbars> - </NavigationContainer> - </SettingsContainer> - </Layout> - </BackAction> - ); -} - -function MultihopSetting() { - const relaySettings = useSelector((state) => state.settings.relaySettings); - const relaySettingsUpdater = useRelaySettingsUpdater(); - - const multihop = 'normal' in relaySettings ? relaySettings.normal.wireguard.useMultihop : false; - const unavailable = - 'normal' in relaySettings ? relaySettings.normal.tunnelProtocol === 'openvpn' : true; - - const setMultihop = useCallback( - async (enabled: boolean) => { - try { - await relaySettingsUpdater((settings) => { - settings.wireguardConstraints.useMultihop = enabled; - return settings; - }); - } catch (e) { - const error = e as Error; - log.error('Failed to update WireGuard multihop settings', error.message); - } - }, - [relaySettingsUpdater], - ); - - return ( - <> - <AriaInputGroup> - <Cell.Container disabled={unavailable}> - <AriaLabel> - <Cell.InputLabel>{messages.gettext('Enable')}</Cell.InputLabel> - </AriaLabel> - <AriaInput> - <Cell.Switch isOn={multihop && !unavailable} onChange={setMultihop} /> - </AriaInput> - </Cell.Container> - {unavailable ? ( - <Cell.CellFooter> - <AriaDescription> - <Cell.CellFooterText>{featureUnavailableMessage()}</Cell.CellFooterText> - </AriaDescription> - </Cell.CellFooter> - ) : null} - </AriaInputGroup> - </> - ); -} - -function featureUnavailableMessage() { - const tunnelProtocol = messages.pgettext('vpn-settings-view', 'Tunnel protocol'); - const multihop = messages.pgettext('wireguard-settings-view', 'Multihop'); - - return sprintf( - messages.pgettext( - // TRANSLATORS: Informs the user that the feature is only available when WireGuard - // TRANSLATORS: is selected. - // TRANSLATORS: Available placeholders: - // TRANSLATORS: %(wireguard)s - will be replaced with WireGuard - // TRANSLATORS: %(tunnelProtocol)s - the name of the tunnel protocol setting - // TRANSLATORS: %(setting)s - the name of the setting - 'wireguard-settings-view', - 'Switch to “%(wireguard)s” in Settings > %(tunnelProtocol)s to make %(setting)s available.', - ), - { wireguard: strings.wireguard, tunnelProtocol, setting: multihop }, - ); -} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/NavigationListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/NavigationListItem.tsx deleted file mode 100644 index 1696c2db05..0000000000 --- a/desktop/packages/mullvad-vpn/src/renderer/components/NavigationListItem.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; - -import { RoutePath } from '../../shared/routes'; -import { ListItem, ListItemProps } from '../lib/components/list-item'; -import { useHistory } from '../lib/history'; - -export type NavigationListItemProps = ListItemProps & { - to: RoutePath; -}; - -function NavigationListItem({ to, children, ...props }: NavigationListItemProps) { - const history = useHistory(); - const navigate = React.useCallback(() => history.push(to), [history, to]); - - return ( - <ListItem {...props}> - <ListItem.Item> - <ListItem.Trigger onClick={navigate}> - <ListItem.Content>{children}</ListItem.Content> - </ListItem.Trigger> - </ListItem.Item> - </ListItem> - ); -} -const NavigationListItemNamespace = Object.assign(NavigationListItem, { - Label: ListItem.Label, - Group: ListItem.Group, - Text: ListItem.Text, - Footer: ListItem.Footer, - Icon: ListItem.Icon, -}); - -export { NavigationListItemNamespace as NavigationListItem }; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/OpenVpnSettings.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/OpenVpnSettings.tsx deleted file mode 100644 index 0d68b5e80b..0000000000 --- a/desktop/packages/mullvad-vpn/src/renderer/components/OpenVpnSettings.tsx +++ /dev/null @@ -1,471 +0,0 @@ -import { useCallback, useMemo } from 'react'; -import { sprintf } from 'sprintf-js'; -import styled from 'styled-components'; - -import { strings } from '../../shared/constants'; -import { - BridgeState, - RelayProtocol, - TunnelProtocol, - wrapConstraint, -} from '../../shared/daemon-rpc-types'; -import { messages } from '../../shared/gettext'; -import log from '../../shared/logging'; -import { removeNonNumericCharacters } from '../../shared/string-helpers'; -import { useAppContext } from '../context'; -import { useRelaySettingsUpdater } from '../lib/constraint-updater'; -import { useHistory } from '../lib/history'; -import { formatHtml } from '../lib/html-formatter'; -import { useSelector } from '../redux/store'; -import { AppNavigationHeader } from './'; -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 { ModalMessage } from './Modal'; -import { NavigationContainer } from './NavigationContainer'; -import { NavigationScrollbars } from './NavigationScrollbars'; -import SettingsHeader, { HeaderTitle } from './SettingsHeader'; - -const MIN_MSSFIX_VALUE = 1000; -const MAX_MSSFIX_VALUE = 1450; -const UDP_PORTS = [1194, 1195, 1196, 1197, 1300, 1301, 1302]; -const TCP_PORTS = [80, 443]; - -export enum BridgeModeAvailability { - available, - blockedDueToTunnelProtocol, - blockedDueToTransportProtocol, -} - -function mapPortToSelectorItem(value: number): SelectorItem<number> { - return { label: value.toString(), value }; -} - -export const StyledNavigationScrollbars = styled(NavigationScrollbars)({ - flex: 1, -}); - -export const StyledSelectorContainer = styled.div({ - flex: 0, -}); - -export default function OpenVpnSettings() { - const { pop } = useHistory(); - - const relaySettings = useSelector((state) => state.settings.relaySettings); - - const protocol = useMemo(() => { - const protocol = 'normal' in relaySettings ? relaySettings.normal.openvpn.protocol : undefined; - return protocol === 'any' ? undefined : protocol; - }, [relaySettings]); - - return ( - <BackAction action={pop}> - <Layout> - <SettingsContainer> - <NavigationContainer> - <AppNavigationHeader - title={sprintf( - // TRANSLATORS: Title label in navigation bar - // TRANSLATORS: Available placeholders: - // TRANSLATORS: %(openvpn)s - Will be replaced with "OpenVPN" - messages.pgettext('openvpn-settings-nav', '%(openvpn)s settings'), - { openvpn: strings.openvpn }, - )} - /> - - <NavigationScrollbars> - <SettingsHeader> - <HeaderTitle> - {sprintf( - // TRANSLATORS: %(openvpn)s will be replaced with "OpenVPN" - messages.pgettext('openvpn-settings-view', '%(openvpn)s settings'), - { - openvpn: strings.openvpn, - }, - )} - </HeaderTitle> - </SettingsHeader> - - <Cell.Group> - <TransportProtocolSelector /> - </Cell.Group> - - {protocol ? ( - <Cell.Group> - <PortSelector /> - </Cell.Group> - ) : undefined} - - <Cell.Group> - <BridgeModeSelector /> - </Cell.Group> - - <Cell.Group> - <MssFixSetting /> - </Cell.Group> - </NavigationScrollbars> - </NavigationContainer> - </SettingsContainer> - </Layout> - </BackAction> - ); -} - -function TransportProtocolSelector() { - const relaySettingsUpdater = useRelaySettingsUpdater(); - const relaySettings = useSelector((state) => state.settings.relaySettings); - const bridgeState = useSelector((state) => state.settings.bridgeState); - - const protocol = useMemo(() => { - const protocol = 'normal' in relaySettings ? relaySettings.normal.openvpn.protocol : 'any'; - return protocol === 'any' ? null : protocol; - }, [relaySettings]); - - const onSelect = useCallback( - async (protocol: RelayProtocol | null) => { - await relaySettingsUpdater((settings) => { - settings.openvpnConstraints.protocol = wrapConstraint(protocol); - settings.openvpnConstraints.port = wrapConstraint<number>(undefined); - return settings; - }); - }, - [relaySettingsUpdater], - ); - - const items: SelectorItem<RelayProtocol>[] = useMemo( - () => [ - { - label: messages.gettext('TCP'), - value: 'tcp', - }, - { - label: messages.gettext('UDP'), - value: 'udp', - disabled: bridgeState === 'on', - }, - ], - [bridgeState], - ); - - return ( - <StyledSelectorContainer> - <AriaInputGroup> - <Selector - title={messages.pgettext('openvpn-settings-view', 'Transport protocol')} - items={items} - value={protocol} - onSelect={onSelect} - automaticValue={null} - /> - {bridgeState === 'on' && ( - <Cell.CellFooter> - <AriaDescription> - <Cell.CellFooterText> - {formatHtml( - // TRANSLATORS: This is used to instruct users how to make UDP mode - // TRANSLATORS: available. - messages.pgettext( - 'openvpn-settings-view', - 'To activate UDP, change <b>Bridge mode</b> to <b>Automatic</b> or <b>Off</b>.', - ), - )} - </Cell.CellFooterText> - </AriaDescription> - </Cell.CellFooter> - )} - </AriaInputGroup> - </StyledSelectorContainer> - ); -} - -function PortSelector() { - const relaySettingsUpdater = useRelaySettingsUpdater(); - const relaySettings = useSelector((state) => state.settings.relaySettings); - - const protocol = useMemo(() => { - const protocol = 'normal' in relaySettings ? relaySettings.normal.openvpn.protocol : 'any'; - return protocol === 'any' ? null : protocol; - }, [relaySettings]); - - const port = useMemo(() => { - const port = 'normal' in relaySettings ? relaySettings.normal.openvpn.port : 'any'; - return port === 'any' ? null : port; - }, [relaySettings]); - - const onSelect = useCallback( - async (port: number | null) => { - await relaySettingsUpdater((settings) => { - settings.openvpnConstraints.port = wrapConstraint(port); - return settings; - }); - }, - [relaySettingsUpdater], - ); - - const portItems = { - udp: UDP_PORTS.map(mapPortToSelectorItem), - tcp: TCP_PORTS.map(mapPortToSelectorItem), - }; - - if (protocol === null) { - return null; - } - - return ( - <StyledSelectorContainer> - <AriaInputGroup> - <Selector - title={sprintf( - // TRANSLATORS: The title for the port selector section. - // TRANSLATORS: Available placeholders: - // TRANSLATORS: %(portType)s - a selected protocol (either TCP or UDP) - messages.pgettext('openvpn-settings-view', '%(portType)s port'), - { - portType: protocol.toUpperCase(), - }, - )} - items={portItems[protocol]} - value={port} - onSelect={onSelect} - automaticValue={null} - /> - </AriaInputGroup> - </StyledSelectorContainer> - ); -} - -function BridgeModeSelector() { - const { setBridgeState: setBridgeStateImpl } = useAppContext(); - const relaySettings = useSelector((state) => state.settings.relaySettings); - - const bridgeState = useSelector((state) => state.settings.bridgeState); - - const tunnelProtocol = useMemo(() => { - const protocol = 'normal' in relaySettings ? relaySettings.normal.tunnelProtocol : 'any'; - return protocol === 'any' ? null : protocol; - }, [relaySettings]); - - const transportProtocol = useMemo(() => { - const protocol = 'normal' in relaySettings ? relaySettings.normal.openvpn.protocol : 'any'; - return protocol === 'any' ? null : protocol; - }, [relaySettings]); - - const options: SelectorItem<BridgeState>[] = useMemo( - () => [ - { - label: messages.gettext('On'), - value: 'on', - disabled: tunnelProtocol !== 'openvpn' || transportProtocol === 'udp', - 'data-testid': 'bridge-mode-on', - }, - { - label: messages.gettext('Off'), - value: 'off', - }, - ], - [tunnelProtocol, transportProtocol], - ); - - const setBridgeState = useCallback( - async (bridgeState: BridgeState) => { - try { - await setBridgeStateImpl(bridgeState); - } catch (e) { - const error = e as Error; - log.error(`Failed to update bridge state: ${error.message}`); - } - }, - [setBridgeStateImpl], - ); - - const onSelectBridgeState = useCallback( - async (newValue: BridgeState) => { - await setBridgeState(newValue); - }, - [setBridgeState], - ); - - const footerText = bridgeModeFooterText(bridgeState === 'on', tunnelProtocol, transportProtocol); - - return ( - <> - <AriaInputGroup> - <StyledSelectorContainer> - <Selector - title={ - // 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> - {footerText !== undefined && ( - <Cell.CellFooter> - <AriaDescription> - <Cell.CellFooterText>{footerText}</Cell.CellFooterText> - </AriaDescription> - </Cell.CellFooter> - )} - </AriaInputGroup> - </> - ); -} - -function bridgeModeFooterText( - bridgeModeOn: boolean, - tunnelProtocol: TunnelProtocol | null, - transportProtocol: RelayProtocol | null, -): 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 - // TRANSLATORS: available. - // TRANSLATORS: Available placeholders: - // TRANSLATORS: %(tunnelProtocol)s - the name of the tunnel protocol setting - // TRANSLATORS: %(openvpn)s - will be replaced with OpenVPN - messages.pgettext( - 'openvpn-settings-view', - 'To activate Bridge mode, go back and change <b>%(tunnelProtocol)s</b> to <b>%(openvpn)s</b>.', - ), - { - tunnelProtocol: messages.pgettext('vpn-settings-view', 'Tunnel protocol'), - openvpn: strings.openvpn, - }, - ), - ); - } else if (transportProtocol === 'udp') { - return formatHtml( - sprintf( - // TRANSLATORS: This is used to instruct users how to make the bridge mode setting - // TRANSLATORS: available. - // TRANSLATORS: Available placeholders: - // TRANSLATORS: %(transportProtocol)s - the name of the transport protocol setting - // TRANSLATORS: %(automatic)s - the translation of "Automatic" - // TRANSLATORS: %(tcp)s - the translation of "TCP" - messages.pgettext( - 'openvpn-settings-view', - 'To activate Bridge mode, change <b>%(transportProtocol)s</b> to <b>%(automatic)s</b> or <b>%(tcp)s</b>.', - ), - { - transportProtocol: messages.pgettext('openvpn-settings-view', 'Transport protocol'), - automatic: messages.gettext('Automatic'), - tcp: messages.gettext('TCP'), - }, - ), - ); - } -} - -function mssfixIsValid(mssfix: string): boolean { - const parsedMssFix = mssfix ? parseInt(mssfix) : undefined; - return ( - parsedMssFix === undefined || - (parsedMssFix >= MIN_MSSFIX_VALUE && parsedMssFix <= MAX_MSSFIX_VALUE) - ); -} - -function MssFixSetting() { - const { setOpenVpnMssfix: setOpenVpnMssfixImpl } = useAppContext(); - const mssfix = useSelector((state) => state.settings.openVpn.mssfix); - - const setOpenVpnMssfix = useCallback( - async (mssfix?: number) => { - try { - await setOpenVpnMssfixImpl(mssfix); - } catch (e) { - const error = e as Error; - log.error('Failed to update mssfix value', error.message); - } - }, - [setOpenVpnMssfixImpl], - ); - - const onMssfixSubmit = useCallback( - async (value: string) => { - const parsedValue = value === '' ? undefined : parseInt(value, 10); - if (mssfixIsValid(value)) { - await setOpenVpnMssfix(parsedValue); - } - }, - [setOpenVpnMssfix], - ); - - return ( - <AriaInputGroup> - <Cell.Container> - <AriaLabel> - <Cell.InputLabel>{messages.pgettext('openvpn-settings-view', 'Mssfix')}</Cell.InputLabel> - </AriaLabel> - <AriaInput> - <Cell.AutoSizingTextInput - initialValue={mssfix ? mssfix.toString() : ''} - inputMode={'numeric'} - maxLength={4} - placeholder={messages.gettext('Default')} - onSubmitValue={onMssfixSubmit} - validateValue={mssfixIsValid} - submitOnBlur={true} - modifyValue={removeNonNumericCharacters} - /> - </AriaInput> - </Cell.Container> - <Cell.CellFooter> - <AriaDescription> - <Cell.CellFooterText> - {sprintf( - // TRANSLATORS: The hint displayed below the Mssfix input field. - // TRANSLATORS: Available placeholders: - // TRANSLATORS: %(openvpn)s - will be replaced with "OpenVPN" - // TRANSLATORS: %(max)d - the maximum possible mssfix value - // TRANSLATORS: %(min)d - the minimum possible mssfix value - messages.pgettext( - 'openvpn-settings-view', - 'Set %(openvpn)s MSS value. Valid range: %(min)d - %(max)d.', - ), - { - openvpn: strings.openvpn, - min: MIN_MSSFIX_VALUE, - max: MAX_MSSFIX_VALUE, - }, - )} - </Cell.CellFooterText> - </AriaDescription> - </Cell.CellFooter> - </AriaInputGroup> - ); -} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/Shadowsocks.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/Shadowsocks.tsx deleted file mode 100644 index b23fe817fc..0000000000 --- a/desktop/packages/mullvad-vpn/src/renderer/components/Shadowsocks.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { useCallback } from 'react'; -import { sprintf } from 'sprintf-js'; -import styled from 'styled-components'; - -import { wrapConstraint } from '../../shared/daemon-rpc-types'; -import { messages } from '../../shared/gettext'; -import { removeNonNumericCharacters } from '../../shared/string-helpers'; -import { useAppContext } from '../context'; -import { useHistory } from '../lib/history'; -import { useSelector } from '../redux/store'; -import { AppNavigationHeader } from './'; -import { AriaDescription, AriaInputGroup } from './AriaGroup'; -import * as Cell from './cell'; -import { SelectorItem, SelectorWithCustomItem } from './cell/Selector'; -import { BackAction } from './KeyboardNavigation'; -import { Layout, SettingsContainer } from './Layout'; -import { NavigationContainer } from './NavigationContainer'; -import { NavigationScrollbars } from './NavigationScrollbars'; -import SettingsHeader, { HeaderTitle } from './SettingsHeader'; - -const PORTS: Array<SelectorItem<number>> = []; -const ALLOWED_RANGE = [1, 65535]; - -const StyledContent = styled.div({ - display: 'flex', - flexDirection: 'column', - flex: 1, - marginBottom: '2px', -}); - -const StyledSelectorContainer = styled.div({ - flex: 0, -}); - -export default function Shadowsocks() { - const { pop } = useHistory(); - - return ( - <BackAction action={pop}> - <Layout> - <SettingsContainer> - <NavigationContainer> - <AppNavigationHeader - title={ - // TRANSLATORS: Title label in navigation bar - messages.pgettext('wireguard-settings-nav', 'Shadowsocks') - } - /> - - <NavigationScrollbars> - <SettingsHeader> - <HeaderTitle> - {messages.pgettext('wireguard-settings-view', 'Shadowsocks')} - </HeaderTitle> - </SettingsHeader> - - <StyledContent> - <Cell.Group> - <ShadowsocksPortSelector /> - </Cell.Group> - </StyledContent> - </NavigationScrollbars> - </NavigationContainer> - </SettingsContainer> - </Layout> - </BackAction> - ); -} - -function ShadowsocksPortSelector() { - const { setObfuscationSettings } = useAppContext(); - const obfuscationSettings = useSelector((state) => state.settings.obfuscationSettings); - - const port = - obfuscationSettings.shadowsocksSettings.port === 'any' - ? null - : obfuscationSettings.shadowsocksSettings.port.only; - - const setShadowsocksPort = useCallback( - async (port: number | null) => { - await setObfuscationSettings({ - ...obfuscationSettings, - shadowsocksSettings: { - ...obfuscationSettings.shadowsocksSettings, - port: wrapConstraint(port), - }, - }); - }, - [setObfuscationSettings, obfuscationSettings], - ); - - const parseValue = useCallback((port: string) => parseInt(port), []); - - const validateValue = useCallback( - (value: number) => value >= ALLOWED_RANGE[0] && value <= ALLOWED_RANGE[1], - [], - ); - - return ( - <AriaInputGroup> - <StyledSelectorContainer> - <SelectorWithCustomItem - // TRANSLATORS: The title for the WireGuard port selector. - title={messages.pgettext('wireguard-settings-view', 'Port')} - items={PORTS} - value={port} - onSelect={setShadowsocksPort} - inputPlaceholder={messages.pgettext('wireguard-settings-view', 'Port')} - automaticValue={null} - parseValue={parseValue} - modifyValue={removeNonNumericCharacters} - validateValue={validateValue} - maxLength={`${ALLOWED_RANGE[1]}`.length} - /> - </StyledSelectorContainer> - <Cell.CellFooter> - <AriaDescription> - <Cell.CellFooterText> - {sprintf( - // TRANSLATORS: Text describing the valid port range for a port selector. - messages.pgettext('wireguard-settings-view', 'Valid range: %(min)s - %(max)s'), - { min: ALLOWED_RANGE[0], max: ALLOWED_RANGE[1] }, - )} - </Cell.CellFooterText> - </AriaDescription> - </Cell.CellFooter> - </AriaInputGroup> - ); -} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/Support.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/Support.tsx index dc2d9f9275..6ceb844c47 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/Support.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/Support.tsx @@ -13,8 +13,8 @@ import * as Cell from './cell'; import { BackAction } from './KeyboardNavigation'; import { Layout, SettingsContainer } from './Layout'; import { NavigationContainer } from './NavigationContainer'; -import { NavigationListItem } from './NavigationListItem'; import { NavigationScrollbars } from './NavigationScrollbars'; +import { SettingsNavigationListItem } from './settings-navigation-list-item'; import SettingsHeader, { HeaderTitle } from './SettingsHeader'; const StyledContent = styled.div({ @@ -63,10 +63,10 @@ function ProblemReportButton() { const label = messages.pgettext('support-view', 'Report a problem'); return ( - <NavigationListItem to={RoutePath.problemReport}> - <NavigationListItem.Label>{label}</NavigationListItem.Label> - <NavigationListItem.Icon icon="chevron-right" /> - </NavigationListItem> + <SettingsNavigationListItem to={RoutePath.problemReport}> + <SettingsNavigationListItem.Label>{label}</SettingsNavigationListItem.Label> + <SettingsNavigationListItem.Icon icon="chevron-right" /> + </SettingsNavigationListItem> ); } diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/UdpOverTcp.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/UdpOverTcp.tsx deleted file mode 100644 index c6e6ff43f6..0000000000 --- a/desktop/packages/mullvad-vpn/src/renderer/components/UdpOverTcp.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { useCallback, useMemo } from 'react'; -import styled from 'styled-components'; - -import { liftConstraint, LiftedConstraint, wrapConstraint } from '../../shared/daemon-rpc-types'; -import { messages } from '../../shared/gettext'; -import { useAppContext } from '../context'; -import { useHistory } from '../lib/history'; -import { useSelector } from '../redux/store'; -import { AppNavigationHeader } from './'; -import { AriaInputGroup } from './AriaGroup'; -import * as Cell from './cell'; -import Selector, { SelectorItem } from './cell/Selector'; -import { BackAction } from './KeyboardNavigation'; -import { Layout, SettingsContainer } from './Layout'; -import { ModalMessage } from './Modal'; -import { NavigationContainer } from './NavigationContainer'; -import { NavigationScrollbars } from './NavigationScrollbars'; -import SettingsHeader, { HeaderTitle } from './SettingsHeader'; - -const UDP2TCP_PORTS = [80, 5001]; - -function mapPortToSelectorItem(value: number): SelectorItem<number> { - return { label: value.toString(), value }; -} - -const StyledContent = styled.div({ - display: 'flex', - flexDirection: 'column', - flex: 1, - marginBottom: '2px', -}); - -const StyledSelectorContainer = styled.div({ - flex: 0, -}); - -export default function UdpOverTcp() { - const { pop } = useHistory(); - - return ( - <BackAction action={pop}> - <Layout> - <SettingsContainer> - <NavigationContainer> - <AppNavigationHeader - title={ - // TRANSLATORS: Title label in navigation bar - messages.pgettext('wireguard-settings-nav', 'UDP-over-TCP') - } - /> - - <NavigationScrollbars> - <SettingsHeader> - <HeaderTitle> - {messages.pgettext('wireguard-settings-view', 'UDP-over-TCP')} - </HeaderTitle> - </SettingsHeader> - - <StyledContent> - <Cell.Group> - <Udp2tcpPortSetting /> - </Cell.Group> - </StyledContent> - </NavigationScrollbars> - </NavigationContainer> - </SettingsContainer> - </Layout> - </BackAction> - ); -} - -function Udp2tcpPortSetting() { - const { setObfuscationSettings } = useAppContext(); - const obfuscationSettings = useSelector((state) => state.settings.obfuscationSettings); - - const port = liftConstraint(obfuscationSettings.udp2tcpSettings.port); - const portItems: SelectorItem<number>[] = useMemo( - () => UDP2TCP_PORTS.map(mapPortToSelectorItem), - [], - ); - - const selectPort = useCallback( - async (port: LiftedConstraint<number>) => { - await setObfuscationSettings({ - ...obfuscationSettings, - udp2tcpSettings: { - ...obfuscationSettings.udp2tcpSettings, - port: wrapConstraint(port), - }, - }); - }, - [setObfuscationSettings, obfuscationSettings], - ); - - return ( - <AriaInputGroup> - <StyledSelectorContainer> - <Selector - // TRANSLATORS: The title for the UDP-over-TCP port selector. - title={messages.pgettext('wireguard-settings-view', 'UDP-over-TCP port')} - details={ - <ModalMessage> - {messages.pgettext( - 'wireguard-settings-view', - 'Which TCP port the UDP-over-TCP obfuscation protocol should connect to on the VPN server.', - )} - </ModalMessage> - } - items={portItems} - value={port} - onSelect={selectPort} - thinTitle - automaticValue={'any' as const} - /> - </StyledSelectorContainer> - </AriaInputGroup> - ); -} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/UserInterfaceSettings.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/UserInterfaceSettings.tsx index be918077bc..5a1e4ad39e 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/UserInterfaceSettings.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/UserInterfaceSettings.tsx @@ -12,8 +12,8 @@ import * as Cell from './cell'; import { BackAction } from './KeyboardNavigation'; import { Layout, SettingsContainer, SettingsContent, SettingsGroup, SettingsStack } from './Layout'; import { NavigationContainer } from './NavigationContainer'; -import { NavigationListItem } from './NavigationListItem'; import { NavigationScrollbars } from './NavigationScrollbars'; +import { SettingsNavigationListItem } from './settings-navigation-list-item'; import SettingsHeader, { HeaderTitle } from './SettingsHeader'; const StyledAnimateMapCellGroup = styled(SettingsGroup)({ @@ -239,20 +239,20 @@ function LanguageButton() { const localeDisplayName = getPreferredLocaleDisplayName(preferredLocale); return ( - <NavigationListItem to={RoutePath.selectLanguage}> - <NavigationListItem.Group> + <SettingsNavigationListItem to={RoutePath.selectLanguage}> + <SettingsNavigationListItem.Group> <Image source="icon-language" /> - <NavigationListItem.Label> + <SettingsNavigationListItem.Label> { // TRANSLATORS: Navigation button to the 'Language' settings view messages.pgettext('user-interface-settings-view', 'Language') } - </NavigationListItem.Label> - </NavigationListItem.Group> - <NavigationListItem.Group> - <NavigationListItem.Text>{localeDisplayName}</NavigationListItem.Text> - <NavigationListItem.Icon icon="chevron-right" /> - </NavigationListItem.Group> - </NavigationListItem> + </SettingsNavigationListItem.Label> + </SettingsNavigationListItem.Group> + <SettingsNavigationListItem.Group> + <SettingsNavigationListItem.Text>{localeDisplayName}</SettingsNavigationListItem.Text> + <SettingsNavigationListItem.Icon icon="chevron-right" /> + </SettingsNavigationListItem.Group> + </SettingsNavigationListItem> ); } diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/VpnSettings.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/VpnSettings.tsx deleted file mode 100644 index d1e3d7df72..0000000000 --- a/desktop/packages/mullvad-vpn/src/renderer/components/VpnSettings.tsx +++ /dev/null @@ -1,835 +0,0 @@ -import { useCallback, useMemo } from 'react'; -import { sprintf } from 'sprintf-js'; -import styled from 'styled-components'; - -import { strings, urls } from '../../shared/constants'; -import { IDnsOptions, TunnelProtocol } from '../../shared/daemon-rpc-types'; -import { messages } from '../../shared/gettext'; -import log from '../../shared/logging'; -import { RoutePath } from '../../shared/routes'; -import { useAppContext } from '../context'; -import { Button } from '../lib/components'; -import { useRelaySettingsUpdater } from '../lib/constraint-updater'; -import { colors, spacings } from '../lib/foundations'; -import { useHistory } from '../lib/history'; -import { formatHtml } from '../lib/html-formatter'; -import { useTunnelProtocol } from '../lib/relay-settings-hooks'; -import { useBoolean } from '../lib/utility-hooks'; -import { RelaySettingsRedux } from '../redux/settings/reducers'; -import { useSelector } from '../redux/store'; -import { AppNavigationHeader } from './'; -import { AriaDescription, AriaDetails, AriaInput, AriaInputGroup, AriaLabel } from './AriaGroup'; -import * as Cell from './cell'; -import Selector, { SelectorItem } from './cell/Selector'; -import CustomDnsSettings from './CustomDnsSettings'; -import { ExternalLink } from './ExternalLink'; -import InfoButton from './InfoButton'; -import { BackAction } from './KeyboardNavigation'; -import { Layout, SettingsContainer, SettingsContent, SettingsGroup, SettingsStack } from './Layout'; -import { ModalAlert, ModalAlertType, ModalMessage } from './Modal'; -import { NavigationContainer } from './NavigationContainer'; -import { NavigationListItem } from './NavigationListItem'; -import { NavigationScrollbars } from './NavigationScrollbars'; -import SettingsHeader, { HeaderTitle } from './SettingsHeader'; - -const StyledInfoButton = styled(InfoButton)({ - marginRight: spacings.medium, -}); - -const StyledTitleLabel = styled(Cell.SectionTitle)({ - flex: 1, -}); - -const StyledSectionItem = styled(Cell.Container)({ - backgroundColor: colors.blue40, -}); - -const LanIpRanges = styled.ul({ - listStyle: 'disc outside', - marginLeft: spacings.large, -}); - -const IndentedValueLabel = styled(Cell.ValueLabel)({ - marginLeft: spacings.medium, -}); - -export default function VpnSettings() { - const { pop } = useHistory(); - - return ( - <BackAction action={pop}> - <Layout> - <SettingsContainer> - <NavigationContainer> - <AppNavigationHeader - title={ - // TRANSLATORS: Title label in navigation bar - messages.pgettext('vpn-settings-view', 'VPN settings') - } - /> - - <NavigationScrollbars> - <SettingsHeader> - <HeaderTitle>{messages.pgettext('vpn-settings-view', 'VPN settings')}</HeaderTitle> - </SettingsHeader> - - <SettingsContent> - <SettingsStack> - <SettingsGroup> - <AutoStart /> - <AutoConnect /> - </SettingsGroup> - - <SettingsGroup> - <AllowLan /> - </SettingsGroup> - - <SettingsGroup> - <DnsBlockers /> - </SettingsGroup> - - <SettingsGroup> - <EnableIpv6 /> - </SettingsGroup> - - <SettingsGroup> - <KillSwitchInfo /> - <LockdownMode /> - </SettingsGroup> - - <SettingsGroup> - <TunnelProtocolSetting /> - </SettingsGroup> - - <SettingsGroup> - <WireguardSettingsButton /> - <OpenVpnSettingsButton /> - </SettingsGroup> - - <SettingsGroup> - <CustomDnsSettings /> - </SettingsGroup> - - <SettingsGroup> - <IpOverrideButton /> - </SettingsGroup> - </SettingsStack> - </SettingsContent> - </NavigationScrollbars> - </NavigationContainer> - </SettingsContainer> - </Layout> - </BackAction> - ); -} - -function AutoStart() { - const autoStart = useSelector((state) => state.settings.autoStart); - const { setAutoStart: setAutoStartImpl } = useAppContext(); - - const setAutoStart = useCallback( - async (autoStart: boolean) => { - try { - await setAutoStartImpl(autoStart); - } catch (e) { - const error = e as Error; - log.error(`Cannot set auto-start: ${error.message}`); - } - }, - [setAutoStartImpl], - ); - - return ( - <AriaInputGroup> - <Cell.Container> - <AriaLabel> - <Cell.InputLabel> - {messages.pgettext('vpn-settings-view', 'Launch app on start-up')} - </Cell.InputLabel> - </AriaLabel> - <AriaInput> - <Cell.Switch isOn={autoStart} onChange={setAutoStart} /> - </AriaInput> - </Cell.Container> - </AriaInputGroup> - ); -} - -function AutoConnect() { - const autoConnect = useSelector((state) => state.settings.guiSettings.autoConnect); - const { setAutoConnect } = useAppContext(); - - return ( - <AriaInputGroup> - <Cell.Container> - <AriaLabel> - <Cell.InputLabel> - {messages.pgettext('vpn-settings-view', 'Auto-connect')} - </Cell.InputLabel> - </AriaLabel> - <AriaInput> - <Cell.Switch isOn={autoConnect} onChange={setAutoConnect} /> - </AriaInput> - </Cell.Container> - <Cell.CellFooter> - <AriaDescription> - <Cell.CellFooterText> - {messages.pgettext( - 'vpn-settings-view', - 'Automatically connect to a server when the app launches.', - )} - </Cell.CellFooterText> - </AriaDescription> - </Cell.CellFooter> - </AriaInputGroup> - ); -} - -function AllowLan() { - const allowLan = useSelector((state) => state.settings.allowLan); - const { setAllowLan } = useAppContext(); - - return ( - <AriaInputGroup> - <Cell.Container> - <AriaLabel> - <Cell.InputLabel> - {messages.pgettext('vpn-settings-view', 'Local network sharing')} - </Cell.InputLabel> - </AriaLabel> - <AriaDetails> - <StyledInfoButton> - <ModalMessage> - {messages.pgettext( - 'vpn-settings-view', - 'This feature allows access to other devices on the local network, such as for sharing, printing, streaming, etc.', - )} - </ModalMessage> - <ModalMessage> - {messages.pgettext( - 'vpn-settings-view', - 'It does this by allowing network communication outside the tunnel to local multicast and broadcast ranges as well as to and from these private IP ranges:', - )} - <LanIpRanges> - <li>10.0.0.0/8</li> - <li>172.16.0.0/12</li> - <li>192.168.0.0/16</li> - <li>169.254.0.0/16</li> - <li>fe80::/10</li> - <li>fc00::/7</li> - </LanIpRanges> - </ModalMessage> - </StyledInfoButton> - </AriaDetails> - <AriaInput> - <Cell.Switch isOn={allowLan} onChange={setAllowLan} /> - </AriaInput> - </Cell.Container> - </AriaInputGroup> - ); -} - -function useDns(setting: keyof IDnsOptions['defaultOptions']) { - const dns = useSelector((state) => state.settings.dns); - const { setDnsOptions } = useAppContext(); - - const updateBlockSetting = useCallback( - (enabled: boolean) => - setDnsOptions({ - ...dns, - defaultOptions: { - ...dns.defaultOptions, - [setting]: enabled, - }, - }), - [setting, dns, setDnsOptions], - ); - - return [dns, updateBlockSetting] as const; -} - -function DnsBlockers() { - const dns = useSelector((state) => state.settings.dns); - const customDnsFeatureName = messages.pgettext('vpn-settings-view', 'Use custom DNS server'); - - const title = ( - <> - <StyledTitleLabel as="label" disabled={dns.state === 'custom'}> - {messages.pgettext('vpn-settings-view', 'DNS content blockers')} - </StyledTitleLabel> - <StyledInfoButton> - <ModalMessage> - {messages.pgettext( - 'vpn-settings-view', - 'When this feature is enabled it stops the device from contacting certain domains or websites known for distributing ads, malware, trackers and more.', - )} - </ModalMessage> - <ModalMessage> - {messages.pgettext( - 'vpn-settings-view', - 'This might cause issues on certain websites, services, and apps.', - )} - </ModalMessage> - <ModalMessage> - {formatHtml( - sprintf( - messages.pgettext( - 'vpn-settings-view', - 'Attention: this setting cannot be used in combination with <b>%(customDnsFeatureName)s</b>', - ), - { customDnsFeatureName }, - ), - )} - </ModalMessage> - </StyledInfoButton> - </> - ); - - return ( - <Cell.ExpandableSection sectionTitle={title} expandableId="dns-blockers"> - <BlockAds /> - <BlockTrackers /> - <BlockMalware /> - <BlockGambling /> - <BlockAdultContent /> - <BlockSocialMedia /> - </Cell.ExpandableSection> - ); -} - -function BlockAds() { - const [dns, setBlockAds] = useDns('blockAds'); - - return ( - <AriaInputGroup> - <StyledSectionItem disabled={dns.state === 'custom'}> - <AriaLabel> - <IndentedValueLabel> - { - // TRANSLATORS: Label for settings that enables ad blocking. - messages.pgettext('vpn-settings-view', 'Ads') - } - </IndentedValueLabel> - </AriaLabel> - <AriaInput> - <Cell.Switch - isOn={dns.state === 'default' && dns.defaultOptions.blockAds} - onChange={setBlockAds} - /> - </AriaInput> - </StyledSectionItem> - </AriaInputGroup> - ); -} - -function BlockTrackers() { - const [dns, setBlockTrackers] = useDns('blockTrackers'); - - return ( - <AriaInputGroup> - <StyledSectionItem disabled={dns.state === 'custom'}> - <AriaLabel> - <IndentedValueLabel> - { - // TRANSLATORS: Label for settings that enables tracker blocking. - messages.pgettext('vpn-settings-view', 'Trackers') - } - </IndentedValueLabel> - </AriaLabel> - <AriaInput> - <Cell.Switch - isOn={dns.state === 'default' && dns.defaultOptions.blockTrackers} - onChange={setBlockTrackers} - /> - </AriaInput> - </StyledSectionItem> - </AriaInputGroup> - ); -} - -function BlockMalware() { - const [dns, setBlockMalware] = useDns('blockMalware'); - - return ( - <AriaInputGroup> - <StyledSectionItem disabled={dns.state === 'custom'}> - <AriaLabel> - <IndentedValueLabel> - { - // TRANSLATORS: Label for settings that enables malware blocking. - messages.pgettext('vpn-settings-view', 'Malware') - } - </IndentedValueLabel> - </AriaLabel> - <AriaDetails> - <StyledInfoButton> - <ModalMessage> - {messages.pgettext( - 'vpn-settings-view', - 'Warning: The malware blocker is not an anti-virus and should not be treated as such, this is just an extra layer of protection.', - )} - </ModalMessage> - </StyledInfoButton> - </AriaDetails> - <AriaInput> - <Cell.Switch - isOn={dns.state === 'default' && dns.defaultOptions.blockMalware} - onChange={setBlockMalware} - /> - </AriaInput> - </StyledSectionItem> - </AriaInputGroup> - ); -} - -function BlockGambling() { - const [dns, setBlockGambling] = useDns('blockGambling'); - - return ( - <AriaInputGroup> - <StyledSectionItem disabled={dns.state === 'custom'}> - <AriaLabel> - <IndentedValueLabel> - { - // TRANSLATORS: Label for settings that enables block of gamling related websites. - messages.pgettext('vpn-settings-view', 'Gambling') - } - </IndentedValueLabel> - </AriaLabel> - <AriaInput> - <Cell.Switch - isOn={dns.state === 'default' && dns.defaultOptions.blockGambling} - onChange={setBlockGambling} - /> - </AriaInput> - </StyledSectionItem> - </AriaInputGroup> - ); -} - -function BlockAdultContent() { - const [dns, setBlockAdultContent] = useDns('blockAdultContent'); - - return ( - <AriaInputGroup> - <StyledSectionItem disabled={dns.state === 'custom'}> - <AriaLabel> - <IndentedValueLabel> - { - // TRANSLATORS: Label for settings that enables block of adult content. - messages.pgettext('vpn-settings-view', 'Adult content') - } - </IndentedValueLabel> - </AriaLabel> - <AriaInput> - <Cell.Switch - isOn={dns.state === 'default' && dns.defaultOptions.blockAdultContent} - onChange={setBlockAdultContent} - /> - </AriaInput> - </StyledSectionItem> - </AriaInputGroup> - ); -} - -function BlockSocialMedia() { - const [dns, setBlockSocialMedia] = useDns('blockSocialMedia'); - - return ( - <AriaInputGroup> - <StyledSectionItem disabled={dns.state === 'custom'}> - <AriaLabel> - <IndentedValueLabel> - { - // TRANSLATORS: Label for settings that enables block of social media. - messages.pgettext('vpn-settings-view', 'Social media') - } - </IndentedValueLabel> - </AriaLabel> - <AriaInput> - <Cell.Switch - isOn={dns.state === 'default' && dns.defaultOptions.blockSocialMedia} - onChange={setBlockSocialMedia} - /> - </AriaInput> - </StyledSectionItem> - {dns.state === 'custom' && <CustomDnsEnabledFooter />} - </AriaInputGroup> - ); -} - -function CustomDnsEnabledFooter() { - const customDnsFeatureName = messages.pgettext('vpn-settings-view', 'Use custom DNS server'); - - // TRANSLATORS: This is displayed when the custom DNS setting is turned on which makes the block - // TRANSLATORS: ads/trackers settings disabled. The text enclosed in "<b></b>" will appear bold. - // TRANSLATORS: Available placeholders: - // TRANSLATORS: %(customDnsFeatureName)s - The name displayed next to the custom DNS toggle. - const blockingDisabledText = messages.pgettext( - 'vpn-settings-view', - 'Disable <b>%(customDnsFeatureName)s</b> below to activate these settings.', - ); - - return ( - <Cell.CellFooter> - <AriaDescription> - <Cell.CellFooterText> - {formatHtml(sprintf(blockingDisabledText, { customDnsFeatureName }))} - </Cell.CellFooterText> - </AriaDescription> - </Cell.CellFooter> - ); -} - -function EnableIpv6() { - const enableIpv6 = useSelector((state) => state.settings.enableIpv6); - const { setEnableIpv6: setEnableIpv6Impl } = useAppContext(); - - const setEnableIpv6 = useCallback( - async (enableIpv6: boolean) => { - try { - await setEnableIpv6Impl(enableIpv6); - } catch (e) { - const error = e as Error; - log.error('Failed to update enable IPv6', error.message); - } - }, - [setEnableIpv6Impl], - ); - - return ( - <AriaInputGroup> - <Cell.Container> - <AriaLabel> - <Cell.InputLabel>{messages.pgettext('vpn-settings-view', 'Enable IPv6')}</Cell.InputLabel> - </AriaLabel> - <AriaDetails> - <StyledInfoButton> - <ModalMessage> - {messages.pgettext( - 'vpn-settings-view', - 'When this feature is enabled, IPv6 can be used alongside IPv4 in the VPN tunnel to communicate with internet services.', - )} - </ModalMessage> - <ModalMessage> - {messages.pgettext( - 'vpn-settings-view', - 'IPv4 is always enabled and the majority of websites and applications use this protocol. We do not recommend enabling IPv6 unless you know you need it.', - )} - </ModalMessage> - </StyledInfoButton> - </AriaDetails> - <AriaInput> - <Cell.Switch isOn={enableIpv6} onChange={setEnableIpv6} /> - </AriaInput> - </Cell.Container> - </AriaInputGroup> - ); -} - -function KillSwitchInfo() { - const [killSwitchInfoVisible, showKillSwitchInfo, hideKillSwitchInfo] = useBoolean(false); - - return ( - <> - <AriaInputGroup> - <Cell.Container> - <AriaLabel> - <Cell.InputLabel> - {messages.pgettext('vpn-settings-view', 'Kill switch')} - </Cell.InputLabel> - </AriaLabel> - <StyledInfoButton onClick={showKillSwitchInfo} /> - <AriaInput> - <Cell.Switch isOn disabled /> - </AriaInput> - </Cell.Container> - </AriaInputGroup> - <ModalAlert - isOpen={killSwitchInfoVisible} - type={ModalAlertType.info} - buttons={[ - <Button key="back" onClick={hideKillSwitchInfo}> - <Button.Text>{messages.gettext('Got it!')}</Button.Text> - </Button>, - ]} - close={hideKillSwitchInfo}> - <ModalMessage> - {messages.pgettext( - 'vpn-settings-view', - 'This built-in feature prevents your traffic from leaking outside of the VPN tunnel if your network suddenly stops working or if the tunnel fails, it does this by blocking your traffic until your connection is reestablished.', - )} - </ModalMessage> - <ModalMessage> - {messages.pgettext( - 'vpn-settings-view', - 'The difference between the Kill Switch and Lockdown Mode is that the Kill Switch will prevent any leaks from happening during automatic tunnel reconnects, software crashes and similar accidents. With Lockdown Mode enabled, you must be connected to a Mullvad VPN server to be able to reach the internet. Manually disconnecting or quitting the app will block your connection.', - )} - </ModalMessage> - </ModalAlert> - </> - ); -} - -function LockdownMode() { - const blockWhenDisconnected = useSelector((state) => state.settings.blockWhenDisconnected); - const { setBlockWhenDisconnected: setBlockWhenDisconnectedImpl } = useAppContext(); - - const [confirmationDialogVisible, showConfirmationDialog, hideConfirmationDialog] = - useBoolean(false); - - const setBlockWhenDisconnected = useCallback( - async (blockWhenDisconnected: boolean) => { - try { - await setBlockWhenDisconnectedImpl(blockWhenDisconnected); - } catch (e) { - const error = e as Error; - log.error('Failed to update block when disconnected', error.message); - } - }, - [setBlockWhenDisconnectedImpl], - ); - - const setLockDownMode = useCallback( - async (newValue: boolean) => { - if (newValue) { - showConfirmationDialog(); - } else { - await setBlockWhenDisconnected(false); - } - }, - [setBlockWhenDisconnected, showConfirmationDialog], - ); - - const confirmLockdownMode = useCallback(async () => { - hideConfirmationDialog(); - await setBlockWhenDisconnected(true); - }, [hideConfirmationDialog, setBlockWhenDisconnected]); - - return ( - <> - <AriaInputGroup> - <Cell.Container> - <AriaLabel> - <Cell.InputLabel> - {messages.pgettext('vpn-settings-view', 'Lockdown mode')} - </Cell.InputLabel> - </AriaLabel> - <AriaDetails> - <StyledInfoButton> - <ModalMessage> - {messages.pgettext( - 'vpn-settings-view', - 'The difference between the Kill Switch and Lockdown Mode is that the Kill Switch will prevent any leaks from happening during automatic tunnel reconnects, software crashes and similar accidents.', - )} - </ModalMessage> - <ModalMessage> - {messages.pgettext( - 'vpn-settings-view', - 'With Lockdown Mode enabled, you must be connected to a Mullvad VPN server to be able to reach the internet. Manually disconnecting or quitting the app will block your connection.', - )} - </ModalMessage> - </StyledInfoButton> - </AriaDetails> - <AriaInput> - <Cell.Switch isOn={blockWhenDisconnected} onChange={setLockDownMode} /> - </AriaInput> - </Cell.Container> - </AriaInputGroup> - <ModalAlert - isOpen={confirmationDialogVisible} - type={ModalAlertType.caution} - buttons={[ - <Button variant="destructive" key="confirm" onClick={confirmLockdownMode}> - <Button.Text>{messages.gettext('Enable anyway')}</Button.Text> - </Button>, - <Button key="back" onClick={hideConfirmationDialog}> - <Button.Text>{messages.gettext('Back')}</Button.Text> - </Button>, - ]} - close={hideConfirmationDialog}> - <ModalMessage> - {messages.pgettext( - 'vpn-settings-view', - 'Attention: enabling this will always require a Mullvad VPN connection in order to reach the internet.', - )} - </ModalMessage> - <ModalMessage> - {messages.pgettext( - 'vpn-settings-view', - 'The app’s built-in kill switch is always on. This setting will additionally block the internet if clicking Disconnect or Quit.', - )} - </ModalMessage> - </ModalAlert> - </> - ); -} - -function TunnelProtocolSetting() { - const tunnelProtocol = useTunnelProtocol(); - - const relaySettingsUpdater = useRelaySettingsUpdater(); - - const relaySettings = useSelector((state) => state.settings.relaySettings); - const multihop = 'normal' in relaySettings ? relaySettings.normal.wireguard.useMultihop : false; - const daita = useSelector((state) => state.settings.wireguard.daita?.enabled ?? false); - const quantumResistant = useSelector((state) => state.settings.wireguard.quantumResistant); - const openVpnDisabled = daita || multihop || quantumResistant; - - const featuresToDisableForOpenVpn = []; - if (daita) { - featuresToDisableForOpenVpn.push(strings.daita); - } - if (multihop) { - featuresToDisableForOpenVpn.push(messages.pgettext('wireguard-settings-view', 'Multihop')); - } - if (quantumResistant) { - featuresToDisableForOpenVpn.push( - messages.pgettext('wireguard-settings-view', 'Quantum-resistant tunnel'), - ); - } - - const setTunnelProtocol = useCallback( - async (tunnelProtocol: TunnelProtocol) => { - try { - await relaySettingsUpdater((settings) => ({ - ...settings, - tunnelProtocol, - })); - } catch (e) { - const error = e as Error; - log.error('Failed to update tunnel protocol constraints', error.message); - } - }, - [relaySettingsUpdater], - ); - - const tunnelProtocolItems: Array<SelectorItem<TunnelProtocol>> = useMemo( - () => [ - { - label: strings.wireguard, - value: 'wireguard', - }, - { - label: strings.openvpn, - value: 'openvpn', - disabled: openVpnDisabled, - }, - ], - [openVpnDisabled], - ); - - return ( - <AriaInputGroup> - <Selector - title={messages.pgettext('vpn-settings-view', 'Tunnel protocol')} - items={tunnelProtocolItems} - value={tunnelProtocol} - onSelect={setTunnelProtocol} - /> - {openVpnDisabled && ( - <Cell.CellFooter> - <AriaDescription> - <Cell.CellFooterText> - {sprintf( - messages.pgettext( - 'vpn-settings-view', - 'To select %(openvpn)s, please disable these settings: %(featureList)s.', - ), - { openvpn: strings.openvpn, featureList: featuresToDisableForOpenVpn.join(', ') }, - )} - </Cell.CellFooterText> - </AriaDescription> - </Cell.CellFooter> - )} - {tunnelProtocol === 'openvpn' && ( - <Cell.CellFooter> - <AriaDescription> - <Cell.CellFooterText> - {sprintf( - // TRANSLATORS: Footer text for tunnel protocol selector when OpenVPN is selected. - // TRANSLATORS: Available placeholders: - // TRANSLATORS: %(openvpn)s - Will be replaced with OpenVPN - messages.pgettext( - 'vpn-settings-view', - 'Attention: We are removing support for %(openVpn)s.', - ), - { openVpn: strings.openvpn }, - )}{' '} - </Cell.CellFooterText> - </AriaDescription> - <ExternalLink variant="labelTiny" to={urls.removingOpenVpnBlog}> - <ExternalLink.Text> - {sprintf( - // TRANSLATORS: Link in tunnel protocol selector footer to blog post - // TRANSLATORS: about OpenVPN support ending. - messages.pgettext('vpn-settings-view', 'Read more'), - )} - </ExternalLink.Text> - <ExternalLink.Icon icon="external" size="small" /> - </ExternalLink> - </Cell.CellFooter> - )} - </AriaInputGroup> - ); -} - -function mapRelaySettingsToProtocol(relaySettings: RelaySettingsRedux) { - if ('normal' in relaySettings) { - const { tunnelProtocol } = relaySettings.normal; - return tunnelProtocol; - // since the GUI doesn't display custom settings, just display the default ones. - // If the user sets any settings, then those will be applied. - } else if ('customTunnelEndpoint' in relaySettings) { - return undefined; - } else { - throw new Error('Unknown type of relay settings.'); - } -} - -function WireguardSettingsButton() { - const tunnelProtocol = useSelector((state) => - mapRelaySettingsToProtocol(state.settings.relaySettings), - ); - - return ( - <NavigationListItem to={RoutePath.wireguardSettings} disabled={tunnelProtocol === 'openvpn'}> - <NavigationListItem.Label> - {sprintf( - // TRANSLATORS: %(wireguard)s will be replaced with the string "WireGuard" - messages.pgettext('vpn-settings-view', '%(wireguard)s settings'), - { wireguard: strings.wireguard }, - )} - </NavigationListItem.Label> - <NavigationListItem.Icon icon="chevron-right" /> - </NavigationListItem> - ); -} - -function OpenVpnSettingsButton() { - const tunnelProtocol = useTunnelProtocol(); - - return ( - <NavigationListItem to={RoutePath.openVpnSettings} disabled={tunnelProtocol === 'wireguard'}> - <NavigationListItem.Label> - {sprintf( - // TRANSLATORS: %(openvpn)s will be replaced with the string "OpenVPN" - messages.pgettext('vpn-settings-view', '%(openvpn)s settings'), - { openvpn: strings.openvpn }, - )} - </NavigationListItem.Label> - <NavigationListItem.Icon icon="chevron-right" /> - </NavigationListItem> - ); -} - -function IpOverrideButton() { - return ( - <NavigationListItem to={RoutePath.settingsImport}> - <NavigationListItem.Label> - {messages.pgettext('vpn-settings-view', 'Server IP override')} - </NavigationListItem.Label> - <NavigationListItem.Icon icon="chevron-right" /> - </NavigationListItem> - ); -} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/WireguardSettings.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/WireguardSettings.tsx deleted file mode 100644 index f7b899391c..0000000000 --- a/desktop/packages/mullvad-vpn/src/renderer/components/WireguardSettings.tsx +++ /dev/null @@ -1,485 +0,0 @@ -import { useCallback, useMemo } from 'react'; -import { sprintf } from 'sprintf-js'; -import styled from 'styled-components'; - -import { strings } from '../../shared/constants'; -import { - Constraint, - IpVersion, - ObfuscationType, - wrapConstraint, -} from '../../shared/daemon-rpc-types'; -import { messages } from '../../shared/gettext'; -import log from '../../shared/logging'; -import { RoutePath } from '../../shared/routes'; -import { removeNonNumericCharacters } from '../../shared/string-helpers'; -import { isInRanges } from '../../shared/utils'; -import { useAppContext } from '../context'; -import { useRelaySettingsUpdater } from '../lib/constraint-updater'; -import { useHistory } from '../lib/history'; -import { useSelector } from '../redux/store'; -import { AppNavigationHeader } from './'; -import { AriaDescription, AriaInput, AriaInputGroup, AriaLabel } from './AriaGroup'; -import * as Cell from './cell'; -import Selector, { SelectorItem, SelectorWithCustomItem } from './cell/Selector'; -import { BackAction } from './KeyboardNavigation'; -import { Layout, SettingsContainer, SettingsContent, SettingsGroup, SettingsStack } from './Layout'; -import { ModalMessage } from './Modal'; -import { NavigationContainer } from './NavigationContainer'; -import { NavigationScrollbars } from './NavigationScrollbars'; -import SettingsHeader, { HeaderTitle } from './SettingsHeader'; - -const MIN_WIREGUARD_MTU_VALUE = 1280; -const MAX_WIREGUARD_MTU_VALUE = 1420; -const WIREUGARD_UDP_PORTS = [51820, 53]; - -function mapPortToSelectorItem(value: number): SelectorItem<number> { - return { label: value.toString(), value }; -} - -const StyledSelectorContainer = styled.div({ - flex: 0, -}); - -export default function WireguardSettings() { - const { pop } = useHistory(); - - return ( - <BackAction action={pop}> - <Layout> - <SettingsContainer> - <NavigationContainer> - <AppNavigationHeader - title={sprintf( - // TRANSLATORS: Title label in navigation bar - // TRANSLATORS: Available placeholders: - // TRANSLATORS: %(wireguard)s - Will be replaced with the string "WireGuard" - messages.pgettext('wireguard-settings-nav', '%(wireguard)s settings'), - { wireguard: strings.wireguard }, - )} - /> - - <NavigationScrollbars> - <SettingsHeader> - <HeaderTitle> - {sprintf( - // TRANSLATORS: Available placeholders: - // TRANSLATORS: %(wireguard)s - Will be replaced with the string "WireGuard" - messages.pgettext('wireguard-settings-view', '%(wireguard)s settings'), - { wireguard: strings.wireguard }, - )} - </HeaderTitle> - </SettingsHeader> - <SettingsContent> - <SettingsStack> - <SettingsGroup> - <PortSelector /> - </SettingsGroup> - - <SettingsGroup> - <ObfuscationSettings /> - </SettingsGroup> - - <SettingsGroup> - <QuantumResistantSetting /> - </SettingsGroup> - - <SettingsGroup> - <IpVersionSetting /> - </SettingsGroup> - - <SettingsGroup> - <MtuSetting /> - </SettingsGroup> - </SettingsStack> - </SettingsContent> - </NavigationScrollbars> - </NavigationContainer> - </SettingsContainer> - </Layout> - </BackAction> - ); -} - -function PortSelector() { - const relaySettings = useSelector((state) => state.settings.relaySettings); - const relaySettingsUpdater = useRelaySettingsUpdater(); - const allowedPortRanges = useSelector((state) => state.settings.wireguardEndpointData.portRanges); - - const wireguardPortItems = useMemo<Array<SelectorItem<number>>>( - () => WIREUGARD_UDP_PORTS.map(mapPortToSelectorItem), - [], - ); - - const port = useMemo(() => { - const port = 'normal' in relaySettings ? relaySettings.normal.wireguard.port : 'any'; - return port === 'any' ? null : port; - }, [relaySettings]); - - const setWireguardPort = useCallback( - async (port: number | null) => { - try { - await relaySettingsUpdater((settings) => { - settings.wireguardConstraints.port = wrapConstraint(port); - return settings; - }); - } catch (e) { - const error = e as Error; - log.error('Failed to update relay settings', error.message); - } - }, - [relaySettingsUpdater], - ); - - const parseValue = useCallback((port: string) => parseInt(port), []); - - const validateValue = useCallback( - (value: number) => isInRanges(value, allowedPortRanges), - [allowedPortRanges], - ); - - const portRangesText = allowedPortRanges - .map(([start, end]) => (start === end ? start : `${start}-${end}`)) - .join(', '); - - return ( - <AriaInputGroup> - <StyledSelectorContainer> - <SelectorWithCustomItem - // TRANSLATORS: The title for the WireGuard port selector. - title={messages.pgettext('wireguard-settings-view', 'Port')} - items={wireguardPortItems} - value={port} - onSelect={setWireguardPort} - inputPlaceholder={messages.pgettext('wireguard-settings-view', 'Port')} - automaticValue={null} - parseValue={parseValue} - modifyValue={removeNonNumericCharacters} - validateValue={validateValue} - maxLength={5} - details={ - <> - <ModalMessage> - {messages.pgettext( - 'wireguard-settings-view', - 'The automatic setting will randomly choose from the valid port ranges shown below.', - )} - </ModalMessage> - <ModalMessage> - {sprintf( - messages.pgettext( - 'wireguard-settings-view', - 'The custom port can be any value inside the valid ranges: %(portRanges)s.', - ), - { portRanges: portRangesText }, - )} - </ModalMessage> - </> - } - /> - </StyledSelectorContainer> - </AriaInputGroup> - ); -} - -function ObfuscationSettings() { - const { setObfuscationSettings } = useAppContext(); - const obfuscationSettings = useSelector((state) => state.settings.obfuscationSettings); - - // TRANSLATORS: Text showing currently selected port. - // TRANSLATORS: Available placeholders: - // TRANSLATORS: %(port)s - Can be either a number between 1 and 65535 or the text "Automatic". - const subLabelTemplate = messages.pgettext('wireguard-settings-view', 'Port: %(port)s'); - - const obfuscationType = obfuscationSettings.selectedObfuscation; - const obfuscationTypeItems: SelectorItem<ObfuscationType>[] = useMemo( - () => [ - { - label: messages.pgettext('wireguard-settings-view', 'Shadowsocks'), - subLabel: sprintf(subLabelTemplate, { - port: formatPortForSubLabel(obfuscationSettings.shadowsocksSettings.port), - }), - value: ObfuscationType.shadowsocks, - details: { - path: RoutePath.shadowsocks, - ariaLabel: messages.pgettext('accessibility', 'Shadowsocks settings'), - }, - }, - { - label: messages.pgettext('wireguard-settings-view', 'UDP-over-TCP'), - subLabel: sprintf(subLabelTemplate, { - port: formatPortForSubLabel(obfuscationSettings.udp2tcpSettings.port), - }), - value: ObfuscationType.udp2tcp, - details: { - path: RoutePath.udpOverTcp, - ariaLabel: messages.pgettext('accessibility', 'UDP-over-TCP settings'), - }, - }, - { - label: messages.pgettext('wireguard-settings-view', 'QUIC'), - value: ObfuscationType.quic, - }, - { - label: messages.gettext('Off'), - value: ObfuscationType.off, - }, - ], - [ - obfuscationSettings.shadowsocksSettings.port, - obfuscationSettings.udp2tcpSettings.port, - subLabelTemplate, - ], - ); - - const selectObfuscationType = useCallback( - async (value: ObfuscationType) => { - await setObfuscationSettings({ - ...obfuscationSettings, - selectedObfuscation: value, - }); - }, - [setObfuscationSettings, obfuscationSettings], - ); - - return ( - <AriaInputGroup> - <StyledSelectorContainer> - <Selector - // TRANSLATORS: The title for the WireGuard obfuscation selector. - title={messages.pgettext('wireguard-settings-view', 'Obfuscation')} - details={ - <ModalMessage> - { - // TRANSLATORS: Describes what WireGuard obfuscation does, how it works and when - // TRANSLATORS: it would be useful to enable it. - messages.pgettext( - 'wireguard-settings-view', - 'Obfuscation hides the WireGuard traffic inside another protocol. It can be used to help circumvent censorship and other types of filtering, where a plain WireGuard connection would be blocked.', - ) - } - </ModalMessage> - } - items={obfuscationTypeItems} - value={obfuscationType} - onSelect={selectObfuscationType} - automaticValue={ObfuscationType.auto} - automaticTestId="automatic-obfuscation" - /> - </StyledSelectorContainer> - </AriaInputGroup> - ); -} - -function formatPortForSubLabel(port: Constraint<number>): string { - return port === 'any' ? messages.gettext('Automatic') : `${port.only}`; -} - -function IpVersionSetting() { - const relaySettingsUpdater = useRelaySettingsUpdater(); - const relaySettings = useSelector((state) => state.settings.relaySettings); - const ipVersion = useMemo(() => { - const ipVersion = 'normal' in relaySettings ? relaySettings.normal.wireguard.ipVersion : 'any'; - return ipVersion === 'any' ? null : ipVersion; - }, [relaySettings]); - - const ipVersionItems: SelectorItem<IpVersion>[] = useMemo( - () => [ - { - label: messages.gettext('IPv4'), - value: 'ipv4', - }, - { - label: messages.gettext('IPv6'), - value: 'ipv6', - }, - ], - [], - ); - - const setIpVersion = useCallback( - async (ipVersion: IpVersion | null) => { - try { - await relaySettingsUpdater((settings) => { - settings.wireguardConstraints.ipVersion = wrapConstraint(ipVersion); - return settings; - }); - } catch (e) { - const error = e as Error; - log.error('Failed to update relay settings', error.message); - } - }, - [relaySettingsUpdater], - ); - - return ( - <AriaInputGroup> - <StyledSelectorContainer> - <Selector - // TRANSLATORS: The title for the WireGuard IP version selector. - title={messages.pgettext('wireguard-settings-view', 'IP version')} - items={ipVersionItems} - value={ipVersion} - onSelect={setIpVersion} - automaticValue={null} - /> - </StyledSelectorContainer> - <Cell.CellFooter> - <AriaDescription> - <Cell.CellFooterText> - {sprintf( - // TRANSLATORS: The hint displayed below the WireGuard IP version selector. - // TRANSLATORS: Available placeholders: - // TRANSLATORS: %(wireguard)s - Will be replaced with the string "WireGuard" - messages.pgettext( - 'wireguard-settings-view', - 'This allows access to %(wireguard)s for devices that only support IPv6.', - ), - { wireguard: strings.wireguard }, - )} - </Cell.CellFooterText> - </AriaDescription> - </Cell.CellFooter> - </AriaInputGroup> - ); -} - -function mtuIsValid(mtu: string): boolean { - const parsedMtu = mtu ? parseInt(mtu) : undefined; - return ( - parsedMtu === undefined || - (parsedMtu >= MIN_WIREGUARD_MTU_VALUE && parsedMtu <= MAX_WIREGUARD_MTU_VALUE) - ); -} - -function MtuSetting() { - const { setWireguardMtu: setWireguardMtuImpl } = useAppContext(); - const mtu = useSelector((state) => state.settings.wireguard.mtu); - - const setMtu = useCallback( - async (mtu?: number) => { - try { - await setWireguardMtuImpl(mtu); - } catch (e) { - const error = e as Error; - log.error('Failed to update mtu value', error.message); - } - }, - [setWireguardMtuImpl], - ); - - const onSubmit = useCallback( - async (value: string) => { - const parsedValue = value === '' ? undefined : parseInt(value, 10); - if (mtuIsValid(value)) { - await setMtu(parsedValue); - } - }, - [setMtu], - ); - - return ( - <AriaInputGroup> - <Cell.Container> - <AriaLabel> - <Cell.InputLabel>{messages.pgettext('wireguard-settings-view', 'MTU')}</Cell.InputLabel> - </AriaLabel> - <AriaInput> - <Cell.AutoSizingTextInput - initialValue={mtu ? mtu.toString() : ''} - inputMode={'numeric'} - maxLength={4} - placeholder={messages.gettext('Default')} - onSubmitValue={onSubmit} - validateValue={mtuIsValid} - submitOnBlur={true} - modifyValue={removeNonNumericCharacters} - /> - </AriaInput> - </Cell.Container> - <Cell.CellFooter> - <AriaDescription> - <Cell.CellFooterText> - {sprintf( - // TRANSLATORS: The hint displayed below the WireGuard MTU input field. - // TRANSLATORS: Available placeholders: - // TRANSLATORS: %(wireguard)s - Will be replaced with the string "WireGuard" - // TRANSLATORS: %(max)d - the maximum possible wireguard mtu value - // TRANSLATORS: %(min)d - the minimum possible wireguard mtu value - messages.pgettext( - 'wireguard-settings-view', - 'Set %(wireguard)s MTU value. Valid range: %(min)d - %(max)d.', - ), - { - wireguard: strings.wireguard, - min: MIN_WIREGUARD_MTU_VALUE, - max: MAX_WIREGUARD_MTU_VALUE, - }, - )} - </Cell.CellFooterText> - </AriaDescription> - </Cell.CellFooter> - </AriaInputGroup> - ); -} - -function QuantumResistantSetting() { - const { setWireguardQuantumResistant } = useAppContext(); - const quantumResistant = useSelector((state) => state.settings.wireguard.quantumResistant); - - const items: SelectorItem<boolean>[] = useMemo( - () => [ - { - label: messages.gettext('On'), - value: true, - }, - { - label: messages.gettext('Off'), - value: false, - }, - ], - [], - ); - - const selectQuantumResistant = useCallback( - async (quantumResistant: boolean | null) => { - await setWireguardQuantumResistant(quantumResistant ?? undefined); - }, - [setWireguardQuantumResistant], - ); - - return ( - <AriaInputGroup> - <StyledSelectorContainer> - <Selector - title={ - // TRANSLATORS: The title for the WireGuard quantum resistance selector. This setting - // TRANSLATORS: makes the cryptography resistant to the future abilities of quantum - // TRANSLATORS: computers. - messages.pgettext('wireguard-settings-view', 'Quantum-resistant tunnel') - } - details={ - <> - <ModalMessage> - {messages.pgettext( - 'wireguard-settings-view', - 'This feature makes the WireGuard tunnel resistant to potential attacks from quantum computers.', - )} - </ModalMessage> - <ModalMessage> - {messages.pgettext( - 'wireguard-settings-view', - 'It does this by performing an extra key exchange using a quantum safe algorithm and mixing the result into WireGuard’s regular encryption. This extra step uses approximately 500 kiB of traffic every time a new tunnel is established.', - )} - </ModalMessage> - </> - } - items={items} - value={quantumResistant ?? null} - onSelect={selectQuantumResistant} - automaticValue={null} - /> - </StyledSelectorContainer> - </AriaInputGroup> - ); -} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/app-main-header/AppMainHeader.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/app-main-header/AppMainHeader.tsx index 5240152272..cf2d6903a1 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/app-main-header/AppMainHeader.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/app-main-header/AppMainHeader.tsx @@ -1,7 +1,7 @@ import { TunnelState } from '../../../shared/daemon-rpc-types'; import { Flex, HeaderProps, Logo, LogoProps, MainHeader } from '../../lib/components'; import { useSelector } from '../../redux/store'; -import { FocusFallback } from '../Focus'; +import { InitialFocus } from '../initial-focus'; import { AppMainHeaderBarAccountButton, AppMainHeaderDeviceInfo, @@ -35,9 +35,9 @@ const AppMainHeader = ({ return ( <MainHeader variant={variant} size={size} {...props}> <Flex $justifyContent="space-between"> - <FocusFallback> + <InitialFocus> {logoVariant !== 'none' ? <Logo variant={logoVariant} /> : <div />} - </FocusFallback> + </InitialFocus> <Flex $gap="medium" $alignItems="center"> {children} </Flex> diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/app-navigation-header/AppNavigationHeader.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/app-navigation-header/AppNavigationHeader.tsx index 80f1f794f4..508a80270d 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/app-navigation-header/AppNavigationHeader.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/app-navigation-header/AppNavigationHeader.tsx @@ -1,11 +1,12 @@ import { useContext } from 'react'; import { NavigationHeader, NavigationHeaderProps } from '../../lib/components'; +import { InitialFocus } from '../initial-focus'; import { NavigationScrollContext } from '../NavigationContainer'; import { AppNavigationHeaderBackButton, AppNavigationHeaderInfoButton } from './components'; export interface NavigationBarProps extends NavigationHeaderProps { - title?: string; + title: string; children?: React.ReactNode; } @@ -14,7 +15,9 @@ const AppNavigationHeader = ({ title, children, ...props }: NavigationBarProps) return ( <NavigationHeader titleVisible={showsBarTitle} {...props}> <AppNavigationHeaderBackButton /> - {title && <NavigationHeader.Title>{title}</NavigationHeader.Title>} + <InitialFocus> + <NavigationHeader.Title>{title}</NavigationHeader.Title> + </InitialFocus> <NavigationHeader.ButtonGroup $justifyContent="flex-end"> {children} </NavigationHeader.ButtonGroup> diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/initial-focus/InitialFocus.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/initial-focus/InitialFocus.tsx new file mode 100644 index 0000000000..731892d0c2 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/initial-focus/InitialFocus.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +import { useInitialFocus } from '../../hooks'; + +type AnyElement = React.ElementType; + +export type InitialFocusProps<E extends AnyElement> = { + children: React.ReactElement<React.ComponentPropsWithRef<E>>; +} & Omit<React.ComponentPropsWithoutRef<E>, 'children'>; + +export function InitialFocus<E extends AnyElement>({ children, ...props }: InitialFocusProps<E>) { + const { ref } = useInitialFocus(); + return React.cloneElement(children, { + ref, + tabIndex: -1, + ...props, + } as React.ComponentPropsWithRef<E>); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/initial-focus/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/initial-focus/index.ts new file mode 100644 index 0000000000..0dfd0f3458 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/initial-focus/index.ts @@ -0,0 +1 @@ +export * from './InitialFocus'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/main-view/ConnectionActionButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/main-view/ConnectionActionButton.tsx deleted file mode 100644 index c7b6c61d29..0000000000 --- a/desktop/packages/mullvad-vpn/src/renderer/components/main-view/ConnectionActionButton.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { useCallback } from 'react'; - -import { messages } from '../../../shared/gettext'; -import log from '../../../shared/logging'; -import { useAppContext } from '../../context'; -import { Button } from '../../lib/components'; -import { useSelector } from '../../redux/store'; - -export default function ConnectionActionButton() { - const tunnelState = useSelector((state) => state.connection.status.state); - - if (tunnelState === 'disconnected' || tunnelState === 'disconnecting') { - return <ConnectButton disabled={tunnelState === 'disconnecting'} />; - } else { - return <DisconnectButton />; - } -} - -function ConnectButton(props: Partial<Parameters<typeof Button>[0]>) { - const { connectTunnel } = useAppContext(); - - const onConnect = useCallback(async () => { - try { - await connectTunnel(); - } catch (e) { - const error = e as Error; - log.error(`Failed to connect the tunnel: ${error.message}`); - } - }, [connectTunnel]); - - return ( - <Button variant="success" onClick={onConnect} {...props}> - <Button.Text>{messages.pgettext('tunnel-control', 'Connect')}</Button.Text> - </Button> - ); -} - -function DisconnectButton() { - const { disconnectTunnel } = useAppContext(); - const tunnelState = useSelector((state) => state.connection.status.state); - - const onDisconnect = useCallback(async () => { - try { - await disconnectTunnel(); - } catch (e) { - const error = e as Error; - log.error(`Failed to disconnect the tunnel: ${error.message}`); - } - }, [disconnectTunnel]); - - const displayAsCancel = tunnelState !== 'connected'; - - return ( - <Button variant="destructive" onClick={onDisconnect}> - <Button.Text> - {displayAsCancel ? messages.gettext('Cancel') : messages.gettext('Disconnect')} - </Button.Text> - </Button> - ); -} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-accordion/SettingsAccordion.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/settings-accordion/SettingsAccordion.tsx new file mode 100644 index 0000000000..389a4c4a92 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-accordion/SettingsAccordion.tsx @@ -0,0 +1,58 @@ +import React from 'react'; + +import { ScrollToAnchorId } from '../../../shared/ipc-types'; +import { useScrollToListItem } from '../../hooks'; +import { Accordion, AccordionProps } from '../../lib/components/accordion'; +import { useHistory } from '../../lib/history'; + +export type SettingsAccordion = Omit<AccordionProps, 'animation'> & { + accordionId: string; + anchorId?: ScrollToAnchorId; +}; + +function SettingsAccordion({ accordionId, anchorId, ...props }: SettingsAccordion) { + const history = useHistory(); + const { location } = history; + const { state } = location; + const initialExpanded = location.state.expandedSections[accordionId]; + const [expanded, setExpanded] = React.useState(initialExpanded); + const { ref, animation } = useScrollToListItem(anchorId); + const titleId = React.useId(); + + const handleOnExpandedChange = React.useCallback( + (value: boolean) => { + setExpanded(value); + history.replace(location, { + ...state, + expandedSections: { + ...state.expandedSections, + [accordionId]: value, + }, + }); + }, + [accordionId, history, location, state], + ); + + return ( + <Accordion + ref={ref} + tabIndex={-1} + animation={animation} + expanded={expanded} + onExpandedChange={handleOnExpandedChange} + titleId={titleId} + aria-labelledby={titleId} + {...props} + /> + ); +} + +const SettingsAccordionNamespace = Object.assign(SettingsAccordion, { + Trigger: Accordion.Trigger, + Header: Accordion.Header, + Content: Accordion.Content, + Title: Accordion.Title, + Icon: Accordion.Icon, +}); + +export { SettingsAccordionNamespace as SettingsAccordion }; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-accordion/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/settings-accordion/index.ts new file mode 100644 index 0000000000..60f9bc00d3 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-accordion/index.ts @@ -0,0 +1 @@ +export * from './SettingsAccordion'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-list-item/SettingsListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/settings-list-item/SettingsListItem.tsx new file mode 100644 index 0000000000..0c505226ef --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-list-item/SettingsListItem.tsx @@ -0,0 +1,30 @@ +import { ScrollToAnchorId } from '../../../shared/ipc-types'; +import { useScrollToListItem } from '../../hooks'; +import { ListItem, ListItemProps } from '../../lib/components/list-item'; + +export type SettingsListItemProps = ListItemProps & { + anchorId?: ScrollToAnchorId; + labelId?: string; +}; + +function SettingsListItem({ labelId, anchorId, ...props }: SettingsListItemProps) { + const { ref, animation } = useScrollToListItem(anchorId); + + return ( + <ListItem ref={ref} aria-labelledby={labelId} tabIndex={-1} animation={animation} {...props} /> + ); +} + +const SettingsListItemNamespace = Object.assign(SettingsListItem, { + Content: ListItem.Content, + Label: ListItem.Label, + Group: ListItem.Group, + Text: ListItem.Text, + Trigger: ListItem.Trigger, + Item: ListItem.Item, + Footer: ListItem.Footer, + Icon: ListItem.Icon, + TextField: ListItem.TextField, +}); + +export { SettingsListItemNamespace as SettingsListItem }; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-list-item/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/settings-list-item/index.ts new file mode 100644 index 0000000000..ccf785cfeb --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-list-item/index.ts @@ -0,0 +1 @@ +export * from './SettingsListItem'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/SettingsListbox.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/SettingsListbox.tsx new file mode 100644 index 0000000000..3b15c84c6a --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/SettingsListbox.tsx @@ -0,0 +1,44 @@ +import React from 'react'; + +import { ScrollToAnchorId } from '../../../shared/ipc-types'; +import { useScrollToListItem } from '../../hooks'; +import { Listbox, ListboxProps } from '../../lib/components/listbox'; +import { BaseOption, InputOption, SplitOption } from './components'; + +export type SettingsListboxProps<T> = Omit<ListboxProps<T>, 'animation'> & { + anchorId?: ScrollToAnchorId; +}; + +function SettingsListbox<T>({ anchorId, ...props }: SettingsListboxProps<T>) { + const { ref, animation } = useScrollToListItem(anchorId); + const labelId = React.useId(); + + return ( + <Listbox + ref={ref} + tabIndex={-1} + role="region" + labelId={labelId} + aria-labelledby={labelId} + animation={animation} + {...props} + /> + ); +} + +const SettingsListboxNamespace = Object.assign(SettingsListbox, { + Item: Listbox.Item, + Content: Listbox.Content, + Label: Listbox.Label, + Group: Listbox.Group, + Text: Listbox.Text, + Footer: Listbox.Footer, + Icon: Listbox.Icon, + Option: Listbox.Option, + Options: Listbox.Options, + BaseOption: BaseOption, + InputOption: InputOption, + SplitOption: SplitOption, +}); + +export { SettingsListboxNamespace as SettingsListbox }; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/base-option/BaseOption.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/base-option/BaseOption.tsx new file mode 100644 index 0000000000..9c618f8c2f --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/base-option/BaseOption.tsx @@ -0,0 +1,27 @@ +import { Listbox } from '../../../../lib/components/listbox'; +import { ListboxOptionProps } from '../../../../lib/components/listbox/components'; + +export type BaseOptionProps<T> = ListboxOptionProps<T>; + +export function BaseOption<T>({ + value, + animation, + disabled, + children, + ...props +}: BaseOptionProps<T>) { + return ( + <Listbox.Option level={1} value={value} animation={animation} disabled={disabled} {...props}> + <Listbox.Option.Trigger> + <Listbox.Option.Item> + <Listbox.Option.Content> + <Listbox.Option.Group> + <Listbox.Option.Icon icon="checkmark" /> + <Listbox.Option.Label>{children}</Listbox.Option.Label> + </Listbox.Option.Group> + </Listbox.Option.Content> + </Listbox.Option.Item> + </Listbox.Option.Trigger> + </Listbox.Option> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/base-option/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/base-option/index.ts new file mode 100644 index 0000000000..2b50b870bd --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/base-option/index.ts @@ -0,0 +1 @@ +export * from './BaseOption'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/index.ts new file mode 100644 index 0000000000..a57c6ded36 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/index.ts @@ -0,0 +1,3 @@ +export * from './base-option'; +export * from './input-option'; +export * from './split-option'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/InputOption.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/InputOption.tsx new file mode 100644 index 0000000000..ec813b07ca --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/InputOption.tsx @@ -0,0 +1,49 @@ +import React from 'react'; + +import { Listbox } from '../../../../lib/components/listbox'; +import { ListboxOptionProps } from '../../../../lib/components/listbox/components'; +import { useTextField } from '../../../../lib/components/text-field'; +import { InputOptionInput, InputOptionLabel, InputOptionTrigger } from './components'; +import { InputOptionProvider } from './InputOptionContext'; + +export type InputOptionProps<T> = ListboxOptionProps<T> & { + defaultValue?: string; + validate?: (value: string) => boolean; + format?: (value: string) => string; +}; + +function InputOption<T>({ + defaultValue, + validate, + format, + children, + ...props +}: InputOptionProps<T>) { + const inputRef = React.useRef<HTMLInputElement>(null); + const labelId = React.useId(); + const inputState = useTextField({ + inputRef, + defaultValue, + validate, + format, + }); + + return ( + <InputOptionProvider inputRef={inputRef} labelId={labelId} inputState={inputState}> + <Listbox.Option level={1} {...props}> + <InputOptionTrigger> + <Listbox.Option.Item> + <Listbox.Option.Content>{children}</Listbox.Option.Content> + </Listbox.Option.Item> + </InputOptionTrigger> + </Listbox.Option> + </InputOptionProvider> + ); +} + +const InputOptionNamespace = Object.assign(InputOption, { + Label: InputOptionLabel, + Input: InputOptionInput, +}); + +export { InputOptionNamespace as InputOption }; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/InputOptionContext.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/InputOptionContext.tsx new file mode 100644 index 0000000000..a549abf409 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/InputOptionContext.tsx @@ -0,0 +1,31 @@ +import React, { createContext, ReactNode, useContext } from 'react'; + +import { UseTextFieldState } from '../../../../lib/components/text-field'; + +type InputOptionContextType = { + inputRef: React.RefObject<HTMLInputElement | null>; + labelId: string | undefined; + inputState: UseTextFieldState; +}; + +const InputOptionContextContext = createContext<InputOptionContextType | undefined>(undefined); + +type InputOptionProviderProps = { + children: ReactNode; +} & InputOptionContextType; + +export const InputOptionProvider = ({ children, ...props }: InputOptionProviderProps) => { + return ( + <InputOptionContextContext.Provider value={props}> + {children} + </InputOptionContextContext.Provider> + ); +}; + +export const useInputOptionContext = (): InputOptionContextType => { + const context = useContext(InputOptionContextContext); + if (!context) { + throw new Error('useInputOptionContext must be used within an InputOptionProvider'); + } + return context; +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/components/index.ts new file mode 100644 index 0000000000..aa7b93b16e --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/components/index.ts @@ -0,0 +1,3 @@ +export * from './input-option-input'; +export * from './input-option-label'; +export * from './input-option-trigger'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/components/input-option-input/InputOptionInput.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/components/input-option-input/InputOptionInput.tsx new file mode 100644 index 0000000000..3d7244c258 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/components/input-option-input/InputOptionInput.tsx @@ -0,0 +1,47 @@ +import React from 'react'; + +import { ListItem } from '../../../../../../lib/components/list-item'; +import { ListItemTextFieldInputProps } from '../../../../../../lib/components/list-item/components/list-item-text-field/components'; +import { useListboxContext } from '../../../../../../lib/components/listbox'; +import { useInputOptionContext } from '../../InputOptionContext'; + +type InputOptionInputProps = ListItemTextFieldInputProps; + +export function InputOptionInput(props: InputOptionInputProps) { + const { onValueChange: listBoxOnValueChange } = useListboxContext<string | undefined>(); + + const { inputRef, labelId, inputState } = useInputOptionContext(); + const { value, invalid, dirty, blur, handleChange, reset } = inputState; + + const handleBlur = React.useCallback(() => { + if (invalid) { + reset(); + } + }, [invalid, reset]); + + const handleSubmit = React.useCallback( + async (event: React.FormEvent) => { + event.preventDefault(); + if (listBoxOnValueChange && !invalid) { + await listBoxOnValueChange?.(value); + blur(); + } + }, + [blur, invalid, listBoxOnValueChange, value], + ); + + return ( + <ListItem.TextField invalid={invalid && dirty} onSubmit={handleSubmit}> + <ListItem.TextField.Input + ref={inputRef} + value={value} + aria-labelledby={labelId} + tabIndex={-1} + inputMode="numeric" + onBlur={handleBlur} + onChange={handleChange} + {...props} + /> + </ListItem.TextField> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/components/input-option-input/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/components/input-option-input/index.ts new file mode 100644 index 0000000000..adb7b801d4 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/components/input-option-input/index.ts @@ -0,0 +1 @@ +export * from './InputOptionInput'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/components/input-option-label/InputOptionLabel.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/components/input-option-label/InputOptionLabel.tsx new file mode 100644 index 0000000000..b2b5fb705d --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/components/input-option-label/InputOptionLabel.tsx @@ -0,0 +1,16 @@ +import { Listbox } from '../../../../../../lib/components/listbox'; +import { useInputOptionContext } from '../../InputOptionContext'; + +export type InputOptionLabelProps = { + children: React.ReactNode; +}; + +export function InputOptionLabel({ children }: InputOptionLabelProps) { + const { labelId } = useInputOptionContext(); + return ( + <Listbox.Option.Group> + <Listbox.Option.Icon icon="checkmark" /> + <Listbox.Option.Label id={labelId}>{children}</Listbox.Option.Label> + </Listbox.Option.Group> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/components/input-option-label/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/components/input-option-label/index.ts new file mode 100644 index 0000000000..97ae323ddc --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/components/input-option-label/index.ts @@ -0,0 +1 @@ +export * from './InputOptionLabel'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/components/input-option-trigger/InputOptionTrigger.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/components/input-option-trigger/InputOptionTrigger.tsx new file mode 100644 index 0000000000..e7343ee11a --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/components/input-option-trigger/InputOptionTrigger.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import styled from 'styled-components'; + +import { useListboxContext } from '../../../../../../lib/components/listbox/'; +import { useListboxOptionContext } from '../../../../../../lib/components/listbox/components/listbox-option/'; +import { + ListboxOptionTriggerProps, + StyledListItemOptionItem, +} from '../../../../../../lib/components/listbox/components/listbox-option/components'; +import { colors } from '../../../../../../lib/foundations'; +import { useInputOptionContext } from '../../InputOptionContext'; + +export type InputOptionTriggerProps = ListboxOptionTriggerProps; + +export const StyledInputOptionTrigger = styled.li` + &&:hover { + ${StyledListItemOptionItem} { + background-color: ${colors.whiteOnBlue10}; + } + } + + &&:active { + ${StyledListItemOptionItem} { + background-color: ${colors.whiteOnBlue20}; + } + } + + &&[aria-selected='true'] { + &:hover { + ${StyledListItemOptionItem} { + background-color: ${colors.green}; + } + } + &:active { + ${StyledListItemOptionItem} { + background-color: ${colors.green}; + } + } + } +`; + +export const InputOptionTrigger = ({ children, ...props }: InputOptionTriggerProps) => { + const { value } = useListboxOptionContext(); + const { + inputRef, + inputState: { value: inputValue }, + } = useInputOptionContext(); + + const { value: selectedValue, onValueChange } = useListboxContext(); + const selected = value === selectedValue; + + const handleClick = React.useCallback(async () => { + inputRef.current?.focus(); + if (!selected) { + await onValueChange?.(inputValue); + } + }, [inputRef, inputValue, onValueChange, selected]); + + const handleFocus = React.useCallback(() => { + inputRef.current?.focus(); + }, [inputRef]); + + return ( + <StyledInputOptionTrigger + role="option" + aria-selected={selected} + tabIndex={-1} + onFocus={handleFocus} + onClick={handleClick} + {...props}> + {children} + </StyledInputOptionTrigger> + ); +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/components/input-option-trigger/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/components/input-option-trigger/index.ts new file mode 100644 index 0000000000..d5e7a3c910 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/components/input-option-trigger/index.ts @@ -0,0 +1 @@ +export * from './InputOptionTrigger'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/index.ts new file mode 100644 index 0000000000..99cdd8032a --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/input-option/index.ts @@ -0,0 +1 @@ +export * from './InputOption'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/SplitOption.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/SplitOption.tsx new file mode 100644 index 0000000000..4c02f43cb0 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/SplitOption.tsx @@ -0,0 +1,22 @@ +import { Flex } from '../../../../lib/components'; +import { Listbox } from '../../../../lib/components/listbox'; +import { ListboxOptionProps } from '../../../../lib/components/listbox/components'; +import { SplitOptionItem, SplitOptionNavigateButton } from './components'; + +export type SplitOptionProps<T> = ListboxOptionProps<T>; + +function SplitOption<T>({ children, ...props }: SplitOptionProps<T>) { + return ( + <Listbox.Option level={1} {...props}> + <Flex>{children}</Flex> + </Listbox.Option> + ); +} + +const SplitOptionNamespace = Object.assign(SplitOption, { + Item: SplitOptionItem, + NavigateButton: SplitOptionNavigateButton, + Label: Listbox.Option.Label, +}); + +export { SplitOptionNamespace as SplitOption }; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/components/index.ts new file mode 100644 index 0000000000..58ae01cb7f --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/components/index.ts @@ -0,0 +1,2 @@ +export * from './split-option-navigate-button'; +export * from './split-option-item'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/components/split-option-item/SplitOptionItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/components/split-option-item/SplitOptionItem.tsx new file mode 100644 index 0000000000..cbe431ad7e --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/components/split-option-item/SplitOptionItem.tsx @@ -0,0 +1,18 @@ +import { Listbox } from '../../../../../../lib/components/listbox'; + +export type ListBoxOptionWithNavigationProps = React.ComponentPropsWithRef<'li'>; + +export function SplitOptionItem({ children, ...props }: ListBoxOptionWithNavigationProps) { + return ( + <Listbox.Option.Trigger {...props}> + <Listbox.Option.Item> + <Listbox.Option.Content> + <Listbox.Option.Group> + <Listbox.Option.Icon icon="checkmark" /> + {children} + </Listbox.Option.Group> + </Listbox.Option.Content> + </Listbox.Option.Item> + </Listbox.Option.Trigger> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/components/split-option-item/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/components/split-option-item/index.ts new file mode 100644 index 0000000000..c00939c9e7 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/components/split-option-item/index.ts @@ -0,0 +1 @@ +export * from './SplitOptionItem'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/components/split-option-navigate-button/SplitOptionNavigateButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/components/split-option-navigate-button/SplitOptionNavigateButton.tsx new file mode 100644 index 0000000000..c942f159c7 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/components/split-option-navigate-button/SplitOptionNavigateButton.tsx @@ -0,0 +1,63 @@ +import { useCallback } from 'react'; +import styled from 'styled-components'; + +import { RoutePath } from '../../../../../../../shared/routes'; +import { Flex, Icon } from '../../../../../../lib/components'; +import { colors } from '../../../../../../lib/foundations'; +import { useHistory } from '../../../../../../lib/history'; + +export type NavigationOptionNavigateProps = { + to: RoutePath; +} & React.ComponentPropsWithRef<'button'>; + +const StyledFlex = styled(Flex)` + background-color: ${colors.blue60}; + height: 100%; +`; + +const StyledSplitOptionNavigateButton = styled.button` + position: relative; + &&::before { + content: ''; + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 1px; + height: 22px; + background-color: ${colors.darkBlue}; + } + &&:hover { + ${StyledFlex} { + background-color: ${colors.blue}; + } + } + &&:active { + ${StyledFlex} { + background-color: ${colors.whiteOnBlue20}; + } + } + &&:focus-visible { + outline: 2px solid ${colors.white}; + outline-offset: -2px; + z-index: 10; + } +`; + +export function SplitOptionNavigateButton({ + to, + children, + ...props +}: NavigationOptionNavigateProps) { + const history = useHistory(); + const navigate = useCallback(() => { + return history.push(to); + }, [history, to]); + + return ( + <StyledSplitOptionNavigateButton onClick={navigate} {...props}> + <StyledFlex $justifyContent="center" $alignItems="center" $padding={{ horizontal: 'medium' }}> + <Icon icon={'chevron-right'} aria-hidden="true" /> + </StyledFlex> + </StyledSplitOptionNavigateButton> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/components/split-option-navigate-button/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/components/split-option-navigate-button/index.ts new file mode 100644 index 0000000000..280c18472a --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/components/split-option-navigate-button/index.ts @@ -0,0 +1 @@ +export * from './SplitOptionNavigateButton'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/index.ts new file mode 100644 index 0000000000..14ff83a168 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/components/split-option/index.ts @@ -0,0 +1 @@ +export * from './SplitOption'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/index.ts new file mode 100644 index 0000000000..69e0ef86bf --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-listbox/index.ts @@ -0,0 +1 @@ +export * from './SettingsListbox'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-navigation-list-item/SettingsNavigationListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/settings-navigation-list-item/SettingsNavigationListItem.tsx new file mode 100644 index 0000000000..ba0f13c480 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-navigation-list-item/SettingsNavigationListItem.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import { RoutePath } from '../../../shared/routes'; +import { useHistory } from '../../lib/history'; +import { SettingsListItem, SettingsListItemProps } from '../settings-list-item'; + +export type SettingsNavigationListItemProps = { + to: RoutePath; +} & SettingsListItemProps; + +function SettingsNavigationListItem({ to, children, ...props }: SettingsNavigationListItemProps) { + const history = useHistory(); + const navigate = React.useCallback(() => history.push(to), [history, to]); + + return ( + <SettingsListItem {...props}> + <SettingsListItem.Trigger onClick={navigate}> + <SettingsListItem.Item> + <SettingsListItem.Content>{children}</SettingsListItem.Content> + </SettingsListItem.Item> + </SettingsListItem.Trigger> + </SettingsListItem> + ); +} + +const SettingsNavigationListItemNamespace = Object.assign(SettingsNavigationListItem, { + Label: SettingsListItem.Label, + Group: SettingsListItem.Group, + Text: SettingsListItem.Text, + Footer: SettingsListItem.Footer, + Icon: SettingsListItem.Icon, +}); + +export { SettingsNavigationListItemNamespace as SettingsNavigationListItem }; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-navigation-list-item/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/settings-navigation-list-item/index.ts new file mode 100644 index 0000000000..e4c54dd8a3 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-navigation-list-item/index.ts @@ -0,0 +1 @@ +export * from './SettingsNavigationListItem'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-toggle-list-item/SettingsToggleListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/settings-toggle-list-item/SettingsToggleListItem.tsx new file mode 100644 index 0000000000..334f1d405c --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-toggle-list-item/SettingsToggleListItem.tsx @@ -0,0 +1,61 @@ +import React from 'react'; + +import { Switch, SwitchProps } from '../../lib/components/switch'; +import { SettingsListItem, SettingsListItemProps } from '../settings-list-item'; +import { + SettingsToggleListItemGroup, + SettingsToggleListItemLabel, + SettingsToggleListItemSwitch, +} from './components'; +import { SettingsToggleListItemProvider } from './SettingsToggleListItemContext'; + +export type SettingsToggleListItemProps = { + description?: string; + checked?: SwitchProps['checked']; + onCheckedChange?: SwitchProps['onCheckedChange']; +} & SettingsListItemProps; + +function SettingsToggleListItem({ + ref, + children, + description, + checked, + onCheckedChange, + disabled, + ...props +}: SettingsToggleListItemProps) { + const descriptionId = React.useId(); + const labelId = React.useId(); + return ( + <SettingsToggleListItemProvider descriptionId={descriptionId}> + <SettingsListItem labelId={labelId} disabled={disabled} {...props}> + <SettingsListItem.Item> + <SettingsListItem.Content> + <Switch + labelId={labelId} + checked={checked} + onCheckedChange={onCheckedChange} + disabled={disabled} + aria-describedby={description ? descriptionId : undefined}> + {children} + </Switch> + </SettingsListItem.Content> + </SettingsListItem.Item> + {description && ( + <SettingsListItem.Footer> + <SettingsListItem.Text id={descriptionId}>{description}</SettingsListItem.Text> + </SettingsListItem.Footer> + )} + </SettingsListItem> + </SettingsToggleListItemProvider> + ); +} +const SettingsToggleListItemNamespace = Object.assign(SettingsToggleListItem, { + Label: SettingsToggleListItemLabel, + Text: SettingsListItem.Text, + Group: SettingsToggleListItemGroup, + Footer: SettingsListItem.Footer, + Switch: SettingsToggleListItemSwitch, +}); + +export { SettingsToggleListItemNamespace as SettingsToggleListItem }; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-toggle-list-item/SettingsToggleListItemContext.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/settings-toggle-list-item/SettingsToggleListItemContext.tsx new file mode 100644 index 0000000000..bc20d02f9c --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-toggle-list-item/SettingsToggleListItemContext.tsx @@ -0,0 +1,33 @@ +import { createContext, useContext } from 'react'; + +type SettingsToggleListItemContextType = { + descriptionId: string; +}; + +const SettingsToggleListItemContext = createContext<SettingsToggleListItemContextType | undefined>( + undefined, +); + +type SettingsToggleListItemProviderProps = + React.PropsWithChildren<SettingsToggleListItemContextType>; + +export const SettingsToggleListItemProvider = ({ + children, + ...props +}: SettingsToggleListItemProviderProps) => { + return ( + <SettingsToggleListItemContext.Provider value={props}> + {children} + </SettingsToggleListItemContext.Provider> + ); +}; + +export const useSettingsToggleListItemContext = (): SettingsToggleListItemContextType => { + const context = useContext(SettingsToggleListItemContext); + if (!context) { + throw new Error( + 'useSettingsToggleListItem must be used within a SettingsToggleListItemProvider', + ); + } + return context; +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-toggle-list-item/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/settings-toggle-list-item/components/index.ts new file mode 100644 index 0000000000..b9eadb5303 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-toggle-list-item/components/index.ts @@ -0,0 +1,3 @@ +export * from './settings-toggle-list-item-group'; +export * from './settings-toggle-list-item-label'; +export * from './settings-toggle-list-item-switch'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-toggle-list-item/components/settings-toggle-list-item-group/SettingsToggleListItemGroup.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/settings-toggle-list-item/components/settings-toggle-list-item-group/SettingsToggleListItemGroup.tsx new file mode 100644 index 0000000000..d654475ecd --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-toggle-list-item/components/settings-toggle-list-item-group/SettingsToggleListItemGroup.tsx @@ -0,0 +1,8 @@ +import { ListItem } from '../../../../lib/components/list-item'; +import { ListItemGroupProps } from '../../../../lib/components/list-item/components'; + +export type SettingsToggleListItemGroup = ListItemGroupProps; + +export function SettingsToggleListItemGroup(props: SettingsToggleListItemGroup) { + return <ListItem.Group $gap="medium" {...props} />; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-toggle-list-item/components/settings-toggle-list-item-group/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/settings-toggle-list-item/components/settings-toggle-list-item-group/index.ts new file mode 100644 index 0000000000..ef07f526af --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-toggle-list-item/components/settings-toggle-list-item-group/index.ts @@ -0,0 +1 @@ +export * from './SettingsToggleListItemGroup'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-toggle-list-item/components/settings-toggle-list-item-label/SettingsToggleListItemLabel.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/settings-toggle-list-item/components/settings-toggle-list-item-label/SettingsToggleListItemLabel.tsx new file mode 100644 index 0000000000..c11a9d658b --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-toggle-list-item/components/settings-toggle-list-item-label/SettingsToggleListItemLabel.tsx @@ -0,0 +1,8 @@ +import { Switch } from '../../../../lib/components/switch'; +import { SwitchLabelProps } from '../../../../lib/components/switch/components/switch-label'; + +export type SettingsToggleListItemLabelProps = SwitchLabelProps; + +export function SettingsToggleListItemLabel(props: SettingsToggleListItemLabelProps) { + return <Switch.Label variant="titleMedium" {...props} />; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-toggle-list-item/components/settings-toggle-list-item-label/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/settings-toggle-list-item/components/settings-toggle-list-item-label/index.ts new file mode 100644 index 0000000000..cd68db8771 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-toggle-list-item/components/settings-toggle-list-item-label/index.ts @@ -0,0 +1 @@ +export * from './SettingsToggleListItemLabel'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-toggle-list-item/components/settings-toggle-list-item-switch/SettingsToggleListItemSwitch.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/settings-toggle-list-item/components/settings-toggle-list-item-switch/SettingsToggleListItemSwitch.tsx new file mode 100644 index 0000000000..05cacc9068 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-toggle-list-item/components/settings-toggle-list-item-switch/SettingsToggleListItemSwitch.tsx @@ -0,0 +1,21 @@ +import styled from 'styled-components'; + +import { Switch } from '../../../../lib/components/switch'; +import { SwitchTriggerProps } from '../../../../lib/components/switch/components'; +import { spacings } from '../../../../lib/foundations'; +import { useSettingsToggleListItemContext } from '../../SettingsToggleListItemContext'; + +export type SettingsToggleListItemSwitchProps = SwitchTriggerProps; + +export const StyledSettingsToggleListItemSwitch = styled(Switch.Trigger)` + margin-left: ${spacings.small}; +`; + +export function SettingsToggleListItemSwitch(props: SettingsToggleListItemSwitchProps) { + const { descriptionId } = useSettingsToggleListItemContext(); + return ( + <Switch.Trigger aria-describedby={descriptionId} {...props}> + <Switch.Thumb /> + </Switch.Trigger> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-toggle-list-item/components/settings-toggle-list-item-switch/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/settings-toggle-list-item/components/settings-toggle-list-item-switch/index.ts new file mode 100644 index 0000000000..fc58780050 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-toggle-list-item/components/settings-toggle-list-item-switch/index.ts @@ -0,0 +1 @@ +export * from './SettingsToggleListItemSwitch'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/settings-toggle-list-item/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/settings-toggle-list-item/index.ts new file mode 100644 index 0000000000..34380a2ff8 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/settings-toggle-list-item/index.ts @@ -0,0 +1 @@ +export * from './SettingsToggleListItem'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/DaitaSettings.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/daita-settings/DaitaSettingsView.tsx index ee95d35a49..5e0aa6d11f 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/DaitaSettings.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/daita-settings/DaitaSettingsView.tsx @@ -2,43 +2,40 @@ import React, { useCallback } from 'react'; import { sprintf } from 'sprintf-js'; import styled from 'styled-components'; -import { strings } from '../../shared/constants'; -import { messages } from '../../shared/gettext'; -import { useAppContext } from '../context'; -import { Button, Flex } from '../lib/components'; -import { spacings } from '../lib/foundations'; -import { useHistory } from '../lib/history'; -import { useBoolean } from '../lib/utility-hooks'; -import { useSelector } from '../redux/store'; -import { AppNavigationHeader } from './'; -import { AriaDescription, AriaInput, AriaInputGroup, AriaLabel } from './AriaGroup'; -import * as Cell from './cell'; -import InfoButton from './InfoButton'; -import { BackAction } from './KeyboardNavigation'; -import { Layout, SettingsContainer } from './Layout'; -import { ModalAlert, ModalAlertType, ModalMessage } from './Modal'; -import { NavigationContainer } from './NavigationContainer'; -import { NavigationScrollbars } from './NavigationScrollbars'; -import PageSlider from './PageSlider'; -import SettingsHeader, { HeaderSubTitle, HeaderTitle } from './SettingsHeader'; +import { strings } from '../../../../shared/constants'; +import { messages } from '../../../../shared/gettext'; +import { useAppContext } from '../../../context'; +import { Button, Flex, Icon, Text } from '../../../lib/components'; +import { useHistory } from '../../../lib/history'; +import { useBoolean } from '../../../lib/utility-hooks'; +import { useSelector } from '../../../redux/store'; +import { AppNavigationHeader } from '../..'; +import * as Cell from '../../cell'; +import InfoButton from '../../InfoButton'; +import { BackAction } from '../../KeyboardNavigation'; +import { Layout, SettingsContainer } from '../../Layout'; +import { ModalAlert, ModalAlertType, ModalMessage } from '../../Modal'; +import { NavigationContainer } from '../../NavigationContainer'; +import { NavigationScrollbars } from '../../NavigationScrollbars'; +import PageSlider from '../../PageSlider'; +import { SettingsToggleListItem } from '../../settings-toggle-list-item'; +import SettingsHeader, { HeaderSubTitle, HeaderTitle } from '../../SettingsHeader'; +import { useShowDaitaMultihopInfo } from './hooks'; const StyledHeaderSubTitle = styled(HeaderSubTitle)({ display: 'inline-block', }); -export const StyledIllustration = styled.img({ +const StyledIllustration = styled.img({ width: '100%', padding: '8px 0 8px', }); -const StyledInfoButton = styled(InfoButton)({ - marginRight: spacings.medium, -}); - const PATH_PREFIX = process.env.NODE_ENV === 'development' ? '../' : ''; -export default function DaitaSettings() { +export function DaitaSettingsView() { const { pop } = useHistory(); + const showDaitaMultihopInfo = useShowDaitaMultihopInfo(); return ( <BackAction action={pop}> @@ -50,6 +47,17 @@ export default function DaitaSettings() { <NavigationScrollbars> <SettingsHeader> <HeaderTitle>{strings.daita}</HeaderTitle> + {showDaitaMultihopInfo && ( + <Flex $gap="small" $alignItems="center"> + <Icon icon="info-circle" color="whiteOnBlue60" size="small" /> + <Text variant="labelTiny" color="whiteAlpha60"> + {messages.pgettext( + 'wireguard-settings-view', + 'Multihop is being used to enable DAITA for your selected location', + )} + </Text> + </Flex> + )} <PageSlider content={[ <React.Fragment key="without-daita"> @@ -189,36 +197,27 @@ function DaitaToggle() { return ( <> - <AriaInputGroup> - <Cell.Container disabled={unavailable}> - <AriaLabel> - <Cell.InputLabel>{messages.gettext('Enable')}</Cell.InputLabel> - </AriaLabel> - <AriaInput> - <Cell.Switch isOn={daita && !unavailable} onChange={setDaita} /> - </AriaInput> - </Cell.Container> - </AriaInputGroup> - <AriaInputGroup> - <Cell.Container disabled={!daita || unavailable}> - <AriaLabel> - <Cell.InputLabel>{directOnlyString}</Cell.InputLabel> - </AriaLabel> - <StyledInfoButton> + <SettingsToggleListItem + anchorId="daita-enable-setting" + disabled={unavailable} + checked={daita && !unavailable} + onCheckedChange={setDaita} + description={unavailable ? featureUnavailableMessage() : undefined}> + <SettingsToggleListItem.Label>{messages.gettext('Enable')}</SettingsToggleListItem.Label> + <SettingsToggleListItem.Switch /> + </SettingsToggleListItem> + <SettingsToggleListItem + disabled={!daita || unavailable} + checked={directOnly && !unavailable} + onCheckedChange={setDirectOnly}> + <SettingsToggleListItem.Label>{directOnlyString}</SettingsToggleListItem.Label> + <SettingsToggleListItem.Group> + <InfoButton> <DirectOnlyModalMessage /> - </StyledInfoButton> - <AriaInput> - <Cell.Switch isOn={directOnly && !unavailable} onChange={setDirectOnly} /> - </AriaInput> - </Cell.Container> - {unavailable ? ( - <Cell.CellFooter> - <AriaDescription> - <Cell.CellFooterText>{featureUnavailableMessage()}</Cell.CellFooterText> - </AriaDescription> - </Cell.CellFooter> - ) : null} - </AriaInputGroup> + </InfoButton> + <SettingsToggleListItem.Switch /> + </SettingsToggleListItem.Group> + </SettingsToggleListItem> <ModalAlert isOpen={confirmationDialogVisible} type={ModalAlertType.caution} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/daita-settings/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/daita-settings/hooks/index.ts new file mode 100644 index 0000000000..ca6c191509 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/daita-settings/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-show-daita-multihop-info'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/daita-settings/hooks/use-show-daita-multihop-info.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/daita-settings/hooks/use-show-daita-multihop-info.ts new file mode 100644 index 0000000000..79bef1644a --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/daita-settings/hooks/use-show-daita-multihop-info.ts @@ -0,0 +1,10 @@ +import { FeatureIndicator } from '../../../../../shared/daemon-rpc-types'; +import { useSelector } from '../../../../redux/store'; + +export const useShowDaitaMultihopInfo = () => { + const tunnelState = useSelector((state) => state.connection.status); + return ( + tunnelState.state === 'connected' && + tunnelState.featureIndicators?.includes(FeatureIndicator.daitaMultihop) + ); +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/daita-settings/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/daita-settings/index.ts new file mode 100644 index 0000000000..f576c71270 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/daita-settings/index.ts @@ -0,0 +1 @@ +export * from './DaitaSettingsView'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/index.ts index e35670b52a..7e721b496a 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/views/index.ts +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/index.ts @@ -1,7 +1,15 @@ export * from './app-info'; export * from './app-upgrade'; +export * from './daita-settings'; export * from './launch'; +export * from './main'; +export * from './multihop-settings'; export * from './login'; +export * from './open-vpn-settings'; export * from './changelog'; export * from './settings'; +export * from './shadowsocks-settings'; export * from './split-tunneling'; +export * from './udp-over-tcp-settings'; +export * from './vpn-settings'; +export * from './wireguard-settings'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/main-view/MainView.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/MainView.tsx index c01ab92c89..f524b8f9b2 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/main-view/MainView.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/MainView.tsx @@ -1,12 +1,12 @@ import styled from 'styled-components'; -import { Spinner } from '../../lib/components'; -import { useSelector } from '../../redux/store'; -import { AppMainHeader } from '../app-main-header'; -import { Container, Layout } from '../Layout'; -import Map from '../Map'; -import NotificationArea from '../NotificationArea'; -import ConnectionPanel from './ConnectionPanel'; +import { Spinner } from '../../../lib/components'; +import { useSelector } from '../../../redux/store'; +import { AppMainHeader } from '../../app-main-header'; +import { Container, Layout } from '../../Layout'; +import Map from '../../Map'; +import NotificationArea from '../../NotificationArea'; +import { ConnectionPanel } from './components'; const StyledContainer = styled(Container)({ position: 'relative', @@ -41,7 +41,7 @@ const StyledMain = styled.main({ maxHeight: '100%', }); -export default function MainView() { +export function MainView() { const connection = useSelector((state) => state.connection); const showSpinner = diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/main-view/ConnectionPanel.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/ConnectionPanel.tsx index 01aa87553b..f990320649 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/main-view/ConnectionPanel.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/ConnectionPanel.tsx @@ -1,20 +1,22 @@ import { useCallback, useEffect } from 'react'; import styled from 'styled-components'; -import { IconButton } from '../../lib/components'; -import { colors } from '../../lib/foundations'; -import { useBoolean } from '../../lib/utility-hooks'; -import { useSelector } from '../../redux/store'; -import CustomScrollbars from '../CustomScrollbars'; -import { BackAction } from '../KeyboardNavigation'; -import ConnectionActionButton from './ConnectionActionButton'; -import ConnectionDetails from './ConnectionDetails'; -import ConnectionStatus from './ConnectionStatus'; -import FeatureIndicators from './FeatureIndicators'; -import Hostname from './Hostname'; -import Location from './Location'; -import SelectLocationButton from './SelectLocationButton'; -import { ConnectionPanelAccordion } from './styles'; +import { IconButton } from '../../../../../lib/components'; +import { colors } from '../../../../../lib/foundations'; +import { useBoolean } from '../../../../../lib/utility-hooks'; +import { useSelector } from '../../../../../redux/store'; +import CustomScrollbars from '../../../../CustomScrollbars'; +import { BackAction } from '../../../../KeyboardNavigation'; +import { ConnectionPanelAccordion } from '../../styles'; +import { + ConnectionActionButton, + ConnectionDetails, + ConnectionStatus, + FeatureIndicators, + Hostname, + Location, + SelectLocationButtons, +} from './components'; const PANEL_MARGIN = '16px'; @@ -68,7 +70,7 @@ const StyledConnectionStatusContainer = styled.div<{ transitionTimingFunction: 'ease-out', })); -export default function ConnectionPanel() { +export function ConnectionPanel() { const [expanded, expandImpl, collapse, toggleExpandedImpl] = useBoolean(); const tunnelState = useSelector((state) => state.connection.status); @@ -118,7 +120,7 @@ export default function ConnectionPanel() { </StyledAccordion> </StyledCustomScrollbars> <StyledConnectionButtonContainer> - <SelectLocationButton /> + <SelectLocationButtons /> <ConnectionActionButton /> </StyledConnectionButtonContainer> </StyledConnectionPanel> diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/connect-button/ConnectButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/connect-button/ConnectButton.tsx new file mode 100644 index 0000000000..2a41ee59fb --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/connect-button/ConnectButton.tsx @@ -0,0 +1,25 @@ +import { useCallback } from 'react'; + +import { messages } from '../../../../../../../../shared/gettext'; +import log from '../../../../../../../../shared/logging'; +import { useAppContext } from '../../../../../../../context'; +import { Button, ButtonProps } from '../../../../../../../lib/components'; + +export function ConnectButton(props: ButtonProps) { + const { connectTunnel } = useAppContext(); + + const onConnect = useCallback(async () => { + try { + await connectTunnel(); + } catch (e) { + const error = e as Error; + log.error(`Failed to connect the tunnel: ${error.message}`); + } + }, [connectTunnel]); + + return ( + <Button variant="success" onClick={onConnect} {...props}> + <Button.Text>{messages.pgettext('tunnel-control', 'Connect')}</Button.Text> + </Button> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/connect-button/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/connect-button/index.ts new file mode 100644 index 0000000000..916bf3f711 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/connect-button/index.ts @@ -0,0 +1 @@ +export * from './ConnectButton'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/connection-action-button/ConnectionActionButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/connection-action-button/ConnectionActionButton.tsx new file mode 100644 index 0000000000..33accc2ce8 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/connection-action-button/ConnectionActionButton.tsx @@ -0,0 +1,12 @@ +import { useSelector } from '../../../../../../../redux/store'; +import { ConnectButton, DisconnectButton } from '../'; + +export function ConnectionActionButton() { + const tunnelState = useSelector((state) => state.connection.status.state); + + if (tunnelState === 'disconnected' || tunnelState === 'disconnecting') { + return <ConnectButton disabled={tunnelState === 'disconnecting'} />; + } else { + return <DisconnectButton />; + } +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/connection-action-button/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/connection-action-button/index.ts new file mode 100644 index 0000000000..e398f98473 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/connection-action-button/index.ts @@ -0,0 +1 @@ +export * from './ConnectionActionButton'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/main-view/ConnectionDetails.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/connection-details/ConnectionDetails.tsx index 859c249a4d..7d5d826222 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/main-view/ConnectionDetails.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/connection-details/ConnectionDetails.tsx @@ -10,11 +10,11 @@ import { TunnelState, TunnelType, tunnelTypeToString, -} from '../../../shared/daemon-rpc-types'; -import { messages } from '../../../shared/gettext'; -import { colors } from '../../lib/foundations'; -import { useSelector } from '../../redux/store'; -import { tinyText } from '../common-styles'; +} from '../../../../../../../../shared/daemon-rpc-types'; +import { messages } from '../../../../../../../../shared/gettext'; +import { colors } from '../../../../../../../lib/foundations'; +import { useSelector } from '../../../../../../../redux/store'; +import { tinyText } from '../../../../../../common-styles'; interface Endpoint { ip: string; @@ -68,7 +68,7 @@ const StyledConnectionDetailsTitle = styled(StyledConnectionDetailsLabel)({ whiteSpace: 'nowrap', }); -export default function ConnectionDetails() { +export function ConnectionDetails() { const reduxConnection = useSelector((state) => state.connection); const [connection, setConnection] = useState(reduxConnection); diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/connection-details/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/connection-details/index.ts new file mode 100644 index 0000000000..05c76b970d --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/connection-details/index.ts @@ -0,0 +1 @@ +export * from './ConnectionDetails'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/main-view/ConnectionStatus.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/connection-status/ConnectionStatus.tsx index 97c6e3a851..527767caa6 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/main-view/ConnectionStatus.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/connection-status/ConnectionStatus.tsx @@ -1,10 +1,10 @@ import styled from 'styled-components'; -import { TunnelState } from '../../../shared/daemon-rpc-types'; -import { messages } from '../../../shared/gettext'; -import { colors } from '../../lib/foundations'; -import { useSelector } from '../../redux/store'; -import { largeText } from '../common-styles'; +import { TunnelState } from '../../../../../../../../shared/daemon-rpc-types'; +import { messages } from '../../../../../../../../shared/gettext'; +import { colors } from '../../../../../../../lib/foundations'; +import { useSelector } from '../../../../../../../redux/store'; +import { largeText } from '../../../../../../common-styles'; const StyledConnectionStatus = styled.span<{ $color: string }>(largeText, (props) => ({ minHeight: '24px', @@ -12,7 +12,7 @@ const StyledConnectionStatus = styled.span<{ $color: string }>(largeText, (props marginBottom: '4px', })); -export default function ConnectionStatus() { +export function ConnectionStatus() { const tunnelState = useSelector((state) => state.connection.status); const color = getConnectionSTatusLabelColor(tunnelState); diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/connection-status/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/connection-status/index.ts new file mode 100644 index 0000000000..87d79f4de6 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/connection-status/index.ts @@ -0,0 +1 @@ +export * from './ConnectionStatus'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/disconnect-button/DisconnectButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/disconnect-button/DisconnectButton.tsx new file mode 100644 index 0000000000..2767318123 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/disconnect-button/DisconnectButton.tsx @@ -0,0 +1,31 @@ +import { useCallback } from 'react'; + +import { messages } from '../../../../../../../../shared/gettext'; +import log from '../../../../../../../../shared/logging'; +import { useAppContext } from '../../../../../../../context'; +import { Button } from '../../../../../../../lib/components'; +import { useSelector } from '../../../../../../../redux/store'; + +export function DisconnectButton() { + const { disconnectTunnel } = useAppContext(); + const tunnelState = useSelector((state) => state.connection.status.state); + + const onDisconnect = useCallback(async () => { + try { + await disconnectTunnel(); + } catch (e) { + const error = e as Error; + log.error(`Failed to disconnect the tunnel: ${error.message}`); + } + }, [disconnectTunnel]); + + const displayAsCancel = tunnelState !== 'connected'; + + return ( + <Button variant="destructive" onClick={onDisconnect}> + <Button.Text> + {displayAsCancel ? messages.gettext('Cancel') : messages.gettext('Disconnect')} + </Button.Text> + </Button> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/disconnect-button/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/disconnect-button/index.ts new file mode 100644 index 0000000000..6404f45336 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/disconnect-button/index.ts @@ -0,0 +1 @@ +export * from './DisconnectButton'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/main-view/FeatureIndicators.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/feature-indicators/FeatureIndicators.tsx index f1d5595b66..b2cadb7aac 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/main-view/FeatureIndicators.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/feature-indicators/FeatureIndicators.tsx @@ -2,14 +2,14 @@ import { useEffect, useRef } from 'react'; import { sprintf } from 'sprintf-js'; import styled from 'styled-components'; -import { strings } from '../../../shared/constants'; -import { FeatureIndicator } from '../../../shared/daemon-rpc-types'; -import { messages } from '../../../shared/gettext'; -import { colors } from '../../lib/foundations'; -import { useStyledRef } from '../../lib/utility-hooks'; -import { useSelector } from '../../redux/store'; -import { tinyText } from '../common-styles'; -import { ConnectionPanelAccordion } from './styles'; +import { messages } from '../../../../../../../../shared/gettext'; +import { FeatureIndicator, Text } from '../../../../../../../lib/components'; +import { colors } from '../../../../../../../lib/foundations'; +import { useStyledRef } from '../../../../../../../lib/utility-hooks'; +import { useSelector } from '../../../../../../../redux/store'; +import { tinyText } from '../../../../../../common-styles'; +import { ConnectionPanelAccordion } from '../../../../styles'; +import { useGetFeatureIndicator } from './hooks'; const LINE_HEIGHT = 22; const GAP = 8; @@ -38,39 +38,18 @@ const StyledFeatureIndicators = styled.div({ const StyledFeatureIndicatorsWrapper = styled.div<{ $expanded: boolean }>((props) => ({ display: 'flex', flexWrap: 'wrap', + alignItems: 'center', gap: `${GAP}px`, - maxHeight: props.$expanded ? 'fit-content' : '52px', + maxHeight: props.$expanded ? 'fit-content' : '56px', overflow: 'hidden', })); -const StyledFeatureIndicatorLabel = styled.span(tinyText, (props) => ({ - display: 'flex', - gap: '4px', - padding: '1px 7px', - justifyContent: 'center', - alignItems: 'center', - borderRadius: '4px', - background: colors.darkBlue, - color: colors.white, - fontWeight: 400, - whiteSpace: 'nowrap', - visibility: 'hidden', - - // Style clickable feature indicators with a border and on-hover effect - boxSizing: 'border-box', // make border act as padding rather than margin - border: 'solid 1px', - borderColor: props.onClick ? colors.blue : colors.darkBlue, - transition: 'background ease-in-out 300ms', - '&&:hover': { - background: props.onClick ? colors.blue60 : undefined, - }, -})); - -const StyledBaseEllipsis = styled.span<{ $display: boolean }>(tinyText, (props) => ({ +const StyledBaseEllipsis = styled(Text)<{ $display: boolean }>((props) => ({ position: 'absolute', top: `${LINE_HEIGHT + GAP}px`, - color: colors.white, - padding: '2px 8px 2px 16px', + padding: '4px 8px', + marginLeft: '8px', + border: '1px solid transparent', display: props.$display ? 'inline' : 'none', })); @@ -98,11 +77,12 @@ interface FeatureIndicatorsProps { // after the second row or overlaps with the invisible ellipsis text will be set to invisible. Then // we can count those and add another ellipsis element which is visible and place it after the last // visible indicator. -export default function FeatureIndicators(props: FeatureIndicatorsProps) { +export function FeatureIndicators(props: FeatureIndicatorsProps) { const tunnelState = useSelector((state) => state.connection.status); const ellipsisRef = useStyledRef<HTMLSpanElement>(); const ellipsisSpacerRef = useStyledRef<HTMLSpanElement>(); const featureIndicatorsContainerRef = useStyledRef<HTMLDivElement>(); + const featureMap = useGetFeatureIndicator(); const featureIndicatorsVisible = tunnelState.state === 'connected' || tunnelState.state === 'connecting'; @@ -128,7 +108,7 @@ export default function FeatureIndicators(props: FeatureIndicatorsProps) { ) { // Get all feature indicator elements. const indicatorElements = Array.from( - featureIndicatorsContainerRef.current.getElementsByTagName('span'), + featureIndicatorsContainerRef.current.getElementsByTagName('button'), ); let lastVisibleIndex = 0; @@ -188,16 +168,21 @@ export default function FeatureIndicators(props: FeatureIndicatorsProps) { ref={featureIndicatorsContainerRef} $expanded={props.expanded}> {sortedIndicators.map((indicator) => { + const feature = featureMap[indicator]; return ( - <StyledFeatureIndicatorLabel + <FeatureIndicator key={indicator.toString()} - data-testid="feature-indicator"> - {getFeatureIndicatorLabel(indicator)} - </StyledFeatureIndicatorLabel> + data-testid="feature-indicator" + onClick={feature.onClick}> + <FeatureIndicator.Text>{feature.label}</FeatureIndicator.Text> + </FeatureIndicator> ); })} </StyledFeatureIndicatorsWrapper> - <StyledEllipsisSpacer $display={!props.expanded} ref={ellipsisSpacerRef}> + <StyledEllipsisSpacer + variant="labelTiny" + $display={!props.expanded} + ref={ellipsisSpacerRef}> { // Mock amount for the spacer ellipsis. This needs to be wider than the real // ellipsis will ever be. @@ -205,6 +190,7 @@ export default function FeatureIndicators(props: FeatureIndicatorsProps) { } </StyledEllipsisSpacer> <StyledEllipsis + variant="labelTiny" onClick={props.expandIsland} $display={!props.expanded} ref={ellipsisRef} @@ -236,53 +222,3 @@ function indicatorShouldBeVisible( // doesn't overlap with the ellipsis. return lineIndex === 0 || (lineIndex === 1 && indicatorRect.right < ellipsisSpacerRect.left); } - -function getFeatureIndicatorLabel(indicator: FeatureIndicator) { - switch (indicator) { - case FeatureIndicator.daita: - return strings.daita; - case FeatureIndicator.daitaMultihop: - return sprintf( - // TRANSLATORS: This is used as a feature indicator to show that DAITA is enabled through - // TRANSLATORS: multihop. - // TRANSLATORS: Available placeholders: - // TRANSLATORS: %(DAITA)s - Is a non-translatable feature "DAITA" - messages.pgettext('connect-view', '%(DAITA)s: Multihop'), - { - DAITA: strings.daita, - }, - ); - case FeatureIndicator.udp2tcp: - case FeatureIndicator.shadowsocks: - case FeatureIndicator.quic: - return messages.pgettext('wireguard-settings-view', 'Obfuscation'); - case FeatureIndicator.multihop: - // TRANSLATORS: This refers to the multihop setting in the VPN settings view. This is - // TRANSLATORS: displayed when the feature is on. - return messages.gettext('Multihop'); - case FeatureIndicator.customDns: - // TRANSLATORS: This refers to the Custom DNS setting in the VPN settings view. This is - // TRANSLATORS: displayed when the feature is on. - return messages.gettext('Custom DNS'); - case FeatureIndicator.customMtu: - return messages.pgettext('wireguard-settings-view', 'MTU'); - case FeatureIndicator.bridgeMode: - return messages.pgettext('openvpn-settings-view', 'Bridge mode'); - case FeatureIndicator.lanSharing: - return messages.pgettext('vpn-settings-view', 'Local network sharing'); - case FeatureIndicator.customMssFix: - return messages.pgettext('openvpn-settings-view', 'Mssfix'); - case FeatureIndicator.lockdownMode: - return messages.pgettext('vpn-settings-view', 'Lockdown mode'); - case FeatureIndicator.splitTunneling: - return strings.splitTunneling; - case FeatureIndicator.serverIpOverride: - return messages.pgettext('settings-import', 'Server IP override'); - case FeatureIndicator.quantumResistance: - // TRANSLATORS: This refers to the quantum resistance setting in the WireGuard settings view. - // TRANSLATORS: This is displayed when the feature is on. - return messages.gettext('Quantum resistance'); - case FeatureIndicator.dnsContentBlockers: - return messages.pgettext('vpn-settings-view', 'DNS content blockers'); - } -} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/feature-indicators/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/feature-indicators/hooks/index.ts new file mode 100644 index 0000000000..74ae78f3d6 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/feature-indicators/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-get-feature-indicator'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/feature-indicators/hooks/use-get-feature-indicator/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/feature-indicators/hooks/use-get-feature-indicator/index.ts new file mode 100644 index 0000000000..eac65c5fe7 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/feature-indicators/hooks/use-get-feature-indicator/index.ts @@ -0,0 +1 @@ +export * from './useGetFeatureIndicator'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/feature-indicators/hooks/use-get-feature-indicator/useGetFeatureIndicator.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/feature-indicators/hooks/use-get-feature-indicator/useGetFeatureIndicator.ts new file mode 100644 index 0000000000..e8cb982c88 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/feature-indicators/hooks/use-get-feature-indicator/useGetFeatureIndicator.ts @@ -0,0 +1,249 @@ +import React from 'react'; +import { sprintf } from 'sprintf-js'; + +import { strings } from '../../../../../../../../../../shared/constants'; +import { FeatureIndicator } from '../../../../../../../../../../shared/daemon-rpc-types'; +import { messages } from '../../../../../../../../../../shared/gettext'; +import { RoutePath } from '../../../../../../../../../../shared/routes'; +import { TransitionType, useHistory } from '../../../../../../../../../lib/history'; + +export const useGetFeatureIndicator = () => { + const history = useHistory(); + + const gotoDaitaFeature = React.useCallback(() => { + history.push(RoutePath.daitaSettings, { + transition: TransitionType.show, + }); + }, [history]); + + const gotoEnableDaitaFeature = React.useCallback(() => { + history.push(RoutePath.daitaSettings, { + transition: TransitionType.show, + options: [ + { + type: 'scroll-to-anchor', + id: 'daita-enable-setting', + }, + ], + }); + }, [history]); + + const gotoMultihopFeature = React.useCallback(() => { + history.push(RoutePath.multihopSettings, { + transition: TransitionType.show, + options: [ + { + type: 'scroll-to-anchor', + id: 'multihop-setting', + }, + ], + }); + }, [history]); + + const gotoCustomDnsFeature = React.useCallback(() => { + history.push(RoutePath.vpnSettings, { + transition: TransitionType.show, + options: [ + { + type: 'scroll-to-anchor', + id: 'custom-dns-settings', + }, + ], + }); + }, [history]); + + const gotoLanSharingFeature = React.useCallback(() => { + history.push(RoutePath.vpnSettings, { + transition: TransitionType.show, + options: [ + { + type: 'scroll-to-anchor', + id: 'allow-lan-setting', + }, + ], + }); + }, [history]); + + const gotoLockdownModeFeature = React.useCallback(() => { + history.push(RoutePath.vpnSettings, { + transition: TransitionType.show, + options: [ + { + type: 'scroll-to-anchor', + id: 'lockdown-mode-setting', + }, + ], + }); + }, [history]); + + const gotoSplitTunnelingFeature = React.useCallback(() => { + history.push(RoutePath.splitTunneling, { + transition: TransitionType.show, + }); + }, [history]); + + const gotoServerIpOverride = React.useCallback(() => { + history.push(RoutePath.settingsImport, { + transition: TransitionType.show, + }); + }, [history]); + + const gotoDnsContentBlockersFeature = React.useCallback(() => { + history.push(RoutePath.vpnSettings, { + transition: TransitionType.show, + expandedSections: { + 'dns-blocker-setting': true, + }, + options: [ + { + type: 'scroll-to-anchor', + id: 'dns-blocker-setting', + }, + ], + }); + }, [history]); + + const gotoMtuFeature = React.useCallback(() => { + history.push(RoutePath.wireguardSettings, { + transition: TransitionType.show, + options: [ + { + type: 'scroll-to-anchor', + id: 'mtu-setting', + }, + ], + }); + }, [history]); + + const gotoQuantumResistantFeature = React.useCallback(() => { + history.push(RoutePath.wireguardSettings, { + transition: TransitionType.show, + options: [ + { + type: 'scroll-to-anchor', + id: 'quantum-resistant-setting', + }, + ], + }); + }, [history]); + + const gotoObfuscation = React.useCallback(() => { + history.push(RoutePath.wireguardSettings, { + transition: TransitionType.show, + options: [ + { + type: 'scroll-to-anchor', + id: 'obfuscation-setting', + }, + ], + }); + }, [history]); + + const gotoBridgeMode = React.useCallback(() => { + history.push(RoutePath.openVpnSettings, { + transition: TransitionType.show, + options: [ + { + type: 'scroll-to-anchor', + id: 'bridge-mode-setting', + }, + ], + }); + }, [history]); + + const goToMssFix = React.useCallback(() => { + history.push(RoutePath.openVpnSettings, { + transition: TransitionType.show, + options: [ + { + type: 'scroll-to-anchor', + id: 'mss-fix-setting', + }, + ], + }); + }, [history]); + + const featureMap: Record<FeatureIndicator, { label: string; onClick?: () => void }> = { + [FeatureIndicator.daita]: { label: strings.daita, onClick: gotoEnableDaitaFeature }, + [FeatureIndicator.daitaMultihop]: { + label: sprintf( + // TRANSLATORS: This is used as a feature indicator to show that DAITA is enabled through + // TRANSLATORS: multihop. + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(DAITA)s - Is a non-translatable feature "DAITA" + messages.pgettext('connect-view', '%(DAITA)s: Multihop'), + { + DAITA: strings.daita, + }, + ), + onClick: gotoDaitaFeature, + }, + [FeatureIndicator.udp2tcp]: { + label: messages.pgettext('wireguard-settings-view', 'Obfuscation'), + onClick: gotoObfuscation, + }, + [FeatureIndicator.shadowsocks]: { + label: messages.pgettext('wireguard-settings-view', 'Obfuscation'), + onClick: gotoObfuscation, + }, + [FeatureIndicator.quic]: { + label: messages.pgettext('wireguard-settings-view', 'Obfuscation'), + onClick: gotoObfuscation, + }, + [FeatureIndicator.multihop]: { + label: + // TRANSLATORS: This refers to the multihop setting in the VPN settings view. This is + // TRANSLATORS: displayed when the feature is on. + messages.gettext('Multihop'), + onClick: gotoMultihopFeature, + }, + [FeatureIndicator.customDns]: { + label: + // TRANSLATORS: This refers to the Custom DNS setting in the VPN settings view. This is + // TRANSLATORS: displayed when the feature is on. + messages.gettext('Custom DNS'), + onClick: gotoCustomDnsFeature, + }, + [FeatureIndicator.customMtu]: { + label: messages.pgettext('wireguard-settings-view', 'MTU'), + onClick: gotoMtuFeature, + }, + [FeatureIndicator.bridgeMode]: { + label: messages.pgettext('openvpn-settings-view', 'Bridge mode'), + onClick: gotoBridgeMode, + }, + [FeatureIndicator.lanSharing]: { + label: messages.pgettext('vpn-settings-view', 'Local network sharing'), + onClick: gotoLanSharingFeature, + }, + [FeatureIndicator.customMssFix]: { + label: messages.pgettext('openvpn-settings-view', 'Mssfix'), + onClick: goToMssFix, + }, + [FeatureIndicator.lockdownMode]: { + label: messages.pgettext('vpn-settings-view', 'Lockdown mode'), + onClick: gotoLockdownModeFeature, + }, + [FeatureIndicator.splitTunneling]: { + label: strings.splitTunneling, + onClick: gotoSplitTunnelingFeature, + }, + [FeatureIndicator.serverIpOverride]: { + label: messages.pgettext('settings-import', 'Server IP override'), + onClick: gotoServerIpOverride, + }, + [FeatureIndicator.quantumResistance]: { + label: + // TRANSLATORS: This refers to the quantum resistance setting in the WireGuard settings view. + // TRANSLATORS: This is displayed when the feature is on. + messages.gettext('Quantum resistance'), + onClick: gotoQuantumResistantFeature, + }, + [FeatureIndicator.dnsContentBlockers]: { + label: messages.pgettext('vpn-settings-view', 'DNS content blockers'), + onClick: gotoDnsContentBlockersFeature, + }, + }; + + return featureMap; +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/feature-indicators/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/feature-indicators/index.ts new file mode 100644 index 0000000000..5045146ed2 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/feature-indicators/index.ts @@ -0,0 +1 @@ +export * from './FeatureIndicators'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/main-view/Hostname.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/hostname/Hostname.tsx index c46c7261af..27cd8d32c9 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/main-view/Hostname.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/hostname/Hostname.tsx @@ -1,13 +1,13 @@ import { sprintf } from 'sprintf-js'; import styled from 'styled-components'; -import { messages } from '../../../shared/gettext'; -import { colors } from '../../lib/foundations'; -import { IConnectionReduxState } from '../../redux/connection/reducers'; -import { useSelector } from '../../redux/store'; -import { smallText } from '../common-styles'; -import Marquee from '../Marquee'; -import { ConnectionPanelAccordion } from './styles'; +import { messages } from '../../../../../../../../shared/gettext'; +import { colors } from '../../../../../../../lib/foundations'; +import { IConnectionReduxState } from '../../../../../../../redux/connection/reducers'; +import { useSelector } from '../../../../../../../redux/store'; +import { smallText } from '../../../../../../common-styles'; +import Marquee from '../../../../../../Marquee'; +import { ConnectionPanelAccordion } from '../../../../styles'; const StyledAccordion = styled(ConnectionPanelAccordion)({ flexShrink: 0, @@ -20,7 +20,7 @@ const StyledHostname = styled.span(smallText, { minHeight: '1em', }); -export default function Hostname() { +export function Hostname() { const tunnelState = useSelector((state) => state.connection.status.state); const connection = useSelector((state) => state.connection); const text = getHostnameText(connection); diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/hostname/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/hostname/index.ts new file mode 100644 index 0000000000..c7dcaa1c27 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/hostname/index.ts @@ -0,0 +1 @@ +export * from './Hostname'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/index.ts new file mode 100644 index 0000000000..54f1c0ee72 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/index.ts @@ -0,0 +1,9 @@ +export * from './connect-button'; +export * from './connection-action-button'; +export * from './connection-details'; +export * from './connection-status'; +export * from './disconnect-button'; +export * from './feature-indicators'; +export * from './hostname'; +export * from './location'; +export * from './select-location-buttons'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/main-view/Location.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/location/Location.tsx index fff4fabe4f..c9088ea23f 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/main-view/Location.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/location/Location.tsx @@ -1,18 +1,18 @@ import styled from 'styled-components'; -import { TunnelState } from '../../../shared/daemon-rpc-types'; -import { colors } from '../../lib/foundations'; -import { useSelector } from '../../redux/store'; -import { largeText } from '../common-styles'; -import Marquee from '../Marquee'; -import { ConnectionPanelAccordion } from './styles'; +import { TunnelState } from '../../../../../../../../shared/daemon-rpc-types'; +import { colors } from '../../../../../../../lib/foundations'; +import { useSelector } from '../../../../../../../redux/store'; +import { largeText } from '../../../../../../common-styles'; +import Marquee from '../../../../../../Marquee'; +import { ConnectionPanelAccordion } from '../../../../styles'; const StyledLocation = styled.span(largeText, { color: colors.white, flexShrink: 0, }); -export default function Location() { +export function Location() { const connection = useSelector((state) => state.connection); const text = getLocationText(connection.status, connection.country, connection.city); diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/location/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/location/index.ts new file mode 100644 index 0000000000..c1b9a927d3 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/location/index.ts @@ -0,0 +1 @@ +export * from './Location'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/select-location-buttons/SelectLocationButtons.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/select-location-buttons/SelectLocationButtons.tsx new file mode 100644 index 0000000000..8627fa9837 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/select-location-buttons/SelectLocationButtons.tsx @@ -0,0 +1,12 @@ +import { useSelector } from '../../../../../../../redux/store'; +import { MultiButton, ReconnectButton, SelectLocationButton } from './components'; + +export function SelectLocationButtons() { + const tunnelState = useSelector((state) => state.connection.status.state); + + if (tunnelState === 'connecting' || tunnelState === 'connected') { + return <MultiButton mainButton={SelectLocationButton} sideButton={ReconnectButton} />; + } else { + return <SelectLocationButton />; + } +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/select-location-buttons/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/select-location-buttons/components/index.ts new file mode 100644 index 0000000000..60087864c4 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/select-location-buttons/components/index.ts @@ -0,0 +1,3 @@ +export * from './multi-button'; +export * from './reconnect-button'; +export * from './select-location-button'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/MultiButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/select-location-buttons/components/multi-button/MultiButton.tsx index e3d773888a..b7d5afcf55 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/MultiButton.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/select-location-buttons/components/multi-button/MultiButton.tsx @@ -1,7 +1,7 @@ import React from 'react'; import styled from 'styled-components'; -import { Button, ButtonProps } from '../lib/components'; +import { Button, ButtonProps } from '../../../../../../../../../lib/components'; const ButtonRow = styled.div({ display: 'flex', diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/select-location-buttons/components/multi-button/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/select-location-buttons/components/multi-button/index.ts new file mode 100644 index 0000000000..225bc69e2e --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/select-location-buttons/components/multi-button/index.ts @@ -0,0 +1 @@ +export * from './MultiButton'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/select-location-buttons/components/reconnect-button/ReconnectButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/select-location-buttons/components/reconnect-button/ReconnectButton.tsx new file mode 100644 index 0000000000..f0a656492f --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/select-location-buttons/components/reconnect-button/ReconnectButton.tsx @@ -0,0 +1,34 @@ +import { useCallback } from 'react'; +import styled from 'styled-components'; + +import { messages } from '../../../../../../../../../../shared/gettext'; +import log from '../../../../../../../../../../shared/logging'; +import { useAppContext } from '../../../../../../../../../context'; +import { Button, ButtonProps, Icon } from '../../../../../../../../../lib/components'; + +const StyledReconnectButton = styled(Button)({ + minWidth: '40px', +}); + +export function ReconnectButton(props: ButtonProps) { + const { reconnectTunnel } = useAppContext(); + + const onReconnect = useCallback(async () => { + try { + await reconnectTunnel(); + } catch (e) { + const error = e as Error; + log.error(`Failed to reconnect the tunnel: ${error.message}`); + } + }, [reconnectTunnel]); + + return ( + <StyledReconnectButton + onClick={onReconnect} + width="fit" + aria-label={messages.gettext('Reconnect')} + {...props}> + <Icon icon="reconnect" /> + </StyledReconnectButton> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/select-location-buttons/components/reconnect-button/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/select-location-buttons/components/reconnect-button/index.ts new file mode 100644 index 0000000000..2b314bc9c9 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/select-location-buttons/components/reconnect-button/index.ts @@ -0,0 +1 @@ +export * from './ReconnectButton'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/main-view/SelectLocationButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/select-location-buttons/components/select-location-button/SelectLocationButton.tsx index 663df7a082..df42394dfc 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/main-view/SelectLocationButton.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/select-location-buttons/components/select-location-button/SelectLocationButton.tsx @@ -1,29 +1,18 @@ import { useCallback, useMemo } from 'react'; import { sprintf } from 'sprintf-js'; -import styled from 'styled-components'; -import { ICustomList } from '../../../shared/daemon-rpc-types'; -import { messages, relayLocations } from '../../../shared/gettext'; -import log from '../../../shared/logging'; -import { RoutePath } from '../../../shared/routes'; -import { useAppContext } from '../../context'; -import { Button, ButtonProps, Icon } from '../../lib/components'; -import { TransitionType, useHistory } from '../../lib/history'; -import { IRelayLocationCountryRedux, RelaySettingsRedux } from '../../redux/settings/reducers'; -import { useSelector } from '../../redux/store'; -import { MultiButton } from '../MultiButton'; +import { ICustomList } from '../../../../../../../../../../shared/daemon-rpc-types'; +import { messages, relayLocations } from '../../../../../../../../../../shared/gettext'; +import { RoutePath } from '../../../../../../../../../../shared/routes'; +import { Button, ButtonProps } from '../../../../../../../../../lib/components'; +import { TransitionType, useHistory } from '../../../../../../../../../lib/history'; +import { + IRelayLocationCountryRedux, + RelaySettingsRedux, +} from '../../../../../../../../../redux/settings/reducers'; +import { useSelector } from '../../../../../../../../../redux/store'; -export default function SelectLocationButtons() { - const tunnelState = useSelector((state) => state.connection.status.state); - - if (tunnelState === 'connecting' || tunnelState === 'connected') { - return <MultiButton mainButton={SelectLocationButton} sideButton={ReconnectButton} />; - } else { - return <SelectLocationButton />; - } -} - -function SelectLocationButton(props: ButtonProps) { +export function SelectLocationButton(props: ButtonProps) { const { push } = useHistory(); const tunnelState = useSelector((state) => state.connection.status.state); @@ -110,30 +99,3 @@ function getRelayName( throw new Error('Unsupported relay settings.'); } } - -const StyledReconnectButton = styled(Button)({ - minWidth: '40px', -}); - -function ReconnectButton(props: ButtonProps) { - const { reconnectTunnel } = useAppContext(); - - const onReconnect = useCallback(async () => { - try { - await reconnectTunnel(); - } catch (e) { - const error = e as Error; - log.error(`Failed to reconnect the tunnel: ${error.message}`); - } - }, [reconnectTunnel]); - - return ( - <StyledReconnectButton - onClick={onReconnect} - width="fit" - aria-label={messages.gettext('Reconnect')} - {...props}> - <Icon icon="reconnect" /> - </StyledReconnectButton> - ); -} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/select-location-buttons/components/select-location-button/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/select-location-buttons/components/select-location-button/index.ts new file mode 100644 index 0000000000..e2eb11039c --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/select-location-buttons/components/select-location-button/index.ts @@ -0,0 +1 @@ +export * from './SelectLocationButton'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/select-location-buttons/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/select-location-buttons/index.ts new file mode 100644 index 0000000000..2865f5ee58 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/components/select-location-buttons/index.ts @@ -0,0 +1 @@ +export * from './SelectLocationButtons'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/index.ts new file mode 100644 index 0000000000..d96e9bcd56 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/connection-panel/index.ts @@ -0,0 +1 @@ +export * from './ConnectionPanel'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/index.ts new file mode 100644 index 0000000000..df6b296b90 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/components/index.ts @@ -0,0 +1 @@ +export * from './connection-panel'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/main/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/index.ts new file mode 100644 index 0000000000..0b6272c8a9 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/index.ts @@ -0,0 +1 @@ +export * from './MainView'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/main-view/styles.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/styles.ts index 58c6e85eb2..f63a40c634 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/main-view/styles.ts +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/main/styles.ts @@ -1,6 +1,6 @@ import styled from 'styled-components'; -import Accordion from '../Accordion'; +import Accordion from '../../Accordion'; export const ConnectionPanelAccordion = styled(Accordion)({ transition: 'height 300ms ease-out', diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/multihop-settings/MultihopSettingsView.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/multihop-settings/MultihopSettingsView.tsx new file mode 100644 index 0000000000..6beb8c4d6b --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/multihop-settings/MultihopSettingsView.tsx @@ -0,0 +1,59 @@ +import styled from 'styled-components'; + +import { messages } from '../../../../shared/gettext'; +import { Flex } from '../../../lib/components'; +import { useHistory } from '../../../lib/history'; +import { AppNavigationHeader } from '../..'; +import * as Cell from '../../cell'; +import { BackAction } from '../../KeyboardNavigation'; +import { Layout, SettingsContainer } from '../../Layout'; +import { NavigationContainer } from '../../NavigationContainer'; +import { NavigationScrollbars } from '../../NavigationScrollbars'; +import SettingsHeader, { HeaderSubTitle, HeaderTitle } from '../../SettingsHeader'; +import { MultihopSetting } from './components'; + +const PATH_PREFIX = process.env.NODE_ENV === 'development' ? '../' : ''; + +const StyledIllustration = styled.img({ + width: '100%', + padding: '8px 0 8px', +}); + +export function MultihopSettingsView() { + const { pop } = useHistory(); + + return ( + <BackAction action={pop}> + <Layout> + <SettingsContainer> + <NavigationContainer> + <AppNavigationHeader title={messages.pgettext('wireguard-settings-view', 'Multihop')} /> + + <NavigationScrollbars> + <SettingsHeader> + <HeaderTitle> + {messages.pgettext('wireguard-settings-view', 'Multihop')} + </HeaderTitle> + <HeaderSubTitle> + <StyledIllustration + src={`${PATH_PREFIX}assets/images/multihop-illustration.svg`} + /> + {messages.pgettext( + 'wireguard-settings-view', + 'Multihop routes your traffic into one WireGuard server and out another, making it harder to trace. This results in increased latency but increases anonymity online.', + )} + </HeaderSubTitle> + </SettingsHeader> + + <Flex $flexDirection="column" $flex={1}> + <Cell.Group> + <MultihopSetting /> + </Cell.Group> + </Flex> + </NavigationScrollbars> + </NavigationContainer> + </SettingsContainer> + </Layout> + </BackAction> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/multihop-settings/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/multihop-settings/components/index.ts new file mode 100644 index 0000000000..bf74f1e5ae --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/multihop-settings/components/index.ts @@ -0,0 +1 @@ +export * from './multihop-setting'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/multihop-settings/components/multihop-setting/MultihopSetting.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/multihop-settings/components/multihop-setting/MultihopSetting.tsx new file mode 100644 index 0000000000..72f77de783 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/multihop-settings/components/multihop-setting/MultihopSetting.tsx @@ -0,0 +1,66 @@ +import { useCallback } from 'react'; +import { sprintf } from 'sprintf-js'; + +import { strings } from '../../../../../../shared/constants'; +import { messages } from '../../../../../../shared/gettext'; +import log from '../../../../../../shared/logging'; +import { useRelaySettingsUpdater } from '../../../../../lib/constraint-updater'; +import { useSelector } from '../../../../../redux/store'; +import { SettingsToggleListItem } from '../../../../settings-toggle-list-item'; + +export function MultihopSetting() { + const relaySettings = useSelector((state) => state.settings.relaySettings); + const relaySettingsUpdater = useRelaySettingsUpdater(); + + const multihop = 'normal' in relaySettings ? relaySettings.normal.wireguard.useMultihop : false; + const unavailable = + 'normal' in relaySettings ? relaySettings.normal.tunnelProtocol === 'openvpn' : true; + + const setMultihop = useCallback( + async (enabled: boolean) => { + try { + await relaySettingsUpdater((settings) => { + settings.wireguardConstraints.useMultihop = enabled; + return settings; + }); + } catch (e) { + const error = e as Error; + log.error('Failed to update WireGuard multihop settings', error.message); + } + }, + [relaySettingsUpdater], + ); + + return ( + <> + <SettingsToggleListItem + anchorId="multihop-setting" + disabled={unavailable} + checked={multihop && !unavailable} + onCheckedChange={setMultihop} + description={unavailable ? featureUnavailableMessage() : undefined}> + <SettingsToggleListItem.Label>{messages.gettext('Enable')}</SettingsToggleListItem.Label> + <SettingsToggleListItem.Switch /> + </SettingsToggleListItem> + </> + ); +} + +function featureUnavailableMessage() { + const tunnelProtocol = messages.pgettext('vpn-settings-view', 'Tunnel protocol'); + const multihop = messages.pgettext('wireguard-settings-view', 'Multihop'); + + return sprintf( + messages.pgettext( + // TRANSLATORS: Informs the user that the feature is only available when WireGuard + // TRANSLATORS: is selected. + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(wireguard)s - will be replaced with WireGuard + // TRANSLATORS: %(tunnelProtocol)s - the name of the tunnel protocol setting + // TRANSLATORS: %(setting)s - the name of the setting + 'wireguard-settings-view', + 'Switch to “%(wireguard)s” in Settings > %(tunnelProtocol)s to make %(setting)s available.', + ), + { wireguard: strings.wireguard, tunnelProtocol, setting: multihop }, + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/multihop-settings/components/multihop-setting/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/multihop-settings/components/multihop-setting/index.ts new file mode 100644 index 0000000000..d89481bb22 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/multihop-settings/components/multihop-setting/index.ts @@ -0,0 +1 @@ +export * from './MultihopSetting'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/multihop-settings/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/multihop-settings/index.ts new file mode 100644 index 0000000000..b5fa5382c3 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/multihop-settings/index.ts @@ -0,0 +1 @@ +export * from './MultihopSettingsView'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/OpenVpnSettingsView.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/OpenVpnSettingsView.tsx new file mode 100644 index 0000000000..a8e47c59aa --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/OpenVpnSettingsView.tsx @@ -0,0 +1,94 @@ +import { useMemo } from 'react'; +import { sprintf } from 'sprintf-js'; +import styled from 'styled-components'; + +import { strings } from '../../../../shared/constants'; +import { messages } from '../../../../shared/gettext'; +import { useHistory } from '../../../lib/history'; +import { useSelector } from '../../../redux/store'; +import { AppNavigationHeader } from '../..'; +import * as Cell from '../../cell'; +import { BackAction } from '../../KeyboardNavigation'; +import { Layout, SettingsContainer } from '../../Layout'; +import { NavigationContainer } from '../../NavigationContainer'; +import { NavigationScrollbars } from '../../NavigationScrollbars'; +import SettingsHeader, { HeaderTitle } from '../../SettingsHeader'; +import { + BridgeModeSetting, + MssFixSetting, + OpenVpnPortSetting, + TransportProtocolSetting, +} from './components'; + +export enum BridgeModeAvailability { + available, + blockedDueToTunnelProtocol, + blockedDueToTransportProtocol, +} + +export const StyledSelectorContainer = styled.div({ + flex: 0, +}); + +export function OpenVpnSettingsView() { + const { pop } = useHistory(); + + const relaySettings = useSelector((state) => state.settings.relaySettings); + + const protocol = useMemo(() => { + const protocol = 'normal' in relaySettings ? relaySettings.normal.openvpn.protocol : undefined; + return protocol === 'any' ? undefined : protocol; + }, [relaySettings]); + + return ( + <BackAction action={pop}> + <Layout> + <SettingsContainer> + <NavigationContainer> + <AppNavigationHeader + title={sprintf( + // TRANSLATORS: Title label in navigation bar + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(openvpn)s - Will be replaced with "OpenVPN" + messages.pgettext('openvpn-settings-nav', '%(openvpn)s settings'), + { openvpn: strings.openvpn }, + )} + /> + + <NavigationScrollbars> + <SettingsHeader> + <HeaderTitle> + {sprintf( + // TRANSLATORS: %(openvpn)s will be replaced with "OpenVPN" + messages.pgettext('openvpn-settings-view', '%(openvpn)s settings'), + { + openvpn: strings.openvpn, + }, + )} + </HeaderTitle> + </SettingsHeader> + + <Cell.Group> + <TransportProtocolSetting /> + </Cell.Group> + + {protocol ? ( + <Cell.Group> + <OpenVpnPortSetting /> + </Cell.Group> + ) : undefined} + + <Cell.Group> + <BridgeModeSetting /> + </Cell.Group> + + <Cell.Group> + <MssFixSetting /> + </Cell.Group> + </NavigationScrollbars> + </NavigationContainer> + </SettingsContainer> + </Layout> + </BackAction> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/components/bridge-mode-setting/BridgeModeSetting.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/components/bridge-mode-setting/BridgeModeSetting.tsx new file mode 100644 index 0000000000..ae0d1a2857 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/components/bridge-mode-setting/BridgeModeSetting.tsx @@ -0,0 +1,164 @@ +import React, { useCallback, useMemo } from 'react'; +import { sprintf } from 'sprintf-js'; + +import { strings } from '../../../../../../shared/constants'; +import { + BridgeState, + RelayProtocol, + TunnelProtocol, +} from '../../../../../../shared/daemon-rpc-types'; +import { messages } from '../../../../../../shared/gettext'; +import log from '../../../../../../shared/logging'; +import { useAppContext } from '../../../../../context'; +import { formatHtml } from '../../../../../lib/html-formatter'; +import { useSelector } from '../../../../../redux/store'; +import InfoButton from '../../../../InfoButton'; +import { ModalMessage } from '../../../../Modal'; +import { SettingsListbox } from '../../../../settings-listbox'; + +export function BridgeModeSetting() { + const { setBridgeState: setBridgeStateImpl } = useAppContext(); + const relaySettings = useSelector((state) => state.settings.relaySettings); + + const bridgeState = useSelector((state) => state.settings.bridgeState); + + const tunnelProtocol = useMemo(() => { + const protocol = 'normal' in relaySettings ? relaySettings.normal.tunnelProtocol : 'any'; + return protocol === 'any' ? null : protocol; + }, [relaySettings]); + + const transportProtocol = useMemo(() => { + const protocol = 'normal' in relaySettings ? relaySettings.normal.openvpn.protocol : 'any'; + return protocol === 'any' ? null : protocol; + }, [relaySettings]); + + const setBridgeState = useCallback( + async (bridgeState: BridgeState) => { + try { + await setBridgeStateImpl(bridgeState); + } catch (e) { + const error = e as Error; + log.error(`Failed to update bridge state: ${error.message}`); + } + }, + [setBridgeStateImpl], + ); + + const onSelectBridgeState = useCallback( + async (newValue: BridgeState) => { + await setBridgeState(newValue); + }, + [setBridgeState], + ); + + const footerText = bridgeModeFooterText(bridgeState === 'on', tunnelProtocol, transportProtocol); + + return ( + <SettingsListbox + anchorId="bridge-mode-setting" + value={bridgeState} + onValueChange={onSelectBridgeState}> + <SettingsListbox.Item> + <SettingsListbox.Content> + <SettingsListbox.Label> + { + // TRANSLATORS: The title for the shadowsocks bridge selector section. + messages.pgettext('openvpn-settings-view', 'Bridge mode') + } + </SettingsListbox.Label> + <InfoButton> + <> + <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> + </> + </InfoButton> + </SettingsListbox.Content> + </SettingsListbox.Item> + <SettingsListbox.Options> + <SettingsListbox.BaseOption value={'auto'}> + {messages.gettext('Automatic')} + </SettingsListbox.BaseOption> + <SettingsListbox.BaseOption + value={'on'} + disabled={tunnelProtocol !== 'openvpn' || transportProtocol === 'udp'}> + {messages.gettext('On')} + </SettingsListbox.BaseOption> + <SettingsListbox.BaseOption value={'off'}> + {messages.gettext('Off')} + </SettingsListbox.BaseOption> + </SettingsListbox.Options> + {footerText !== undefined && ( + <SettingsListbox.Footer> + <SettingsListbox.Text>{footerText}</SettingsListbox.Text> + </SettingsListbox.Footer> + )} + </SettingsListbox> + ); +} + +function bridgeModeFooterText( + bridgeModeOn: boolean, + tunnelProtocol: TunnelProtocol | null, + transportProtocol: RelayProtocol | null, +): 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 + // TRANSLATORS: available. + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(tunnelProtocol)s - the name of the tunnel protocol setting + // TRANSLATORS: %(openvpn)s - will be replaced with OpenVPN + messages.pgettext( + 'openvpn-settings-view', + 'To activate Bridge mode, go back and change <b>%(tunnelProtocol)s</b> to <b>%(openvpn)s</b>.', + ), + { + tunnelProtocol: messages.pgettext('vpn-settings-view', 'Tunnel protocol'), + openvpn: strings.openvpn, + }, + ), + ); + } else if (transportProtocol === 'udp') { + return formatHtml( + sprintf( + // TRANSLATORS: This is used to instruct users how to make the bridge mode setting + // TRANSLATORS: available. + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(transportProtocol)s - the name of the transport protocol setting + // TRANSLATORS: %(automatic)s - the translation of "Automatic" + // TRANSLATORS: %(tcp)s - the translation of "TCP" + messages.pgettext( + 'openvpn-settings-view', + 'To activate Bridge mode, change <b>%(transportProtocol)s</b> to <b>%(automatic)s</b> or <b>%(tcp)s</b>.', + ), + { + transportProtocol: messages.pgettext('openvpn-settings-view', 'Transport protocol'), + automatic: messages.gettext('Automatic'), + tcp: messages.gettext('TCP'), + }, + ), + ); + } +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/components/bridge-mode-setting/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/components/bridge-mode-setting/index.ts new file mode 100644 index 0000000000..779a111e57 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/components/bridge-mode-setting/index.ts @@ -0,0 +1 @@ +export * from './BridgeModeSetting'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/components/index.ts new file mode 100644 index 0000000000..ae2e648b6d --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/components/index.ts @@ -0,0 +1,4 @@ +export * from './bridge-mode-setting'; +export * from './mss-fix-setting'; +export * from './open-vpn-port-setting'; +export * from './transport-protocol-setting'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/components/mss-fix-setting/MssFixSetting.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/components/mss-fix-setting/MssFixSetting.tsx new file mode 100644 index 0000000000..d56b3ddf58 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/components/mss-fix-setting/MssFixSetting.tsx @@ -0,0 +1,125 @@ +import React, { useCallback } from 'react'; +import { sprintf } from 'sprintf-js'; + +import { strings } from '../../../../../../shared/constants'; +import { messages } from '../../../../../../shared/gettext'; +import log from '../../../../../../shared/logging'; +import { removeNonNumericCharacters } from '../../../../../../shared/string-helpers'; +import { useAppContext } from '../../../../../context'; +import { useTextField } from '../../../../../lib/components/text-field'; +import { useSelector } from '../../../../../redux/store'; +import { SettingsListItem } from '../../../../settings-list-item'; + +const MIN_MSSFIX_VALUE = 1000; +const MAX_MSSFIX_VALUE = 1450; + +export function MssFixSetting() { + const { setOpenVpnMssfix: setOpenVpnMssfixImpl } = useAppContext(); + const mssfix = useSelector((state) => state.settings.openVpn.mssfix); + + const inputRef = React.useRef<HTMLInputElement>(null); + const labelId = React.useId(); + const descriptionId = React.useId(); + + const setOpenVpnMssfix = useCallback( + async (mssfix?: number) => { + try { + await setOpenVpnMssfixImpl(mssfix); + } catch (e) { + const error = e as Error; + log.error('Failed to update mssfix value', error.message); + } + }, + [setOpenVpnMssfixImpl], + ); + + const onMssfixSubmit = useCallback( + async (value: string) => { + const parsedValue = value === '' ? undefined : parseInt(value, 10); + if (mssfixIsValid(value)) { + await setOpenVpnMssfix(parsedValue); + } + }, + [setOpenVpnMssfix], + ); + + const { value, handleChange, invalid, dirty, blur, reset } = useTextField({ + inputRef, + defaultValue: mssfix ? mssfix.toString() : '', + format: removeNonNumericCharacters, + validate: mssfixIsValid, + }); + + const handleBlur = React.useCallback(async () => { + if (!invalid && dirty) { + await onMssfixSubmit(value); + } + if (invalid) { + reset(); + } + }, [dirty, invalid, onMssfixSubmit, reset, value]); + + const handleSubmit = React.useCallback( + async (event: React.FormEvent) => { + event.preventDefault(); + if (!invalid) { + await onMssfixSubmit(value); + blur(); + } + }, + [blur, invalid, onMssfixSubmit, value], + ); + + return ( + <SettingsListItem anchorId="mss-fix-setting" aria-labelledby={labelId}> + <SettingsListItem.Item> + <SettingsListItem.Content> + <SettingsListItem.Label id={labelId}> + {messages.pgettext('openvpn-settings-view', 'Mssfix')} + </SettingsListItem.Label> + <SettingsListItem.TextField invalid={invalid} onSubmit={handleSubmit}> + <SettingsListItem.TextField.Input + ref={inputRef} + value={value} + placeholder={messages.gettext('Default')} + inputMode="numeric" + maxLength={4} + aria-labelledby={labelId} + aria-describedby={descriptionId} + onBlur={handleBlur} + onChange={handleChange} + /> + </SettingsListItem.TextField> + </SettingsListItem.Content> + </SettingsListItem.Item> + <SettingsListItem.Footer> + <SettingsListItem.Text id={descriptionId}> + {sprintf( + // TRANSLATORS: The hint displayed below the Mssfix input field. + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(openvpn)s - will be replaced with "OpenVPN" + // TRANSLATORS: %(max)d - the maximum possible mssfix value + // TRANSLATORS: %(min)d - the minimum possible mssfix value + messages.pgettext( + 'openvpn-settings-view', + 'Set %(openvpn)s MSS value. Valid range: %(min)d - %(max)d.', + ), + { + openvpn: strings.openvpn, + min: MIN_MSSFIX_VALUE, + max: MAX_MSSFIX_VALUE, + }, + )} + </SettingsListItem.Text> + </SettingsListItem.Footer> + </SettingsListItem> + ); +} + +function mssfixIsValid(mssfix: string): boolean { + const parsedMssFix = mssfix ? parseInt(mssfix) : undefined; + return ( + parsedMssFix === undefined || + (parsedMssFix >= MIN_MSSFIX_VALUE && parsedMssFix <= MAX_MSSFIX_VALUE) + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/components/mss-fix-setting/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/components/mss-fix-setting/index.ts new file mode 100644 index 0000000000..9cfbf7a7fc --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/components/mss-fix-setting/index.ts @@ -0,0 +1 @@ +export * from './MssFixSetting'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/components/open-vpn-port-setting/OpenVpnPortSetting.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/components/open-vpn-port-setting/OpenVpnPortSetting.tsx new file mode 100644 index 0000000000..9176c4a1a8 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/components/open-vpn-port-setting/OpenVpnPortSetting.tsx @@ -0,0 +1,85 @@ +import { useCallback, useMemo } from 'react'; +import { sprintf } from 'sprintf-js'; +import styled from 'styled-components'; + +import { wrapConstraint } from '../../../../../../shared/daemon-rpc-types'; +import { messages } from '../../../../../../shared/gettext'; +import { useRelaySettingsUpdater } from '../../../../../lib/constraint-updater'; +import { useSelector } from '../../../../../redux/store'; +import { SelectorItem } from '../../../../cell/Selector'; +import { SettingsListbox } from '../../../../settings-listbox'; + +const UDP_PORTS = [1194, 1195, 1196, 1197, 1300, 1301, 1302]; +const TCP_PORTS = [80, 443]; + +export const StyledSelectorContainer = styled.div({ + flex: 0, +}); + +function mapPortToSelectorItem(value: number): SelectorItem<number> { + return { label: value.toString(), value }; +} + +export function OpenVpnPortSetting() { + const relaySettingsUpdater = useRelaySettingsUpdater(); + const relaySettings = useSelector((state) => state.settings.relaySettings); + + const protocol = useMemo(() => { + const protocol = 'normal' in relaySettings ? relaySettings.normal.openvpn.protocol : 'any'; + return protocol === 'any' ? null : protocol; + }, [relaySettings]); + + const port = useMemo(() => { + const port = 'normal' in relaySettings ? relaySettings.normal.openvpn.port : 'any'; + return port === 'any' ? null : port; + }, [relaySettings]); + + const onSelect = useCallback( + async (port: number | null) => { + await relaySettingsUpdater((settings) => { + settings.openvpnConstraints.port = wrapConstraint(port); + return settings; + }); + }, + [relaySettingsUpdater], + ); + + const portItems = { + udp: UDP_PORTS.map(mapPortToSelectorItem), + tcp: TCP_PORTS.map(mapPortToSelectorItem), + }; + + if (protocol === null) { + return null; + } + + return ( + <SettingsListbox value={port} onValueChange={onSelect}> + <SettingsListbox.Item> + <SettingsListbox.Content> + <SettingsListbox.Label> + {sprintf( + // TRANSLATORS: The title for the port selector section. + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(portType)s - a selected protocol (either TCP or UDP) + messages.pgettext('openvpn-settings-view', '%(portType)s port'), + { + portType: protocol.toUpperCase(), + }, + )} + </SettingsListbox.Label> + </SettingsListbox.Content> + </SettingsListbox.Item> + <SettingsListbox.Options> + <SettingsListbox.BaseOption value={null}> + {messages.gettext('Automatic')} + </SettingsListbox.BaseOption> + {portItems[protocol].map((item) => ( + <SettingsListbox.BaseOption key={item.value} value={item.value}> + {item.label} + </SettingsListbox.BaseOption> + ))} + </SettingsListbox.Options> + </SettingsListbox> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/components/open-vpn-port-setting/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/components/open-vpn-port-setting/index.ts new file mode 100644 index 0000000000..9f9d80b366 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/components/open-vpn-port-setting/index.ts @@ -0,0 +1 @@ +export * from './OpenVpnPortSetting'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/components/transport-protocol-setting/TransportProtocolSetting.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/components/transport-protocol-setting/TransportProtocolSetting.tsx new file mode 100644 index 0000000000..0c00e3f22d --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/components/transport-protocol-setting/TransportProtocolSetting.tsx @@ -0,0 +1,72 @@ +import React, { useCallback, useMemo } from 'react'; + +import { RelayProtocol, wrapConstraint } from '../../../../../../shared/daemon-rpc-types'; +import { messages } from '../../../../../../shared/gettext'; +import { useRelaySettingsUpdater } from '../../../../../lib/constraint-updater'; +import { formatHtml } from '../../../../../lib/html-formatter'; +import { useSelector } from '../../../../../redux/store'; +import { SettingsListbox } from '../../../../settings-listbox'; + +export function TransportProtocolSetting() { + const relaySettingsUpdater = useRelaySettingsUpdater(); + const relaySettings = useSelector((state) => state.settings.relaySettings); + const bridgeState = useSelector((state) => state.settings.bridgeState); + + const descriptionId = React.useId(); + + const protocol = useMemo(() => { + const protocol = 'normal' in relaySettings ? relaySettings.normal.openvpn.protocol : 'any'; + return protocol === 'any' ? null : protocol; + }, [relaySettings]); + + const onSelect = useCallback( + async (protocol: RelayProtocol | null) => { + await relaySettingsUpdater((settings) => { + settings.openvpnConstraints.protocol = wrapConstraint(protocol); + settings.openvpnConstraints.port = wrapConstraint<number>(undefined); + return settings; + }); + }, + [relaySettingsUpdater], + ); + + return ( + <SettingsListbox value={protocol} onValueChange={onSelect}> + <SettingsListbox.Item> + <SettingsListbox.Content> + <SettingsListbox.Label> + {messages.pgettext('openvpn-settings-view', 'Transport protocol')} + </SettingsListbox.Label> + </SettingsListbox.Content> + </SettingsListbox.Item> + <SettingsListbox.Options> + <SettingsListbox.BaseOption value={null}> + {messages.gettext('Automatic')} + </SettingsListbox.BaseOption> + <SettingsListbox.BaseOption value={'tcp'}> + {messages.gettext('TCP')} + </SettingsListbox.BaseOption> + <SettingsListbox.BaseOption + value={'udp'} + disabled={bridgeState === 'on'} + aria-describedby={bridgeState === 'on' ? descriptionId : undefined}> + {messages.gettext('UDP')} + </SettingsListbox.BaseOption> + </SettingsListbox.Options> + {bridgeState === 'on' && ( + <SettingsListbox.Footer> + <SettingsListbox.Text id={descriptionId}> + {formatHtml( + // TRANSLATORS: This is used to instruct users how to make UDP mode + // TRANSLATORS: available. + messages.pgettext( + 'openvpn-settings-view', + 'To activate UDP, change <b>Bridge mode</b> to <b>Automatic</b> or <b>Off</b>.', + ), + )} + </SettingsListbox.Text> + </SettingsListbox.Footer> + )} + </SettingsListbox> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/components/transport-protocol-setting/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/components/transport-protocol-setting/index.ts new file mode 100644 index 0000000000..2b822bb755 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/components/transport-protocol-setting/index.ts @@ -0,0 +1 @@ +export * from './TransportProtocolSetting'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/index.ts new file mode 100644 index 0000000000..b5ca71c62b --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/open-vpn-settings/index.ts @@ -0,0 +1 @@ +export * from './OpenVpnSettingsView'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/api-access-methods-list-item/ApiAccessMethodsListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/api-access-methods-list-item/ApiAccessMethodsListItem.tsx index 97079c0047..5ea6d8d81f 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/api-access-methods-list-item/ApiAccessMethodsListItem.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/api-access-methods-list-item/ApiAccessMethodsListItem.tsx @@ -1,17 +1,17 @@ import { messages } from '../../../../../../shared/gettext'; import { RoutePath } from '../../../../../../shared/routes'; -import { NavigationListItem } from '../../../../NavigationListItem'; +import { SettingsNavigationListItem } from '../../../../settings-navigation-list-item'; export function ApiAccessMethodsListItem() { return ( - <NavigationListItem to={RoutePath.apiAccessMethods}> - <NavigationListItem.Label> + <SettingsNavigationListItem to={RoutePath.apiAccessMethods}> + <SettingsNavigationListItem.Label> { // TRANSLATORS: Navigation button to the 'API access methods' view messages.pgettext('settings-view', 'API access') } - </NavigationListItem.Label> - <NavigationListItem.Icon icon="chevron-right" /> - </NavigationListItem> + </SettingsNavigationListItem.Label> + <SettingsNavigationListItem.Icon icon="chevron-right" /> + </SettingsNavigationListItem> ); } diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/app-info-list-item/AppInfoListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/app-info-list-item/AppInfoListItem.tsx index a1e7c58e99..636782fd04 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/app-info-list-item/AppInfoListItem.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/app-info-list-item/AppInfoListItem.tsx @@ -5,9 +5,9 @@ import { RoutePath } from '../../../../../../shared/routes'; import { Flex } from '../../../../../lib/components'; import { Dot } from '../../../../../lib/components/dot'; import { useVersionCurrent, useVersionSuggestedUpgrade } from '../../../../../redux/hooks'; -import { NavigationListItem } from '../../../../NavigationListItem'; +import { SettingsNavigationListItem } from '../../../../settings-navigation-list-item'; -const StyledText = styled(NavigationListItem.Text)` +const StyledText = styled(SettingsNavigationListItem.Text)` margin-top: -4px; `; @@ -16,14 +16,14 @@ export function AppInfoListItem() { const { suggestedUpgrade } = useVersionSuggestedUpgrade(); return ( - <NavigationListItem to={RoutePath.appInfo}> + <SettingsNavigationListItem to={RoutePath.appInfo}> <Flex $flexDirection="column"> - <NavigationListItem.Label> + <SettingsNavigationListItem.Label> { // TRANSLATORS: Navigation button to the 'App info' view messages.pgettext('settings-view', 'App info') } - </NavigationListItem.Label> + </SettingsNavigationListItem.Label> {suggestedUpgrade && ( <StyledText variant="footnoteMini"> { @@ -33,11 +33,11 @@ export function AppInfoListItem() { </StyledText> )} </Flex> - <NavigationListItem.Group> - <NavigationListItem.Text>{current}</NavigationListItem.Text> + <SettingsNavigationListItem.Group> + <SettingsNavigationListItem.Text>{current}</SettingsNavigationListItem.Text> {suggestedUpgrade && <Dot variant="warning" size="small" />} - <NavigationListItem.Icon icon="chevron-right" /> - </NavigationListItem.Group> - </NavigationListItem> + <SettingsNavigationListItem.Icon icon="chevron-right" /> + </SettingsNavigationListItem.Group> + </SettingsNavigationListItem> ); } diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/daita-list-item/DaitaListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/daita-list-item/DaitaListItem.tsx index f1280db9c6..6524d67626 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/daita-list-item/DaitaListItem.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/daita-list-item/DaitaListItem.tsx @@ -1,21 +1,21 @@ import { strings } from '../../../../../../shared/constants'; import { messages } from '../../../../../../shared/gettext'; import { RoutePath } from '../../../../../../shared/routes'; -import { NavigationListItem } from '../../../../NavigationListItem'; +import { SettingsNavigationListItem } from '../../../../settings-navigation-list-item'; import { useIsOn } from './hooks'; export function DaitaListItem() { const isOn = useIsOn(); return ( - <NavigationListItem to={RoutePath.daitaSettings}> - <NavigationListItem.Label>{strings.daita}</NavigationListItem.Label> - <NavigationListItem.Group> - <NavigationListItem.Text> + <SettingsNavigationListItem to={RoutePath.daitaSettings}> + <SettingsNavigationListItem.Label>{strings.daita}</SettingsNavigationListItem.Label> + <SettingsNavigationListItem.Group> + <SettingsNavigationListItem.Text> {isOn ? messages.gettext('On') : messages.gettext('Off')} - </NavigationListItem.Text> - <NavigationListItem.Icon icon="chevron-right" /> - </NavigationListItem.Group> - </NavigationListItem> + </SettingsNavigationListItem.Text> + <SettingsNavigationListItem.Icon icon="chevron-right" /> + </SettingsNavigationListItem.Group> + </SettingsNavigationListItem> ); } diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/debug-list-item/DebugListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/debug-list-item/DebugListItem.tsx index 60fe84f657..b0da1d5bf0 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/debug-list-item/DebugListItem.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/debug-list-item/DebugListItem.tsx @@ -1,11 +1,11 @@ import { RoutePath } from '../../../../../../shared/routes'; -import { NavigationListItem } from '../../../../NavigationListItem'; +import { SettingsNavigationListItem } from '../../../../settings-navigation-list-item'; export function DebugListItem() { return ( - <NavigationListItem to={RoutePath.debug}> - <NavigationListItem.Label>Developer tools</NavigationListItem.Label> - <NavigationListItem.Icon icon="chevron-right" /> - </NavigationListItem> + <SettingsNavigationListItem to={RoutePath.debug}> + <SettingsNavigationListItem.Label>Developer tools</SettingsNavigationListItem.Label> + <SettingsNavigationListItem.Icon icon="chevron-right" /> + </SettingsNavigationListItem> ); } diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/multihop-list-item/MultihopListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/multihop-list-item/MultihopListItem.tsx index e64996cb78..f9f9e24f3c 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/multihop-list-item/MultihopListItem.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/multihop-list-item/MultihopListItem.tsx @@ -1,20 +1,23 @@ import { messages } from '../../../../../../shared/gettext'; import { RoutePath } from '../../../../../../shared/routes'; import { Icon } from '../../../../../lib/components'; -import { ListItem } from '../../../../../lib/components/list-item'; -import { NavigationListItem } from '../../../../NavigationListItem'; +import { SettingsNavigationListItem } from '../../../../settings-navigation-list-item'; import { useIsOn } from './hooks'; export function MultihopListItem() { const isOn = useIsOn(); return ( - <NavigationListItem to={RoutePath.multihopSettings}> - <ListItem.Label>{messages.pgettext('settings-view', 'Multihop')}</ListItem.Label> - <ListItem.Group> - <ListItem.Text>{isOn ? messages.gettext('On') : messages.gettext('Off')}</ListItem.Text> + <SettingsNavigationListItem to={RoutePath.multihopSettings}> + <SettingsNavigationListItem.Label> + {messages.pgettext('settings-view', 'Multihop')} + </SettingsNavigationListItem.Label> + <SettingsNavigationListItem.Group> + <SettingsNavigationListItem.Text> + {isOn ? messages.gettext('On') : messages.gettext('Off')} + </SettingsNavigationListItem.Text> <Icon icon="chevron-right" /> - </ListItem.Group> - </NavigationListItem> + </SettingsNavigationListItem.Group> + </SettingsNavigationListItem> ); } diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/split-tunneling-list-item/SplitTunnelingListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/split-tunneling-list-item/SplitTunnelingListItem.tsx index 9cf84551e8..51a734540f 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/split-tunneling-list-item/SplitTunnelingListItem.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/split-tunneling-list-item/SplitTunnelingListItem.tsx @@ -1,12 +1,12 @@ import { strings } from '../../../../../../shared/constants'; import { RoutePath } from '../../../../../../shared/routes'; -import { NavigationListItem } from '../../../../NavigationListItem'; +import { SettingsNavigationListItem } from '../../../../settings-navigation-list-item'; export function SplitTunnelingListItem() { return ( - <NavigationListItem to={RoutePath.splitTunneling}> - <NavigationListItem.Label>{strings.splitTunneling}</NavigationListItem.Label> - <NavigationListItem.Icon icon="chevron-right" /> - </NavigationListItem> + <SettingsNavigationListItem to={RoutePath.splitTunneling}> + <SettingsNavigationListItem.Label>{strings.splitTunneling}</SettingsNavigationListItem.Label> + <SettingsNavigationListItem.Icon icon="chevron-right" /> + </SettingsNavigationListItem> ); } diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/support-list-item/SupportListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/support-list-item/SupportListItem.tsx index 7157052a92..75815be27b 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/support-list-item/SupportListItem.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/support-list-item/SupportListItem.tsx @@ -1,17 +1,17 @@ import { messages } from '../../../../../../shared/gettext'; import { RoutePath } from '../../../../../../shared/routes'; -import { NavigationListItem } from '../../../../NavigationListItem'; +import { SettingsNavigationListItem } from '../../../../settings-navigation-list-item'; export function SupportListItem() { return ( - <NavigationListItem to={RoutePath.support}> - <NavigationListItem.Label> + <SettingsNavigationListItem to={RoutePath.support}> + <SettingsNavigationListItem.Label> { // TRANSLATORS: Navigation button to the 'Support' view messages.pgettext('settings-view', 'Support') } - </NavigationListItem.Label> - <NavigationListItem.Icon icon="chevron-right" /> - </NavigationListItem> + </SettingsNavigationListItem.Label> + <SettingsNavigationListItem.Icon icon="chevron-right" /> + </SettingsNavigationListItem> ); } diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/user-interface-settings-list-item/UserInterfaceSettingsListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/user-interface-settings-list-item/UserInterfaceSettingsListItem.tsx index a93de63563..bbcb007db9 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/user-interface-settings-list-item/UserInterfaceSettingsListItem.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/user-interface-settings-list-item/UserInterfaceSettingsListItem.tsx @@ -1,17 +1,17 @@ import { messages } from '../../../../../../shared/gettext'; import { RoutePath } from '../../../../../../shared/routes'; -import { NavigationListItem } from '../../../../NavigationListItem'; +import { SettingsNavigationListItem } from '../../../../settings-navigation-list-item'; export function UserInterfaceSettingsListItem() { return ( - <NavigationListItem to={RoutePath.userInterfaceSettings}> - <NavigationListItem.Label> + <SettingsNavigationListItem to={RoutePath.userInterfaceSettings}> + <SettingsNavigationListItem.Label> { // TRANSLATORS: Navigation button to the 'User interface settings' view messages.pgettext('settings-view', 'User interface settings') } - </NavigationListItem.Label> - <NavigationListItem.Icon icon="chevron-right" /> - </NavigationListItem> + </SettingsNavigationListItem.Label> + <SettingsNavigationListItem.Icon icon="chevron-right" /> + </SettingsNavigationListItem> ); } diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/vpn-settings-list-item/VpnSettingsListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/vpn-settings-list-item/VpnSettingsListItem.tsx index e5b3f89bd6..411a44d608 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/vpn-settings-list-item/VpnSettingsListItem.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/vpn-settings-list-item/VpnSettingsListItem.tsx @@ -1,17 +1,17 @@ import { messages } from '../../../../../../shared/gettext'; import { RoutePath } from '../../../../../../shared/routes'; -import { NavigationListItem } from '../../../../NavigationListItem'; +import { SettingsNavigationListItem } from '../../../../settings-navigation-list-item'; export function VpnSettingsListItem() { return ( - <NavigationListItem to={RoutePath.vpnSettings}> - <NavigationListItem.Label> + <SettingsNavigationListItem to={RoutePath.vpnSettings}> + <SettingsNavigationListItem.Label> { // TRANSLATORS: Navigation button to the 'VPN settings' view messages.pgettext('settings-view', 'VPN settings') } - </NavigationListItem.Label> - <NavigationListItem.Icon icon="chevron-right" /> - </NavigationListItem> + </SettingsNavigationListItem.Label> + <SettingsNavigationListItem.Icon icon="chevron-right" /> + </SettingsNavigationListItem> ); } diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/shadowsocks-settings/ShadowsocksSettingsView.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/shadowsocks-settings/ShadowsocksSettingsView.tsx new file mode 100644 index 0000000000..7b13ed0ecf --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/shadowsocks-settings/ShadowsocksSettingsView.tsx @@ -0,0 +1,54 @@ +import styled from 'styled-components'; + +import { messages } from '../../../../shared/gettext'; +import { useHistory } from '../../../lib/history'; +import { AppNavigationHeader } from '../..'; +import * as Cell from '../../cell'; +import { BackAction } from '../../KeyboardNavigation'; +import { Layout, SettingsContainer } from '../../Layout'; +import { NavigationContainer } from '../../NavigationContainer'; +import { NavigationScrollbars } from '../../NavigationScrollbars'; +import SettingsHeader, { HeaderTitle } from '../../SettingsHeader'; +import { ShadowsocksPortSetting } from './components'; + +const StyledContent = styled.div({ + display: 'flex', + flexDirection: 'column', + flex: 1, + marginBottom: '2px', +}); + +export function ShadowsocksSettingsView() { + const { pop } = useHistory(); + + return ( + <BackAction action={pop}> + <Layout> + <SettingsContainer> + <NavigationContainer> + <AppNavigationHeader + title={ + // TRANSLATORS: Title label in navigation bar + messages.pgettext('wireguard-settings-nav', 'Shadowsocks') + } + /> + + <NavigationScrollbars> + <SettingsHeader> + <HeaderTitle> + {messages.pgettext('wireguard-settings-view', 'Shadowsocks')} + </HeaderTitle> + </SettingsHeader> + + <StyledContent> + <Cell.Group> + <ShadowsocksPortSetting /> + </Cell.Group> + </StyledContent> + </NavigationScrollbars> + </NavigationContainer> + </SettingsContainer> + </Layout> + </BackAction> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/shadowsocks-settings/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/shadowsocks-settings/components/index.ts new file mode 100644 index 0000000000..ac69d11d9e --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/shadowsocks-settings/components/index.ts @@ -0,0 +1 @@ +export * from './shadowsocks-port-setting'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/shadowsocks-settings/components/shadowsocks-port-setting/ShadowSocksPortSetting.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/shadowsocks-settings/components/shadowsocks-port-setting/ShadowSocksPortSetting.tsx new file mode 100644 index 0000000000..91876a61f6 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/shadowsocks-settings/components/shadowsocks-port-setting/ShadowSocksPortSetting.tsx @@ -0,0 +1,94 @@ +import React, { useCallback } from 'react'; +import { sprintf } from 'sprintf-js'; + +import { wrapConstraint } from '../../../../../../shared/daemon-rpc-types'; +import { messages } from '../../../../../../shared/gettext'; +import { removeNonNumericCharacters } from '../../../../../../shared/string-helpers'; +import { useAppContext } from '../../../../../context'; +import { useSelector } from '../../../../../redux/store'; +import { SettingsListbox } from '../../../../settings-listbox'; + +const ALLOWED_RANGE = [1, 65535]; + +export function ShadowsocksPortSetting() { + const { setObfuscationSettings } = useAppContext(); + const obfuscationSettings = useSelector((state) => state.settings.obfuscationSettings); + const descriptionId = React.useId(); + + const selectedOption = React.useMemo(() => { + const port = obfuscationSettings.shadowsocksSettings.port; + if (port === 'any') + return { + port: 'any', + value: null, + }; + return { + port: port.only, + value: 'custom', + }; + }, [obfuscationSettings]); + + const setShadowsocksPort = useCallback( + async (port: string | null) => { + await setObfuscationSettings({ + ...obfuscationSettings, + shadowsocksSettings: { + ...obfuscationSettings.shadowsocksSettings, + port: wrapConstraint(typeof port === 'string' ? parseInt(port) : port), + }, + }); + }, + [setObfuscationSettings, obfuscationSettings], + ); + + const validateValue = useCallback((value: string) => { + const port = parseInt(value); + return port >= ALLOWED_RANGE[0] && port <= ALLOWED_RANGE[1]; + }, []); + + return ( + <SettingsListbox value={selectedOption.value} onValueChange={setShadowsocksPort}> + <SettingsListbox.Item> + <SettingsListbox.Content> + <SettingsListbox.Label> + { + // TRANSLATORS: The title for the WireGuard port selector. + messages.pgettext('wireguard-settings-view', 'Port') + } + </SettingsListbox.Label> + </SettingsListbox.Content> + </SettingsListbox.Item> + <SettingsListbox.Options> + <SettingsListbox.BaseOption value={null}> + {messages.gettext('Automatic')} + </SettingsListbox.BaseOption> + <SettingsListbox.InputOption + value="custom" + defaultValue={ + selectedOption.value === 'custom' ? selectedOption.port?.toString() : undefined + } + validate={validateValue} + format={removeNonNumericCharacters}> + <SettingsListbox.InputOption.Label> + {messages.gettext('Custom')} + </SettingsListbox.InputOption.Label> + <SettingsListbox.InputOption.Input + aria-describedby={descriptionId} + type="text" + placeholder={messages.pgettext('wireguard-settings-view', 'Port')} + maxLength={`${ALLOWED_RANGE[1]}`.length} + /> + </SettingsListbox.InputOption> + </SettingsListbox.Options> + <SettingsListbox.Footer> + <SettingsListbox.Text id={descriptionId}> + {sprintf( + // TRANSLATORS: Text describing the valid port range for a port selector. + messages.pgettext('wireguard-settings-view', 'Valid range: %(min)s - %(max)s'), + { min: ALLOWED_RANGE[0], max: ALLOWED_RANGE[1] }, + )} + </SettingsListbox.Text> + </SettingsListbox.Footer> + </SettingsListbox> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/shadowsocks-settings/components/shadowsocks-port-setting/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/shadowsocks-settings/components/shadowsocks-port-setting/index.ts new file mode 100644 index 0000000000..a7857e9ba5 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/shadowsocks-settings/components/shadowsocks-port-setting/index.ts @@ -0,0 +1 @@ +export * from './ShadowSocksPortSetting'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/shadowsocks-settings/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/shadowsocks-settings/index.ts new file mode 100644 index 0000000000..7e8baaf1ee --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/shadowsocks-settings/index.ts @@ -0,0 +1 @@ +export * from './ShadowsocksSettingsView'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/udp-over-tcp-settings/UdpOverTcpSettingsView.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/udp-over-tcp-settings/UdpOverTcpSettingsView.tsx new file mode 100644 index 0000000000..462a6335f5 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/udp-over-tcp-settings/UdpOverTcpSettingsView.tsx @@ -0,0 +1,54 @@ +import styled from 'styled-components'; + +import { messages } from '../../../../shared/gettext'; +import { useHistory } from '../../../lib/history'; +import { AppNavigationHeader } from '../..'; +import * as Cell from '../../cell'; +import { BackAction } from '../../KeyboardNavigation'; +import { Layout, SettingsContainer } from '../../Layout'; +import { NavigationContainer } from '../../NavigationContainer'; +import { NavigationScrollbars } from '../../NavigationScrollbars'; +import SettingsHeader, { HeaderTitle } from '../../SettingsHeader'; +import { UdpOverTcpPortSetting } from './components'; + +const StyledContent = styled.div({ + display: 'flex', + flexDirection: 'column', + flex: 1, + marginBottom: '2px', +}); + +export function UdpOverTcpSettingsView() { + const { pop } = useHistory(); + + return ( + <BackAction action={pop}> + <Layout> + <SettingsContainer> + <NavigationContainer> + <AppNavigationHeader + title={ + // TRANSLATORS: Title label in navigation bar + messages.pgettext('wireguard-settings-nav', 'UDP-over-TCP') + } + /> + + <NavigationScrollbars> + <SettingsHeader> + <HeaderTitle> + {messages.pgettext('wireguard-settings-view', 'UDP-over-TCP')} + </HeaderTitle> + </SettingsHeader> + + <StyledContent> + <Cell.Group> + <UdpOverTcpPortSetting /> + </Cell.Group> + </StyledContent> + </NavigationScrollbars> + </NavigationContainer> + </SettingsContainer> + </Layout> + </BackAction> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/udp-over-tcp-settings/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/udp-over-tcp-settings/components/index.ts new file mode 100644 index 0000000000..6be2d43b04 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/udp-over-tcp-settings/components/index.ts @@ -0,0 +1 @@ +export * from './udp-over-tcp-port-setting'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/udp-over-tcp-settings/components/udp-over-tcp-port-setting/UdpOverTcpPortSetting.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/udp-over-tcp-settings/components/udp-over-tcp-port-setting/UdpOverTcpPortSetting.tsx new file mode 100644 index 0000000000..83d7a3797b --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/udp-over-tcp-settings/components/udp-over-tcp-port-setting/UdpOverTcpPortSetting.tsx @@ -0,0 +1,79 @@ +import { useCallback, useMemo } from 'react'; + +import { + liftConstraint, + LiftedConstraint, + wrapConstraint, +} from '../../../../../../shared/daemon-rpc-types'; +import { messages } from '../../../../../../shared/gettext'; +import { useAppContext } from '../../../../../context'; +import { useSelector } from '../../../../../redux/store'; +import { SelectorItem } from '../../../../cell/Selector'; +import InfoButton from '../../../../InfoButton'; +import { ModalMessage } from '../../../../Modal'; +import { SettingsListbox } from '../../../../settings-listbox'; + +const UDP2TCP_PORTS = [80, 5001]; + +function mapPortToSelectorItem(value: number): SelectorItem<number> { + return { label: value.toString(), value }; +} + +export function UdpOverTcpPortSetting() { + const { setObfuscationSettings } = useAppContext(); + const obfuscationSettings = useSelector((state) => state.settings.obfuscationSettings); + + const port = liftConstraint(obfuscationSettings.udp2tcpSettings.port); + const portItems: SelectorItem<number>[] = useMemo( + () => UDP2TCP_PORTS.map(mapPortToSelectorItem), + [], + ); + + const selectPort = useCallback( + async (port: LiftedConstraint<number>) => { + await setObfuscationSettings({ + ...obfuscationSettings, + udp2tcpSettings: { + ...obfuscationSettings.udp2tcpSettings, + port: wrapConstraint(port), + }, + }); + }, + [setObfuscationSettings, obfuscationSettings], + ); + + return ( + <SettingsListbox value={port} onValueChange={selectPort}> + <SettingsListbox.Item> + <SettingsListbox.Content> + <SettingsListbox.Label> + { + // TRANSLATORS: The title for the WireGuard port selector. + messages.pgettext('wireguard-settings-view', 'Port') + } + </SettingsListbox.Label> + <InfoButton> + <ModalMessage> + {messages.pgettext( + 'wireguard-settings-view', + 'Which TCP port the UDP-over-TCP obfuscation protocol should connect to on the VPN server.', + )} + </ModalMessage> + </InfoButton> + </SettingsListbox.Content> + </SettingsListbox.Item> + <SettingsListbox.Options> + <SettingsListbox.BaseOption value={'any'}> + {messages.gettext('Automatic')} + </SettingsListbox.BaseOption> + {portItems.map((item) => { + return ( + <SettingsListbox.BaseOption key={item.value} value={item.value}> + {item.label} + </SettingsListbox.BaseOption> + ); + })} + </SettingsListbox.Options> + </SettingsListbox> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/udp-over-tcp-settings/components/udp-over-tcp-port-setting/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/udp-over-tcp-settings/components/udp-over-tcp-port-setting/index.ts new file mode 100644 index 0000000000..817680d6fe --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/udp-over-tcp-settings/components/udp-over-tcp-port-setting/index.ts @@ -0,0 +1 @@ +export * from './UdpOverTcpPortSetting'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/udp-over-tcp-settings/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/udp-over-tcp-settings/index.ts new file mode 100644 index 0000000000..b8de7ff75d --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/udp-over-tcp-settings/index.ts @@ -0,0 +1 @@ +export * from './UdpOverTcpSettingsView'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/VpnSettingsView.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/VpnSettingsView.tsx new file mode 100644 index 0000000000..7e703db33c --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/VpnSettingsView.tsx @@ -0,0 +1,98 @@ +import { messages } from '../../../../shared/gettext'; +import { useHistory } from '../../../lib/history'; +import { AppNavigationHeader } from '../..'; +import { BackAction } from '../../KeyboardNavigation'; +import { + Layout, + SettingsContainer, + SettingsContent, + SettingsGroup, + SettingsStack, +} from '../../Layout'; +import { NavigationContainer } from '../../NavigationContainer'; +import { NavigationScrollbars } from '../../NavigationScrollbars'; +import SettingsHeader, { HeaderTitle } from '../../SettingsHeader'; +import { + AllowLanSetting, + AutoConnectSetting, + AutoStartSetting, + CustomDnsSettings, + DnsBlockerSettings, + EnableIpv6Setting, + IpOverrideSettings, + KillSwitchSetting, + LockdownModeSetting, + OpenVpnSettings, + TunnelProtocolSetting, + WireguardSettings, +} from './components'; + +export function VpnSettingsView() { + const { pop } = useHistory(); + + return ( + <BackAction action={pop}> + <Layout> + <SettingsContainer> + <NavigationContainer> + <AppNavigationHeader + title={ + // TRANSLATORS: Title label in navigation bar + messages.pgettext('vpn-settings-view', 'VPN settings') + } + /> + + <NavigationScrollbars> + <SettingsHeader> + <HeaderTitle>{messages.pgettext('vpn-settings-view', 'VPN settings')}</HeaderTitle> + </SettingsHeader> + + <SettingsContent> + <SettingsStack> + <SettingsGroup> + <AutoStartSetting /> + <AutoConnectSetting /> + </SettingsGroup> + + <SettingsGroup> + <AllowLanSetting /> + </SettingsGroup> + + <SettingsGroup> + <DnsBlockerSettings /> + </SettingsGroup> + + <SettingsGroup> + <EnableIpv6Setting /> + </SettingsGroup> + + <SettingsGroup> + <KillSwitchSetting /> + <LockdownModeSetting /> + </SettingsGroup> + + <SettingsGroup> + <TunnelProtocolSetting /> + </SettingsGroup> + + <SettingsGroup> + <WireguardSettings /> + <OpenVpnSettings /> + </SettingsGroup> + + <SettingsGroup> + <CustomDnsSettings /> + </SettingsGroup> + + <SettingsGroup> + <IpOverrideSettings /> + </SettingsGroup> + </SettingsStack> + </SettingsContent> + </NavigationScrollbars> + </NavigationContainer> + </SettingsContainer> + </Layout> + </BackAction> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/allow-lan-setting/AllowLanSetting.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/allow-lan-setting/AllowLanSetting.tsx new file mode 100644 index 0000000000..73c36df204 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/allow-lan-setting/AllowLanSetting.tsx @@ -0,0 +1,55 @@ +import styled from 'styled-components'; + +import { messages } from '../../../../../../shared/gettext'; +import { useAppContext } from '../../../../../context'; +import { spacings } from '../../../../../lib/foundations'; +import { useSelector } from '../../../../../redux/store'; +import InfoButton from '../../../../InfoButton'; +import { ModalMessage } from '../../../../Modal'; +import { SettingsToggleListItem } from '../../../../settings-toggle-list-item'; + +const LanIpRanges = styled.ul({ + listStyle: 'disc outside', + marginLeft: spacings.large, +}); + +export function AllowLanSetting() { + const allowLan = useSelector((state) => state.settings.allowLan); + const { setAllowLan } = useAppContext(); + + return ( + <SettingsToggleListItem + anchorId="allow-lan-setting" + checked={allowLan} + onCheckedChange={setAllowLan}> + <SettingsToggleListItem.Label> + {messages.pgettext('vpn-settings-view', 'Local network sharing')} + </SettingsToggleListItem.Label> + <SettingsToggleListItem.Group> + <InfoButton> + <ModalMessage> + {messages.pgettext( + 'vpn-settings-view', + 'This feature allows access to other devices on the local network, such as for sharing, printing, streaming, etc.', + )} + </ModalMessage> + <ModalMessage> + {messages.pgettext( + 'vpn-settings-view', + 'It does this by allowing network communication outside the tunnel to local multicast and broadcast ranges as well as to and from these private IP ranges:', + )} + <LanIpRanges> + <li>10.0.0.0/8</li> + <li>172.16.0.0/12</li> + <li>192.168.0.0/16</li> + <li>169.254.0.0/16</li> + <li>fe80::/10</li> + <li>fc00::/7</li> + </LanIpRanges> + </ModalMessage> + </InfoButton> + <SettingsToggleListItem.Switch /> + </SettingsToggleListItem.Group> + </SettingsToggleListItem> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/allow-lan-setting/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/allow-lan-setting/index.ts new file mode 100644 index 0000000000..b36af1258d --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/allow-lan-setting/index.ts @@ -0,0 +1 @@ +export * from './AllowLanSetting'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/auto-connect-setting/AutoConnectSetting.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/auto-connect-setting/AutoConnectSetting.tsx new file mode 100644 index 0000000000..c930536706 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/auto-connect-setting/AutoConnectSetting.tsx @@ -0,0 +1,26 @@ +import { messages } from '../../../../../../shared/gettext'; +import { useAppContext } from '../../../../../context'; +import { useSelector } from '../../../../../redux/store'; +import { SettingsToggleListItem } from '../../../../settings-toggle-list-item'; + +export function AutoConnectSetting() { + const autoConnect = useSelector((state) => state.settings.guiSettings.autoConnect); + const { setAutoConnect } = useAppContext(); + + const footer = messages.pgettext( + 'vpn-settings-view', + 'Automatically connect to a server when the app launches.', + ); + + return ( + <SettingsToggleListItem + checked={autoConnect} + onCheckedChange={setAutoConnect} + description={footer}> + <SettingsToggleListItem.Label> + {messages.pgettext('vpn-settings-view', 'Auto-connect')} + </SettingsToggleListItem.Label> + <SettingsToggleListItem.Switch /> + </SettingsToggleListItem> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/auto-connect-setting/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/auto-connect-setting/index.ts new file mode 100644 index 0000000000..0b1ee1b790 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/auto-connect-setting/index.ts @@ -0,0 +1 @@ +export * from './AutoConnectSetting'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/auto-start-setting/AutoStartSetting.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/auto-start-setting/AutoStartSetting.tsx new file mode 100644 index 0000000000..7dd96b73d6 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/auto-start-setting/AutoStartSetting.tsx @@ -0,0 +1,33 @@ +import { useCallback } from 'react'; + +import { messages } from '../../../../../../shared/gettext'; +import log from '../../../../../../shared/logging'; +import { useAppContext } from '../../../../../context'; +import { useSelector } from '../../../../../redux/store'; +import { SettingsToggleListItem } from '../../../../settings-toggle-list-item'; + +export function AutoStartSetting() { + const autoStart = useSelector((state) => state.settings.autoStart); + const { setAutoStart: setAutoStartImpl } = useAppContext(); + + const setAutoStart = useCallback( + async (autoStart: boolean) => { + try { + await setAutoStartImpl(autoStart); + } catch (e) { + const error = e as Error; + log.error(`Cannot set auto-start: ${error.message}`); + } + }, + [setAutoStartImpl], + ); + + return ( + <SettingsToggleListItem checked={autoStart} onCheckedChange={setAutoStart}> + <SettingsToggleListItem.Label> + {messages.pgettext('vpn-settings-view', 'Launch app on start-up')} + </SettingsToggleListItem.Label> + <SettingsToggleListItem.Switch /> + </SettingsToggleListItem> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/auto-start-setting/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/auto-start-setting/index.ts new file mode 100644 index 0000000000..c2b2ae04c1 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/auto-start-setting/index.ts @@ -0,0 +1 @@ +export * from './AutoStartSetting'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/CustomDnsSettings.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/custom-dns-settings/CustomDnsSettings.tsx index cb01154020..dc12364632 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/CustomDnsSettings.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/custom-dns-settings/CustomDnsSettings.tsx @@ -1,22 +1,18 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { messages } from '../../shared/gettext'; -import { useAppContext } from '../context'; -import { Button, IconButton } from '../lib/components'; -import { formatHtml } from '../lib/html-formatter'; -import { IpAddress } from '../lib/ip'; -import { useBoolean, useMounted, useStyledRef } from '../lib/utility-hooks'; -import { useSelector } from '../redux/store'; -import Accordion from './Accordion'; -import { - AriaDescribed, - AriaDescription, - AriaDescriptionGroup, - AriaInput, - AriaInputGroup, - AriaLabel, -} from './AriaGroup'; -import * as Cell from './cell'; +import { messages } from '../../../../../../shared/gettext'; +import { useAppContext } from '../../../../../context'; +import { Button, IconButton } from '../../../../../lib/components'; +import { Accordion } from '../../../../../lib/components/accordion'; +import { formatHtml } from '../../../../../lib/html-formatter'; +import { IpAddress } from '../../../../../lib/ip'; +import { useBoolean, useMounted, useStyledRef } from '../../../../../lib/utility-hooks'; +import { useSelector } from '../../../../../redux/store'; +import { AriaDescribed, AriaDescription, AriaDescriptionGroup } from '../../../../AriaGroup'; +import * as Cell from '../../../../cell'; +import List, { stringValueAsKey } from '../../../../List'; +import { ModalAlert, ModalAlertType } from '../../../../Modal'; +import { SettingsToggleListItem } from '../../../../settings-toggle-list-item'; import { AddServerContainer, StyledAddCustomDnsLabel, @@ -25,12 +21,10 @@ import { StyledItemContainer, StyledLabel, } from './CustomDnsSettingsStyles'; -import List, { stringValueAsKey } from './List'; -import { ModalAlert, ModalAlertType } from './Modal'; const manualLocal = window.env.platform === 'win32' || window.env.platform === 'linux'; -export default function CustomDnsSettings() { +export function CustomDnsSettings() { const { setDnsOptions } = useAppContext(); const dns = useSelector((state) => state.settings.dns); @@ -41,6 +35,8 @@ export default function CustomDnsSettings() { const [savingEdit, setSavingEdit] = useState(false); const willShowConfirmationDialog = useRef(false); + const descriptionId = React.useId(); + const featureAvailable = useMemo( () => dns.state === 'custom' || @@ -53,7 +49,7 @@ export default function CustomDnsSettings() { [dns], ); - const switchRef = useStyledRef<HTMLDivElement>(); + const switchRef = useStyledRef<HTMLButtonElement>(); const addButtonRef = useStyledRef<HTMLButtonElement>(); const inputContainerRef = useStyledRef<HTMLDivElement>(); @@ -192,72 +188,68 @@ export default function CustomDnsSettings() { return ( <> - <Cell.Container disabled={!featureAvailable}> - <AriaInputGroup> - <AriaLabel> - <Cell.InputLabel> - {messages.pgettext('vpn-settings-view', 'Use custom DNS server')} - </Cell.InputLabel> - </AriaLabel> - <AriaInput> - <Cell.Switch - innerRef={switchRef} - isOn={dns.state === 'custom' || inputVisible} - onChange={setCustomDnsEnabled} - /> - </AriaInput> - </AriaInputGroup> - </Cell.Container> - <Accordion expanded={listExpanded}> - <Cell.Section role="listbox"> - <List - items={dns.customOptions.addresses} - getKey={stringValueAsKey} - skipAddTransition={true} - skipRemoveTransition={savingEdit}> - {(item) => ( - <CellListItem - onRemove={onRemove} - onChange={onEdit} - willShowConfirmationDialog={willShowConfirmationDialog}> - {item} - </CellListItem> - )} - </List> - </Cell.Section> + <SettingsToggleListItem + anchorId="custom-dns-settings" + checked={dns.state === 'custom' || inputVisible} + onCheckedChange={setCustomDnsEnabled} + disabled={!featureAvailable}> + <SettingsToggleListItem.Label> + {messages.pgettext('vpn-settings-view', 'Use custom DNS server')} + </SettingsToggleListItem.Label> + <SettingsToggleListItem.Switch ref={switchRef} aria-describedby={descriptionId} /> + </SettingsToggleListItem> + <Accordion expanded={listExpanded} disabled={!featureAvailable}> + <Accordion.Content> + <Cell.Section role="listbox"> + <List + items={dns.customOptions.addresses} + getKey={stringValueAsKey} + skipAddTransition={true} + skipRemoveTransition={savingEdit}> + {(item) => ( + <CellListItem + onRemove={onRemove} + onChange={onEdit} + willShowConfirmationDialog={willShowConfirmationDialog}> + {item} + </CellListItem> + )} + </List> + </Cell.Section> - {inputVisible && ( - <div ref={inputContainerRef}> - <Cell.RowInput - placeholder={messages.pgettext('vpn-settings-view', 'Enter IP')} - onSubmit={onAdd} - onChange={setValid} - invalid={invalid} - paddingLeft={32} - onBlur={onInputBlur} - autofocus - /> - </div> - )} + {inputVisible && ( + <div ref={inputContainerRef}> + <Cell.RowInput + placeholder={messages.pgettext('vpn-settings-view', 'Enter IP')} + onSubmit={onAdd} + onChange={setValid} + invalid={invalid} + paddingLeft={32} + onBlur={onInputBlur} + autofocus + /> + </div> + )} - <AddServerContainer> - <StyledButton - ref={addButtonRef} - onClick={showInput} - disabled={inputVisible} - tabIndex={-1}> - <StyledAddCustomDnsLabel tabIndex={-1}> - {messages.pgettext('vpn-settings-view', 'Add a server')} - </StyledAddCustomDnsLabel> - </StyledButton> - <IconButton variant="secondary" onClick={showInput}> - <IconButton.Icon icon="add-circle" /> - </IconButton> - </AddServerContainer> + <AddServerContainer> + <StyledButton + ref={addButtonRef} + onClick={showInput} + disabled={inputVisible} + tabIndex={-1}> + <StyledAddCustomDnsLabel tabIndex={-1}> + {messages.pgettext('vpn-settings-view', 'Add a server')} + </StyledAddCustomDnsLabel> + </StyledButton> + <IconButton variant="secondary" onClick={showInput}> + <IconButton.Icon icon="add-circle" /> + </IconButton> + </AddServerContainer> + </Accordion.Content> </Accordion> <StyledCustomDnsFooter> - <Cell.CellFooterText> + <Cell.CellFooterText id={descriptionId}> {featureAvailable ? messages.pgettext('vpn-settings-view', 'Enable to add at least one DNS server.') : formatHtml( diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/CustomDnsSettingsStyles.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/custom-dns-settings/CustomDnsSettingsStyles.tsx index 0d6dac8558..42a8c2e443 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/components/CustomDnsSettingsStyles.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/custom-dns-settings/CustomDnsSettingsStyles.tsx @@ -1,7 +1,7 @@ import styled from 'styled-components'; -import { colors } from '../lib/foundations'; -import * as Cell from './cell'; +import { colors } from '../../../../../lib/foundations'; +import * as Cell from '../../../../cell'; export const StyledCustomDnsFooter = styled(Cell.CellFooter)({ marginBottom: '2px', diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/custom-dns-settings/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/custom-dns-settings/index.ts new file mode 100644 index 0000000000..7db9bb279e --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/custom-dns-settings/index.ts @@ -0,0 +1 @@ +export * from './CustomDnsSettings'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/DnsBlockerSetting.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/DnsBlockerSetting.tsx new file mode 100644 index 0000000000..52e0d6d28a --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/DnsBlockerSetting.tsx @@ -0,0 +1,78 @@ +import { sprintf } from 'sprintf-js'; + +import { messages } from '../../../../../../shared/gettext'; +import { FlexRow } from '../../../../../lib/components/flex-row'; +import { formatHtml } from '../../../../../lib/html-formatter'; +import { useSelector } from '../../../../../redux/store'; +import InfoButton from '../../../../InfoButton'; +import { ModalMessage } from '../../../../Modal'; +import { SettingsAccordion } from '../../../../settings-accordion'; +import { + BlockAdsSetting, + BlockAdultContentSetting, + BlockGamblingSetting, + BlockMalwareSetting, + BlockSocialMediaSetting, + BlockTrackersSetting, + CustomDnsEnabledFooter, +} from './components'; + +export function DnsBlockerSettings() { + const dns = useSelector((state) => state.settings.dns); + const customDnsFeatureName = messages.pgettext('vpn-settings-view', 'Use custom DNS server'); + + return ( + <> + <SettingsAccordion + accordionId="dns-blocker-setting" + anchorId="dns-blocker-setting" + aria-label={messages.pgettext('vpn-settings-view', 'DNS content blockers')} + disabled={dns.state === 'custom'}> + <SettingsAccordion.Header> + <SettingsAccordion.Title> + {messages.pgettext('vpn-settings-view', 'DNS content blockers')} + </SettingsAccordion.Title> + <FlexRow $gap="medium"> + <InfoButton> + <ModalMessage> + {messages.pgettext( + 'vpn-settings-view', + 'When this feature is enabled it stops the device from contacting certain domains or websites known for distributing ads, malware, trackers and more.', + )} + </ModalMessage> + <ModalMessage> + {messages.pgettext( + 'vpn-settings-view', + 'This might cause issues on certain websites, services, and apps.', + )} + </ModalMessage> + <ModalMessage> + {formatHtml( + sprintf( + messages.pgettext( + 'vpn-settings-view', + 'Attention: this setting cannot be used in combination with <b>%(customDnsFeatureName)s</b>', + ), + { customDnsFeatureName }, + ), + )} + </ModalMessage> + </InfoButton> + <SettingsAccordion.Trigger> + <SettingsAccordion.Icon /> + </SettingsAccordion.Trigger> + </FlexRow> + </SettingsAccordion.Header> + <SettingsAccordion.Content> + <BlockAdsSetting /> + <BlockTrackersSetting /> + <BlockMalwareSetting /> + <BlockGamblingSetting /> + <BlockAdultContentSetting /> + <BlockSocialMediaSetting /> + </SettingsAccordion.Content> + </SettingsAccordion> + {dns.state === 'custom' && <CustomDnsEnabledFooter />} + </> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-ads-setting/BlockAdsSetting.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-ads-setting/BlockAdsSetting.tsx new file mode 100644 index 0000000000..df06d6cb19 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-ads-setting/BlockAdsSetting.tsx @@ -0,0 +1,27 @@ +import { messages } from '../../../../../../../../shared/gettext'; +import { FlexRow } from '../../../../../../../lib/components/flex-row'; +import { SettingsToggleListItem } from '../../../../../../settings-toggle-list-item'; +import { useDns } from '../../hooks'; + +export function BlockAdsSetting() { + const [dns, setBlockAds] = useDns('blockAds'); + + return ( + <SettingsToggleListItem + level={1} + animation={undefined} + disabled={dns.state === 'custom'} + checked={dns.state === 'default' && dns.defaultOptions.blockAds} + onCheckedChange={setBlockAds}> + <FlexRow $padding={{ left: 'medium' }}> + <SettingsToggleListItem.Label variant="bodySmall"> + { + // TRANSLATORS: Label for settings that enables ad blocking. + messages.pgettext('vpn-settings-view', 'Ads') + } + </SettingsToggleListItem.Label> + </FlexRow> + <SettingsToggleListItem.Switch /> + </SettingsToggleListItem> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-ads-setting/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-ads-setting/index.ts new file mode 100644 index 0000000000..b0cc8e4565 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-ads-setting/index.ts @@ -0,0 +1 @@ +export * from './BlockAdsSetting'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-adult-content-setting/BlockAdultContentSetting.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-adult-content-setting/BlockAdultContentSetting.tsx new file mode 100644 index 0000000000..d7dd67c140 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-adult-content-setting/BlockAdultContentSetting.tsx @@ -0,0 +1,27 @@ +import { messages } from '../../../../../../../../shared/gettext'; +import { FlexRow } from '../../../../../../../lib/components/flex-row'; +import { SettingsToggleListItem } from '../../../../../../settings-toggle-list-item'; +import { useDns } from '../../hooks'; + +export function BlockAdultContentSetting() { + const [dns, setBlockAdultContent] = useDns('blockAdultContent'); + + return ( + <SettingsToggleListItem + level={1} + animation={false} + disabled={dns.state === 'custom'} + checked={dns.state === 'default' && dns.defaultOptions.blockAdultContent} + onCheckedChange={setBlockAdultContent}> + <FlexRow $padding={{ left: 'medium' }}> + <SettingsToggleListItem.Label variant="bodySmall"> + { + // TRANSLATORS: Label for settings that enables block of adult content. + messages.pgettext('vpn-settings-view', 'Adult content') + } + </SettingsToggleListItem.Label> + </FlexRow> + <SettingsToggleListItem.Switch /> + </SettingsToggleListItem> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-adult-content-setting/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-adult-content-setting/index.ts new file mode 100644 index 0000000000..dc5b4b3a9d --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-adult-content-setting/index.ts @@ -0,0 +1 @@ +export * from './BlockAdultContentSetting'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-gambling-setting/BlockGamblingSetting.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-gambling-setting/BlockGamblingSetting.tsx new file mode 100644 index 0000000000..b46acebc12 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-gambling-setting/BlockGamblingSetting.tsx @@ -0,0 +1,27 @@ +import { messages } from '../../../../../../../../shared/gettext'; +import { FlexRow } from '../../../../../../../lib/components/flex-row'; +import { SettingsToggleListItem } from '../../../../../../settings-toggle-list-item'; +import { useDns } from '../../hooks'; + +export function BlockGamblingSetting() { + const [dns, setBlockGambling] = useDns('blockGambling'); + + return ( + <SettingsToggleListItem + level={1} + animation={false} + disabled={dns.state === 'custom'} + checked={dns.state === 'default' && dns.defaultOptions.blockGambling} + onCheckedChange={setBlockGambling}> + <FlexRow $padding={{ left: 'medium' }}> + <SettingsToggleListItem.Label variant="bodySmall"> + { + // TRANSLATORS: Label for settings that enables block of gamling related websites. + messages.pgettext('vpn-settings-view', 'Gambling') + } + </SettingsToggleListItem.Label> + </FlexRow> + <SettingsToggleListItem.Switch /> + </SettingsToggleListItem> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-gambling-setting/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-gambling-setting/index.ts new file mode 100644 index 0000000000..52655e2172 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-gambling-setting/index.ts @@ -0,0 +1 @@ +export * from './BlockGamblingSetting'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-malware-setting/BlockMalwareSetting.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-malware-setting/BlockMalwareSetting.tsx new file mode 100644 index 0000000000..bf3b21f526 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-malware-setting/BlockMalwareSetting.tsx @@ -0,0 +1,39 @@ +import { messages } from '../../../../../../../../shared/gettext'; +import { FlexRow } from '../../../../../../../lib/components/flex-row'; +import InfoButton from '../../../../../../InfoButton'; +import { ModalMessage } from '../../../../../../Modal'; +import { SettingsToggleListItem } from '../../../../../../settings-toggle-list-item'; +import { useDns } from '../../hooks'; + +export function BlockMalwareSetting() { + const [dns, setBlockMalware] = useDns('blockMalware'); + + return ( + <SettingsToggleListItem + level={1} + animation={false} + disabled={dns.state === 'custom'} + checked={dns.state === 'default' && dns.defaultOptions.blockMalware} + onCheckedChange={setBlockMalware}> + <FlexRow $padding={{ left: 'medium' }}> + <SettingsToggleListItem.Label variant="bodySmall"> + { + // TRANSLATORS: Label for settings that enables malware blocking. + messages.pgettext('vpn-settings-view', 'Malware') + } + </SettingsToggleListItem.Label> + </FlexRow> + <SettingsToggleListItem.Group> + <InfoButton> + <ModalMessage> + {messages.pgettext( + 'vpn-settings-view', + 'Warning: The malware blocker is not an anti-virus and should not be treated as such, this is just an extra layer of protection.', + )} + </ModalMessage> + </InfoButton> + <SettingsToggleListItem.Switch /> + </SettingsToggleListItem.Group> + </SettingsToggleListItem> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-malware-setting/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-malware-setting/index.ts new file mode 100644 index 0000000000..bed7da077d --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-malware-setting/index.ts @@ -0,0 +1 @@ +export * from './BlockMalwareSetting'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-social-media-setting/BlockSocialMediaSetting.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-social-media-setting/BlockSocialMediaSetting.tsx new file mode 100644 index 0000000000..b49dcc4b17 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-social-media-setting/BlockSocialMediaSetting.tsx @@ -0,0 +1,27 @@ +import { messages } from '../../../../../../../../shared/gettext'; +import { FlexRow } from '../../../../../../../lib/components/flex-row'; +import { SettingsToggleListItem } from '../../../../../../settings-toggle-list-item'; +import { useDns } from '../../hooks'; + +export function BlockSocialMediaSetting() { + const [dns, setBlockSocialMedia] = useDns('blockSocialMedia'); + + return ( + <SettingsToggleListItem + level={1} + animation={false} + disabled={dns.state === 'custom'} + checked={dns.state === 'default' && dns.defaultOptions.blockSocialMedia} + onCheckedChange={setBlockSocialMedia}> + <FlexRow $padding={{ left: 'medium' }}> + <SettingsToggleListItem.Label variant="bodySmall"> + { + // TRANSLATORS: Label for settings that enables block of social media. + messages.pgettext('vpn-settings-view', 'Social media') + } + </SettingsToggleListItem.Label> + </FlexRow> + <SettingsToggleListItem.Switch /> + </SettingsToggleListItem> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-social-media-setting/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-social-media-setting/index.ts new file mode 100644 index 0000000000..d012367eed --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-social-media-setting/index.ts @@ -0,0 +1 @@ +export * from './BlockSocialMediaSetting'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-trackers-setting/BlockTrackersSetting.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-trackers-setting/BlockTrackersSetting.tsx new file mode 100644 index 0000000000..308d61a5da --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-trackers-setting/BlockTrackersSetting.tsx @@ -0,0 +1,27 @@ +import { messages } from '../../../../../../../../shared/gettext'; +import { FlexRow } from '../../../../../../../lib/components/flex-row'; +import { SettingsToggleListItem } from '../../../../../../settings-toggle-list-item'; +import { useDns } from '../../hooks'; + +export function BlockTrackersSetting() { + const [dns, setBlockTrackers] = useDns('blockTrackers'); + + return ( + <SettingsToggleListItem + level={1} + animation={false} + disabled={dns.state === 'custom'} + checked={dns.state === 'default' && dns.defaultOptions.blockTrackers} + onCheckedChange={setBlockTrackers}> + <FlexRow $padding={{ left: 'medium' }}> + <SettingsToggleListItem.Label variant="bodySmall"> + { + // TRANSLATORS: Label for settings that enables tracker blocking. + messages.pgettext('vpn-settings-view', 'Trackers') + } + </SettingsToggleListItem.Label> + </FlexRow> + <SettingsToggleListItem.Switch /> + </SettingsToggleListItem> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-trackers-setting/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-trackers-setting/index.ts new file mode 100644 index 0000000000..f459f0d0c3 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/block-trackers-setting/index.ts @@ -0,0 +1 @@ +export * from './BlockTrackersSetting'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/custom-dns-enabled-footer/CustomDnsEnabledFooter.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/custom-dns-enabled-footer/CustomDnsEnabledFooter.tsx new file mode 100644 index 0000000000..931471d231 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/custom-dns-enabled-footer/CustomDnsEnabledFooter.tsx @@ -0,0 +1,25 @@ +import { sprintf } from 'sprintf-js'; + +import { messages } from '../../../../../../../../shared/gettext'; +import { Flex, Text } from '../../../../../../../lib/components'; + +export function CustomDnsEnabledFooter() { + const customDnsFeatureName = messages.pgettext('vpn-settings-view', 'Use custom DNS server'); + + // TRANSLATORS: This is displayed when the custom DNS setting is turned on which makes the block + // TRANSLATORS: ads/trackers settings disabled. The text enclosed in "<b></b>" will appear bold. + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(customDnsFeatureName)s - The name displayed next to the custom DNS toggle. + const blockingDisabledText = messages.pgettext( + 'vpn-settings-view', + 'Disable "%(customDnsFeatureName)s" below to activate these settings.', + ); + + return ( + <Flex $padding={{ top: 'tiny', horizontal: 'medium' }}> + <Text variant="labelTiny" color="whiteAlpha60"> + {sprintf(blockingDisabledText, { customDnsFeatureName })} + </Text> + </Flex> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/custom-dns-enabled-footer/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/custom-dns-enabled-footer/index.ts new file mode 100644 index 0000000000..1192d2aa5a --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/custom-dns-enabled-footer/index.ts @@ -0,0 +1 @@ +export * from './CustomDnsEnabledFooter'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/index.ts new file mode 100644 index 0000000000..fd79e5c5cc --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/components/index.ts @@ -0,0 +1,7 @@ +export * from './block-ads-setting'; +export * from './block-adult-content-setting'; +export * from './block-gambling-setting'; +export * from './block-malware-setting'; +export * from './block-social-media-setting'; +export * from './block-trackers-setting'; +export * from './custom-dns-enabled-footer'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/hooks/index.ts new file mode 100644 index 0000000000..1e0f813a16 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/hooks/index.ts @@ -0,0 +1 @@ +export * from './useDns'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/hooks/useDns.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/hooks/useDns.ts new file mode 100644 index 0000000000..096427fc69 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/hooks/useDns.ts @@ -0,0 +1,24 @@ +import { useCallback } from 'react'; + +import { IDnsOptions } from '../../../../../../../shared/daemon-rpc-types'; +import { useAppContext } from '../../../../../../context'; +import { useSelector } from '../../../../../../redux/store'; + +export function useDns(setting: keyof IDnsOptions['defaultOptions']) { + const dns = useSelector((state) => state.settings.dns); + const { setDnsOptions } = useAppContext(); + + const updateBlockSetting = useCallback( + (enabled: boolean) => + setDnsOptions({ + ...dns, + defaultOptions: { + ...dns.defaultOptions, + [setting]: enabled, + }, + }), + [setting, dns, setDnsOptions], + ); + + return [dns, updateBlockSetting] as const; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/index.ts new file mode 100644 index 0000000000..a22df37b10 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/dns-blocker-settings/index.ts @@ -0,0 +1 @@ +export * from './DnsBlockerSetting'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/enable-ipv6-setting/EnableIpv6Setting.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/enable-ipv6-setting/EnableIpv6Setting.tsx new file mode 100644 index 0000000000..e821206afa --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/enable-ipv6-setting/EnableIpv6Setting.tsx @@ -0,0 +1,51 @@ +import { useCallback } from 'react'; + +import { messages } from '../../../../../../shared/gettext'; +import log from '../../../../../../shared/logging'; +import { useAppContext } from '../../../../../context'; +import { useSelector } from '../../../../../redux/store'; +import InfoButton from '../../../../InfoButton'; +import { ModalMessage } from '../../../../Modal'; +import { SettingsToggleListItem } from '../../../../settings-toggle-list-item'; + +export function EnableIpv6Setting() { + const enableIpv6 = useSelector((state) => state.settings.enableIpv6); + const { setEnableIpv6: setEnableIpv6Impl } = useAppContext(); + + const setEnableIpv6 = useCallback( + async (enableIpv6: boolean) => { + try { + await setEnableIpv6Impl(enableIpv6); + } catch (e) { + const error = e as Error; + log.error('Failed to update enable IPv6', error.message); + } + }, + [setEnableIpv6Impl], + ); + + return ( + <SettingsToggleListItem checked={enableIpv6} onCheckedChange={setEnableIpv6}> + <SettingsToggleListItem.Label> + {messages.pgettext('vpn-settings-view', 'Enable IPv6')} + </SettingsToggleListItem.Label> + <SettingsToggleListItem.Group> + <InfoButton> + <ModalMessage> + {messages.pgettext( + 'vpn-settings-view', + 'When this feature is enabled, IPv6 can be used alongside IPv4 in the VPN tunnel to communicate with internet services.', + )} + </ModalMessage> + <ModalMessage> + {messages.pgettext( + 'vpn-settings-view', + 'IPv4 is always enabled and the majority of websites and applications use this protocol. We do not recommend enabling IPv6 unless you know you need it.', + )} + </ModalMessage> + </InfoButton> + <SettingsToggleListItem.Switch /> + </SettingsToggleListItem.Group> + </SettingsToggleListItem> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/enable-ipv6-setting/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/enable-ipv6-setting/index.ts new file mode 100644 index 0000000000..4c05674aaf --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/enable-ipv6-setting/index.ts @@ -0,0 +1 @@ +export * from './EnableIpv6Setting'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/index.ts new file mode 100644 index 0000000000..a475e36484 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/index.ts @@ -0,0 +1,12 @@ +export * from './allow-lan-setting'; +export * from './custom-dns-settings'; +export * from './auto-connect-setting'; +export * from './auto-start-setting'; +export * from './dns-blocker-settings'; +export * from './enable-ipv6-setting'; +export * from './ip-override-settings'; +export * from './kill-switch-setting'; +export * from './lockdown-mode-setting'; +export * from './open-vpn-settings'; +export * from './tunnel-protocol-setting'; +export * from './wireguard-settings'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/ip-override-settings/IpOverrideSettings.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/ip-override-settings/IpOverrideSettings.tsx new file mode 100644 index 0000000000..7f8348fc3c --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/ip-override-settings/IpOverrideSettings.tsx @@ -0,0 +1,14 @@ +import { messages } from '../../../../../../shared/gettext'; +import { RoutePath } from '../../../../../../shared/routes'; +import { SettingsNavigationListItem } from '../../../../settings-navigation-list-item'; + +export function IpOverrideSettings() { + return ( + <SettingsNavigationListItem to={RoutePath.settingsImport}> + <SettingsNavigationListItem.Label> + {messages.pgettext('vpn-settings-view', 'Server IP override')} + </SettingsNavigationListItem.Label> + <SettingsNavigationListItem.Icon icon="chevron-right" /> + </SettingsNavigationListItem> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/ip-override-settings/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/ip-override-settings/index.ts new file mode 100644 index 0000000000..b7fcb1e1d9 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/ip-override-settings/index.ts @@ -0,0 +1 @@ +export * from './IpOverrideSettings'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/kill-switch-setting/KillSwitchSetting.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/kill-switch-setting/KillSwitchSetting.tsx new file mode 100644 index 0000000000..0f4f2ceed2 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/kill-switch-setting/KillSwitchSetting.tsx @@ -0,0 +1,38 @@ +import { messages } from '../../../../../../shared/gettext'; +import { Switch } from '../../../../../lib/components/switch'; +import InfoButton from '../../../../InfoButton'; +import { ModalMessage } from '../../../../Modal'; +import { SettingsListItem } from '../../../../settings-list-item'; + +export function KillSwitchSetting() { + return ( + <SettingsListItem> + <SettingsListItem.Item> + <SettingsListItem.Content> + <SettingsListItem.Label> + {messages.pgettext('vpn-settings-view', 'Kill switch')} + </SettingsListItem.Label> + <SettingsListItem.Group $gap="medium"> + <InfoButton> + <ModalMessage> + {messages.pgettext( + 'vpn-settings-view', + 'This built-in feature prevents your traffic from leaking outside of the VPN tunnel if your network suddenly stops working or if the tunnel fails, it does this by blocking your traffic until your connection is reestablished.', + )} + </ModalMessage> + <ModalMessage> + {messages.pgettext( + 'vpn-settings-view', + 'The difference between the Kill Switch and Lockdown Mode is that the Kill Switch will prevent any leaks from happening during automatic tunnel reconnects, software crashes and similar accidents. With Lockdown Mode enabled, you must be connected to a Mullvad VPN server to be able to reach the internet. Manually disconnecting or quitting the app will block your connection.', + )} + </ModalMessage> + </InfoButton> + <Switch checked disabled> + <Switch.Thumb /> + </Switch> + </SettingsListItem.Group> + </SettingsListItem.Content> + </SettingsListItem.Item> + </SettingsListItem> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/kill-switch-setting/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/kill-switch-setting/index.ts new file mode 100644 index 0000000000..014f91555f --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/kill-switch-setting/index.ts @@ -0,0 +1 @@ +export * from './KillSwitchSetting'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/lockdown-mode-setting/LockdownModeSetting.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/lockdown-mode-setting/LockdownModeSetting.tsx new file mode 100644 index 0000000000..30da61d848 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/lockdown-mode-setting/LockdownModeSetting.tsx @@ -0,0 +1,101 @@ +import { useCallback } from 'react'; + +import { messages } from '../../../../../../shared/gettext'; +import log from '../../../../../../shared/logging'; +import { useAppContext } from '../../../../../context'; +import { Button } from '../../../../../lib/components'; +import { useBoolean } from '../../../../../lib/utility-hooks'; +import { useSelector } from '../../../../../redux/store'; +import InfoButton from '../../../../InfoButton'; +import { ModalAlert, ModalAlertType, ModalMessage } from '../../../../Modal'; +import { SettingsToggleListItem } from '../../../../settings-toggle-list-item'; + +export function LockdownModeSetting() { + const blockWhenDisconnected = useSelector((state) => state.settings.blockWhenDisconnected); + const { setBlockWhenDisconnected: setBlockWhenDisconnectedImpl } = useAppContext(); + + const [confirmationDialogVisible, showConfirmationDialog, hideConfirmationDialog] = + useBoolean(false); + + const setBlockWhenDisconnected = useCallback( + async (blockWhenDisconnected: boolean) => { + try { + await setBlockWhenDisconnectedImpl(blockWhenDisconnected); + } catch (e) { + const error = e as Error; + log.error('Failed to update block when disconnected', error.message); + } + }, + [setBlockWhenDisconnectedImpl], + ); + + const setLockDownMode = useCallback( + async (newValue: boolean) => { + if (newValue) { + showConfirmationDialog(); + } else { + await setBlockWhenDisconnected(false); + } + }, + [setBlockWhenDisconnected, showConfirmationDialog], + ); + + const confirmLockdownMode = useCallback(async () => { + hideConfirmationDialog(); + await setBlockWhenDisconnected(true); + }, [hideConfirmationDialog, setBlockWhenDisconnected]); + + return ( + <SettingsToggleListItem + anchorId="lockdown-mode-setting" + checked={blockWhenDisconnected} + onCheckedChange={setLockDownMode}> + <SettingsToggleListItem.Label> + {messages.pgettext('vpn-settings-view', 'Lockdown mode')} + </SettingsToggleListItem.Label> + <SettingsToggleListItem.Group> + <InfoButton> + <ModalMessage> + {messages.pgettext( + 'vpn-settings-view', + 'The difference between the Kill Switch and Lockdown Mode is that the Kill Switch will prevent any leaks from happening during automatic tunnel reconnects, software crashes and similar accidents.', + )} + </ModalMessage> + <ModalMessage> + {messages.pgettext( + 'vpn-settings-view', + 'With Lockdown Mode enabled, you must be connected to a Mullvad VPN server to be able to reach the internet. Manually disconnecting or quitting the app will block your connection.', + )} + </ModalMessage> + </InfoButton> + + <ModalAlert + isOpen={confirmationDialogVisible} + type={ModalAlertType.caution} + buttons={[ + <Button variant="destructive" key="confirm" onClick={confirmLockdownMode}> + <Button.Text>{messages.gettext('Enable anyway')}</Button.Text> + </Button>, + <Button key="back" onClick={hideConfirmationDialog}> + <Button.Text>{messages.gettext('Back')}</Button.Text> + </Button>, + ]} + close={hideConfirmationDialog}> + <ModalMessage> + {messages.pgettext( + 'vpn-settings-view', + 'Attention: enabling this will always require a Mullvad VPN connection in order to reach the internet.', + )} + </ModalMessage> + <ModalMessage> + {messages.pgettext( + 'vpn-settings-view', + 'The app’s built-in kill switch is always on. This setting will additionally block the internet if clicking Disconnect or Quit.', + )} + </ModalMessage> + </ModalAlert> + <SettingsToggleListItem.Switch /> + </SettingsToggleListItem.Group> + </SettingsToggleListItem> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/lockdown-mode-setting/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/lockdown-mode-setting/index.ts new file mode 100644 index 0000000000..a9d149891b --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/lockdown-mode-setting/index.ts @@ -0,0 +1 @@ +export * from './LockdownModeSetting'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/open-vpn-settings/OpenVpnSettings.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/open-vpn-settings/OpenVpnSettings.tsx new file mode 100644 index 0000000000..5fdb51b9c6 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/open-vpn-settings/OpenVpnSettings.tsx @@ -0,0 +1,26 @@ +import { sprintf } from 'sprintf-js'; + +import { strings } from '../../../../../../shared/constants'; +import { messages } from '../../../../../../shared/gettext'; +import { RoutePath } from '../../../../../../shared/routes'; +import { useTunnelProtocol } from '../../../../../lib/relay-settings-hooks'; +import { SettingsNavigationListItem } from '../../../../settings-navigation-list-item'; + +export function OpenVpnSettings() { + const tunnelProtocol = useTunnelProtocol(); + + return ( + <SettingsNavigationListItem + to={RoutePath.openVpnSettings} + disabled={tunnelProtocol === 'wireguard'}> + <SettingsNavigationListItem.Label> + {sprintf( + // TRANSLATORS: %(openvpn)s will be replaced with the string "OpenVPN" + messages.pgettext('vpn-settings-view', '%(openvpn)s settings'), + { openvpn: strings.openvpn }, + )} + </SettingsNavigationListItem.Label> + <SettingsNavigationListItem.Icon icon="chevron-right" /> + </SettingsNavigationListItem> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/open-vpn-settings/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/open-vpn-settings/index.ts new file mode 100644 index 0000000000..534d8c9e68 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/open-vpn-settings/index.ts @@ -0,0 +1 @@ +export * from './OpenVpnSettings'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/tunnel-protocol-setting/TunnelProtocol.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/tunnel-protocol-setting/TunnelProtocol.tsx new file mode 100644 index 0000000000..80ca4991c8 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/tunnel-protocol-setting/TunnelProtocol.tsx @@ -0,0 +1,116 @@ +import { useCallback } from 'react'; +import { sprintf } from 'sprintf-js'; + +import { strings, urls } from '../../../../../../shared/constants'; +import { TunnelProtocol } from '../../../../../../shared/daemon-rpc-types'; +import { messages } from '../../../../../../shared/gettext'; +import log from '../../../../../../shared/logging'; +import { useRelaySettingsUpdater } from '../../../../../lib/constraint-updater'; +import { useTunnelProtocol } from '../../../../../lib/relay-settings-hooks'; +import { useSelector } from '../../../../../redux/store'; +import { ExternalLink } from '../../../../ExternalLink'; +import { SettingsListbox } from '../../../../settings-listbox'; + +export function TunnelProtocolSetting() { + const tunnelProtocol = useTunnelProtocol(); + + const relaySettingsUpdater = useRelaySettingsUpdater(); + + const relaySettings = useSelector((state) => state.settings.relaySettings); + const multihop = 'normal' in relaySettings ? relaySettings.normal.wireguard.useMultihop : false; + const daita = useSelector((state) => state.settings.wireguard.daita?.enabled ?? false); + const quantumResistant = useSelector((state) => state.settings.wireguard.quantumResistant); + const openVpnDisabled = daita || multihop || quantumResistant; + + const featuresToDisableForOpenVpn = []; + if (daita) { + featuresToDisableForOpenVpn.push(strings.daita); + } + if (multihop) { + featuresToDisableForOpenVpn.push(messages.pgettext('wireguard-settings-view', 'Multihop')); + } + if (quantumResistant) { + featuresToDisableForOpenVpn.push( + messages.pgettext('wireguard-settings-view', 'Quantum-resistant tunnel'), + ); + } + + const setTunnelProtocol = useCallback( + async (tunnelProtocol: TunnelProtocol) => { + try { + await relaySettingsUpdater((settings) => ({ + ...settings, + tunnelProtocol, + })); + } catch (e) { + const error = e as Error; + log.error('Failed to update tunnel protocol constraints', error.message); + } + }, + [relaySettingsUpdater], + ); + + const openVpnDisabledFooter = sprintf( + messages.pgettext( + 'vpn-settings-view', + 'To select %(openvpn)s, please disable these settings: %(featureList)s.', + ), + { openvpn: strings.openvpn, featureList: featuresToDisableForOpenVpn.join(', ') }, + ); + + return ( + <SettingsListbox + onValueChange={setTunnelProtocol} + value={tunnelProtocol} + aria-description={openVpnDisabled ? openVpnDisabledFooter : undefined}> + <SettingsListbox.Item> + <SettingsListbox.Content> + <SettingsListbox.Label> + {messages.pgettext('vpn-settings-view', 'Tunnel protocol')} + </SettingsListbox.Label> + </SettingsListbox.Content> + </SettingsListbox.Item> + <SettingsListbox.Options> + <SettingsListbox.BaseOption value={'wireguard'}> + {strings.wireguard} + </SettingsListbox.BaseOption> + <SettingsListbox.BaseOption value={'openvpn'} disabled={openVpnDisabled}> + {strings.openvpn} + </SettingsListbox.BaseOption> + </SettingsListbox.Options> + {openVpnDisabled && ( + <SettingsListbox.Footer> + <SettingsListbox.Text>{openVpnDisabledFooter}</SettingsListbox.Text> + </SettingsListbox.Footer> + )} + {tunnelProtocol === 'openvpn' && ( + <SettingsListbox.Footer> + <div> + <SettingsListbox.Text> + {sprintf( + // TRANSLATORS: Footer text for tunnel protocol selector when OpenVPN is selected. + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(openvpn)s - Will be replaced with OpenVPN + messages.pgettext( + 'vpn-settings-view', + 'Attention: We are removing support for %(openVpn)s.', + ), + { openVpn: strings.openvpn }, + )}{' '} + </SettingsListbox.Text> + <ExternalLink variant="labelTiny" to={urls.removingOpenVpnBlog}> + <ExternalLink.Text> + {sprintf( + // TRANSLATORS: Link in tunnel protocol selector footer to blog post + // TRANSLATORS: about OpenVPN support ending. + messages.pgettext('vpn-settings-view', 'Read more'), + )} + </ExternalLink.Text> + <ExternalLink.Icon icon="external" size="small" /> + </ExternalLink> + </div> + </SettingsListbox.Footer> + )} + </SettingsListbox> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/tunnel-protocol-setting/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/tunnel-protocol-setting/index.ts new file mode 100644 index 0000000000..baa44a84e6 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/tunnel-protocol-setting/index.ts @@ -0,0 +1 @@ +export * from './TunnelProtocol'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/wireguard-settings/WireguardSettings.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/wireguard-settings/WireguardSettings.tsx new file mode 100644 index 0000000000..84ad2816e1 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/wireguard-settings/WireguardSettings.tsx @@ -0,0 +1,42 @@ +import { sprintf } from 'sprintf-js'; + +import { strings } from '../../../../../../shared/constants'; +import { messages } from '../../../../../../shared/gettext'; +import { RoutePath } from '../../../../../../shared/routes'; +import { RelaySettingsRedux } from '../../../../../redux/settings/reducers'; +import { useSelector } from '../../../../../redux/store'; +import { SettingsNavigationListItem } from '../../../../settings-navigation-list-item'; + +function mapRelaySettingsToProtocol(relaySettings: RelaySettingsRedux) { + if ('normal' in relaySettings) { + const { tunnelProtocol } = relaySettings.normal; + return tunnelProtocol; + // since the GUI doesn't display custom settings, just display the default ones. + // If the user sets any settings, then those will be applied. + } else if ('customTunnelEndpoint' in relaySettings) { + return undefined; + } else { + throw new Error('Unknown type of relay settings.'); + } +} + +export function WireguardSettings() { + const tunnelProtocol = useSelector((state) => + mapRelaySettingsToProtocol(state.settings.relaySettings), + ); + + return ( + <SettingsNavigationListItem + to={RoutePath.wireguardSettings} + disabled={tunnelProtocol === 'openvpn'}> + <SettingsNavigationListItem.Label> + {sprintf( + // TRANSLATORS: %(wireguard)s will be replaced with the string "WireGuard" + messages.pgettext('vpn-settings-view', '%(wireguard)s settings'), + { wireguard: strings.wireguard }, + )} + </SettingsNavigationListItem.Label> + <SettingsNavigationListItem.Icon icon="chevron-right" /> + </SettingsNavigationListItem> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/wireguard-settings/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/wireguard-settings/index.ts new file mode 100644 index 0000000000..8b6d14ab5a --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/components/wireguard-settings/index.ts @@ -0,0 +1 @@ +export * from './WireguardSettings'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/index.ts new file mode 100644 index 0000000000..8b4ff16e3c --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/vpn-settings/index.ts @@ -0,0 +1 @@ +export * from './VpnSettingsView'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/WireguardSettingsView.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/WireguardSettingsView.tsx new file mode 100644 index 0000000000..b4a9a818d6 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/WireguardSettingsView.tsx @@ -0,0 +1,84 @@ +import { sprintf } from 'sprintf-js'; + +import { strings } from '../../../../shared/constants'; +import { messages } from '../../../../shared/gettext'; +import { useHistory } from '../../../lib/history'; +import { AppNavigationHeader } from '../..'; +import { BackAction } from '../../KeyboardNavigation'; +import { + Layout, + SettingsContainer, + SettingsContent, + SettingsGroup, + SettingsStack, +} from '../../Layout'; +import { NavigationContainer } from '../../NavigationContainer'; +import { NavigationScrollbars } from '../../NavigationScrollbars'; +import SettingsHeader, { HeaderTitle } from '../../SettingsHeader'; +import { + IpVersionSetting, + MtuSetting, + ObfuscationSettings, + PortSetting, + QuantumResistantSetting, +} from './components'; + +export function WireguardSettingsView() { + const { pop } = useHistory(); + + return ( + <BackAction action={pop}> + <Layout> + <SettingsContainer> + <NavigationContainer> + <AppNavigationHeader + title={sprintf( + // TRANSLATORS: Title label in navigation bar + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(wireguard)s - Will be replaced with the string "WireGuard" + messages.pgettext('wireguard-settings-nav', '%(wireguard)s settings'), + { wireguard: strings.wireguard }, + )} + /> + + <NavigationScrollbars> + <SettingsHeader> + <HeaderTitle> + {sprintf( + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(wireguard)s - Will be replaced with the string "WireGuard" + messages.pgettext('wireguard-settings-view', '%(wireguard)s settings'), + { wireguard: strings.wireguard }, + )} + </HeaderTitle> + </SettingsHeader> + <SettingsContent> + <SettingsStack> + <SettingsGroup> + <PortSetting /> + </SettingsGroup> + + <SettingsGroup> + <ObfuscationSettings /> + </SettingsGroup> + + <SettingsGroup> + <QuantumResistantSetting /> + </SettingsGroup> + + <SettingsGroup> + <IpVersionSetting /> + </SettingsGroup> + + <SettingsGroup> + <MtuSetting /> + </SettingsGroup> + </SettingsStack> + </SettingsContent> + </NavigationScrollbars> + </NavigationContainer> + </SettingsContainer> + </Layout> + </BackAction> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/index.ts new file mode 100644 index 0000000000..42aaeef491 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/index.ts @@ -0,0 +1,5 @@ +export * from './ip-version-setting'; +export * from './mtu-setting'; +export * from './obfuscation-settings'; +export * from './port-setting'; +export * from './quantum-resistant-setting'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/ip-version-setting/IpVersionSetting.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/ip-version-setting/IpVersionSetting.tsx new file mode 100644 index 0000000000..9c4f6b157b --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/ip-version-setting/IpVersionSetting.tsx @@ -0,0 +1,74 @@ +import { useCallback, useMemo } from 'react'; +import { sprintf } from 'sprintf-js'; + +import { strings } from '../../../../../../shared/constants'; +import { IpVersion, wrapConstraint } from '../../../../../../shared/daemon-rpc-types'; +import { messages } from '../../../../../../shared/gettext'; +import log from '../../../../../../shared/logging'; +import { useRelaySettingsUpdater } from '../../../../../lib/constraint-updater'; +import { useSelector } from '../../../../../redux/store'; +import { SettingsListbox } from '../../../../settings-listbox'; + +export function IpVersionSetting() { + const relaySettingsUpdater = useRelaySettingsUpdater(); + const relaySettings = useSelector((state) => state.settings.relaySettings); + const ipVersion = useMemo(() => { + const ipVersion = 'normal' in relaySettings ? relaySettings.normal.wireguard.ipVersion : 'any'; + return ipVersion === 'any' ? null : ipVersion; + }, [relaySettings]); + + const setIpVersion = useCallback( + async (ipVersion: IpVersion | null) => { + try { + await relaySettingsUpdater((settings) => { + settings.wireguardConstraints.ipVersion = wrapConstraint(ipVersion); + return settings; + }); + } catch (e) { + const error = e as Error; + log.error('Failed to update relay settings', error.message); + } + }, + [relaySettingsUpdater], + ); + + return ( + <SettingsListbox value={ipVersion} onValueChange={setIpVersion}> + <SettingsListbox.Item> + <SettingsListbox.Content> + <SettingsListbox.Label> + { + // TRANSLATORS: The title for the WireGuard IP version selector. + messages.pgettext('wireguard-settings-view', 'IP version') + } + </SettingsListbox.Label> + </SettingsListbox.Content> + </SettingsListbox.Item> + <SettingsListbox.Options> + <SettingsListbox.BaseOption value={null}> + {messages.gettext('Automatic')} + </SettingsListbox.BaseOption> + <SettingsListbox.BaseOption value={'ipv4'}> + {messages.gettext('IPv4')} + </SettingsListbox.BaseOption> + <SettingsListbox.BaseOption value={'ipv6'}> + {messages.gettext('IPv6')} + </SettingsListbox.BaseOption> + </SettingsListbox.Options> + <SettingsListbox.Footer> + <SettingsListbox.Text> + {sprintf( + // TRANSLATORS: The hint displayed below the WireGuard IP version selector. + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(wireguard)s - Will be replaced with the string "WireGuard" + messages.pgettext( + 'wireguard-settings-view', + 'This allows access to %(wireguard)s for devices that only support IPv6.', + ), + { wireguard: strings.wireguard }, + )} + </SettingsListbox.Text> + </SettingsListbox.Footer> + </SettingsListbox> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/ip-version-setting/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/ip-version-setting/index.ts new file mode 100644 index 0000000000..947b3403ce --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/ip-version-setting/index.ts @@ -0,0 +1 @@ +export * from './IpVersionSetting'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/mtu-setting/MtuSetting.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/mtu-setting/MtuSetting.tsx new file mode 100644 index 0000000000..fc4d8f6a27 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/mtu-setting/MtuSetting.tsx @@ -0,0 +1,130 @@ +import React, { useCallback } from 'react'; +import { sprintf } from 'sprintf-js'; + +import { strings } from '../../../../../../shared/constants'; +import { messages } from '../../../../../../shared/gettext'; +import log from '../../../../../../shared/logging'; +import { removeNonNumericCharacters } from '../../../../../../shared/string-helpers'; +import { useAppContext } from '../../../../../context'; +import { useTextField } from '../../../../../lib/components/text-field'; +import { useSelector } from '../../../../../redux/store'; +import { SettingsListItem } from '../../../../settings-list-item'; + +const MIN_WIREGUARD_MTU_VALUE = 1280; +const MAX_WIREGUARD_MTU_VALUE = 1420; + +function mtuIsValid(mtu: string): boolean { + const parsedMtu = mtu ? parseInt(mtu) : undefined; + return ( + parsedMtu === undefined || + (parsedMtu >= MIN_WIREGUARD_MTU_VALUE && parsedMtu <= MAX_WIREGUARD_MTU_VALUE) + ); +} + +export function MtuSetting() { + const { setWireguardMtu: setWireguardMtuImpl } = useAppContext(); + const mtu = useSelector((state) => state.settings.wireguard.mtu); + + const inputRef = React.useRef<HTMLInputElement>(null); + const labelId = React.useId(); + const descriptionId = React.useId(); + + const setMtu = useCallback( + async (mtu?: number) => { + try { + await setWireguardMtuImpl(mtu); + } catch (e) { + const error = e as Error; + log.error('Failed to update mtu value', error.message); + } + }, + [setWireguardMtuImpl], + ); + + const onSubmit = useCallback( + async (value: string) => { + const parsedValue = value === '' ? undefined : parseInt(value, 10); + if (mtuIsValid(value)) { + await setMtu(parsedValue); + } + }, + [setMtu], + ); + + const { value, handleChange, invalid, dirty, blur, reset } = useTextField({ + inputRef, + defaultValue: mtu ? mtu.toString() : '', + format: removeNonNumericCharacters, + validate: mtuIsValid, + }); + + const handleBlur = React.useCallback(async () => { + if (!invalid && dirty) { + await onSubmit(value); + } + if (invalid) { + reset(); + } + }, [dirty, invalid, onSubmit, reset, value]); + + const handleSubmit = React.useCallback( + async (event: React.FormEvent) => { + event.preventDefault(); + if (!invalid) { + await onSubmit(value); + blur(); + } + }, + [blur, invalid, onSubmit, value], + ); + + return ( + <SettingsListItem anchorId="mtu-setting" aria-labelledby={labelId}> + <SettingsListItem.Item> + <SettingsListItem.Content> + <SettingsListItem.Label id={labelId}> + { + // TRANSLATORS: The title for the WireGuard MTU setting. MTU stands for Maximum + // TRANSLATORS: Transmission Unit and controls the maximum size of packets sent over + // TRANSLATORS: the VPN tunnel. + messages.pgettext('wireguard-settings-view', 'MTU') + } + </SettingsListItem.Label> + <SettingsListItem.TextField invalid={invalid} onSubmit={handleSubmit}> + <SettingsListItem.TextField.Input + ref={inputRef} + value={value} + placeholder={messages.gettext('Default')} + inputMode="numeric" + maxLength={4} + aria-labelledby={labelId} + aria-describedby={descriptionId} + onBlur={handleBlur} + onChange={handleChange} + /> + </SettingsListItem.TextField> + </SettingsListItem.Content> + </SettingsListItem.Item> + <SettingsListItem.Footer> + <SettingsListItem.Text id={descriptionId}> + {sprintf( + // TRANSLATORS: The hint displayed below the WireGuard MTU input field. + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(wireguard)s - Will be replaced with the string "WireGuard" + // TRANSLATORS: %(max)d - the maximum possible wireguard mtu value + // TRANSLATORS: %(min)d - the minimum possible wireguard mtu value + messages.pgettext( + 'wireguard-settings-view', + 'Set %(wireguard)s MTU value. Valid range: %(min)d - %(max)d.', + ), + { + wireguard: strings.wireguard, + min: MIN_WIREGUARD_MTU_VALUE, + max: MAX_WIREGUARD_MTU_VALUE, + }, + )} + </SettingsListItem.Text> + </SettingsListItem.Footer> + </SettingsListItem> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/mtu-setting/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/mtu-setting/index.ts new file mode 100644 index 0000000000..8925e802c8 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/mtu-setting/index.ts @@ -0,0 +1 @@ +export * from './MtuSetting'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/obfuscation-settings/ObfuscationSettings.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/obfuscation-settings/ObfuscationSettings.tsx new file mode 100644 index 0000000000..5589764c56 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/obfuscation-settings/ObfuscationSettings.tsx @@ -0,0 +1,116 @@ +import { useCallback } from 'react'; +import { sprintf } from 'sprintf-js'; + +import { Constraint, ObfuscationType } from '../../../../../../shared/daemon-rpc-types'; +import { messages } from '../../../../../../shared/gettext'; +import { RoutePath } from '../../../../../../shared/routes'; +import { useAppContext } from '../../../../../context'; +import { Text } from '../../../../../lib/components'; +import { FlexColumn } from '../../../../../lib/components/flex-column'; +import { useSelector } from '../../../../../redux/store'; +import InfoButton from '../../../../InfoButton'; +import { ModalMessage } from '../../../../Modal'; +import { SettingsListbox } from '../../../../settings-listbox'; + +export function formatPortForSubLabel(port: Constraint<number>): string { + return port === 'any' ? messages.gettext('Automatic') : `${port.only}`; +} + +export function ObfuscationSettings() { + const { setObfuscationSettings } = useAppContext(); + const obfuscationSettings = useSelector((state) => state.settings.obfuscationSettings); + + // TRANSLATORS: Text showing currently selected port. + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(port)s - Can be either a number between 1 and 65535 or the text "Automatic". + const subLabelTemplate = messages.pgettext('wireguard-settings-view', 'Port: %(port)s'); + + const obfuscationType = obfuscationSettings.selectedObfuscation; + + const selectObfuscationType = useCallback( + async (value: ObfuscationType) => { + await setObfuscationSettings({ + ...obfuscationSettings, + selectedObfuscation: value, + }); + }, + [setObfuscationSettings, obfuscationSettings], + ); + + return ( + <SettingsListbox + anchorId="obfuscation-setting" + onValueChange={selectObfuscationType} + value={obfuscationType}> + <SettingsListbox.Item> + <SettingsListbox.Content> + <SettingsListbox.Label> + { + // TRANSLATORS: The title for the WireGuard obfuscation selector. + messages.pgettext('wireguard-settings-view', 'Obfuscation') + } + </SettingsListbox.Label> + <InfoButton> + <ModalMessage> + { + // TRANSLATORS: Describes what WireGuard obfuscation does, how it works and when + // TRANSLATORS: it would be useful to enable it. + messages.pgettext( + 'wireguard-settings-view', + 'Obfuscation hides the WireGuard traffic inside another protocol. It can be used to help circumvent censorship and other types of filtering, where a plain WireGuard connection would be blocked.', + ) + } + </ModalMessage> + </InfoButton> + </SettingsListbox.Content> + </SettingsListbox.Item> + <SettingsListbox.Options> + <SettingsListbox.BaseOption value={ObfuscationType.auto}> + {messages.gettext('Automatic')} + </SettingsListbox.BaseOption> + <SettingsListbox.SplitOption value={ObfuscationType.shadowsocks}> + <SettingsListbox.SplitOption.Item> + <FlexColumn> + <SettingsListbox.SplitOption.Label> + {messages.pgettext('wireguard-settings-view', 'Shadowsocks')} + </SettingsListbox.SplitOption.Label> + <Text variant="labelTiny" color="whiteAlpha60"> + {sprintf(subLabelTemplate, { + port: formatPortForSubLabel(obfuscationSettings.shadowsocksSettings.port), + })} + </Text> + </FlexColumn> + </SettingsListbox.SplitOption.Item> + <SettingsListbox.SplitOption.NavigateButton + to={RoutePath.shadowsocks} + aria-description={messages.pgettext('accessibility', 'Shadowsocks settings')} + /> + </SettingsListbox.SplitOption> + <SettingsListbox.SplitOption value={ObfuscationType.udp2tcp}> + <SettingsListbox.SplitOption.Item> + <FlexColumn> + <SettingsListbox.SplitOption.Label> + {messages.pgettext('wireguard-settings-view', 'UDP-over-TCP')} + </SettingsListbox.SplitOption.Label> + <Text variant="labelTiny" color="whiteAlpha60"> + {sprintf(subLabelTemplate, { + port: formatPortForSubLabel(obfuscationSettings.udp2tcpSettings.port), + })} + </Text> + </FlexColumn> + </SettingsListbox.SplitOption.Item> + <SettingsListbox.SplitOption.NavigateButton + to={RoutePath.udpOverTcp} + aria-description={messages.pgettext('accessibility', 'UDP-over-TCP settings')} + /> + </SettingsListbox.SplitOption> + <SettingsListbox.BaseOption value={ObfuscationType.quic}> + {messages.pgettext('wireguard-settings-view', 'QUIC')} + </SettingsListbox.BaseOption> + <SettingsListbox.BaseOption value={ObfuscationType.off}> + {messages.gettext('Off')} + </SettingsListbox.BaseOption> + </SettingsListbox.Options> + </SettingsListbox> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/obfuscation-settings/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/obfuscation-settings/index.ts new file mode 100644 index 0000000000..16dbafd65d --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/obfuscation-settings/index.ts @@ -0,0 +1 @@ +export * from './ObfuscationSettings'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/port-setting/PortSetting.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/port-setting/PortSetting.tsx new file mode 100644 index 0000000000..f808b0175b --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/port-setting/PortSetting.tsx @@ -0,0 +1,147 @@ +import { useCallback, useMemo } from 'react'; +import { sprintf } from 'sprintf-js'; + +import { wrapConstraint } from '../../../../../../shared/daemon-rpc-types'; +import { messages } from '../../../../../../shared/gettext'; +import log from '../../../../../../shared/logging'; +import { removeNonNumericCharacters } from '../../../../../../shared/string-helpers'; +import { isInRanges } from '../../../../../../shared/utils'; +import { useRelaySettingsUpdater } from '../../../../../lib/constraint-updater'; +import { useSelector } from '../../../../../redux/store'; +import { SelectorItem } from '../../../../cell/Selector'; +import InfoButton from '../../../../InfoButton'; +import { ModalMessage } from '../../../../Modal'; +import { SettingsListbox } from '../../../../settings-listbox'; + +const WIREUGARD_UDP_PORTS = [51820, 53]; + +function mapPortToSelectorItem(value: number): SelectorItem<number> { + return { label: value.toString(), value }; +} +export function PortSetting() { + const relaySettings = useSelector((state) => state.settings.relaySettings); + const relaySettingsUpdater = useRelaySettingsUpdater(); + const allowedPortRanges = useSelector((state) => state.settings.wireguardEndpointData.portRanges); + + const wireguardPortItems = useMemo<Array<SelectorItem<number>>>( + () => WIREUGARD_UDP_PORTS.map(mapPortToSelectorItem), + [], + ); + + const selectedOption = useMemo(() => { + const port = 'normal' in relaySettings ? relaySettings.normal.wireguard.port : 'any'; + if (port === 'any') + return { + port: 'any', + value: null, + }; + if (port && !WIREUGARD_UDP_PORTS.includes(port)) + return { + port, + value: 'custom', + }; + return { + port, + value: port, + }; + }, [relaySettings]); + + const setWireguardPort = useCallback( + async (port: number | string | null) => { + try { + await relaySettingsUpdater((settings) => { + settings.wireguardConstraints.port = wrapConstraint( + typeof port === 'string' ? parseInt(port) : port, + ); + return settings; + }); + } catch (e) { + const error = e as Error; + log.error('Failed to update relay settings', error.message); + } + }, + [relaySettingsUpdater], + ); + + const validateValue = useCallback( + (value: number) => isInRanges(value, allowedPortRanges), + [allowedPortRanges], + ); + + const validateStringValue = useCallback( + (value: string) => { + const numericValue = parseInt(value, 10); + if (Number.isNaN(numericValue)) return false; + return validateValue(numericValue); + }, + [validateValue], + ); + + const portRangesText = allowedPortRanges + .map(([start, end]) => (start === end ? start : `${start}-${end}`)) + .join(', '); + + return ( + <SettingsListbox + anchorId="port-setting" + value={selectedOption.value} + onValueChange={setWireguardPort}> + <SettingsListbox.Item> + <SettingsListbox.Content> + <SettingsListbox.Label> + { + // TRANSLATORS: The title for the WireGuard port selector. + messages.pgettext('wireguard-settings-view', 'Port') + } + </SettingsListbox.Label> + <InfoButton> + <> + <ModalMessage> + {messages.pgettext( + 'wireguard-settings-view', + 'The automatic setting will randomly choose from the valid port ranges shown below.', + )} + </ModalMessage> + <ModalMessage> + {sprintf( + messages.pgettext( + 'wireguard-settings-view', + 'The custom port can be any value inside the valid ranges: %(portRanges)s.', + ), + { portRanges: portRangesText }, + )} + </ModalMessage> + </> + </InfoButton> + </SettingsListbox.Content> + </SettingsListbox.Item> + <SettingsListbox.Options> + <SettingsListbox.BaseOption value={null}> + {messages.gettext('Automatic')} + </SettingsListbox.BaseOption> + {wireguardPortItems.map((item) => ( + <SettingsListbox.BaseOption key={item.value} value={item.value}> + {item.label} + </SettingsListbox.BaseOption> + ))} + <SettingsListbox.InputOption + defaultValue={ + selectedOption.value === 'custom' ? selectedOption.port?.toString() : undefined + } + value="custom" + validate={validateStringValue} + format={removeNonNumericCharacters}> + <SettingsListbox.InputOption.Label> + {messages.gettext('Custom')} + </SettingsListbox.InputOption.Label> + <SettingsListbox.InputOption.Input + placeholder={messages.pgettext('wireguard-settings-view', 'Port')} + maxLength={5} + type="text" + inputMode="numeric" + /> + </SettingsListbox.InputOption> + </SettingsListbox.Options> + </SettingsListbox> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/port-setting/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/port-setting/index.ts new file mode 100644 index 0000000000..38f37009dc --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/port-setting/index.ts @@ -0,0 +1 @@ +export * from './PortSetting'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/quantum-resistant-setting/QuantumResistantSetting.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/quantum-resistant-setting/QuantumResistantSetting.tsx new file mode 100644 index 0000000000..d177685557 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/quantum-resistant-setting/QuantumResistantSetting.tsx @@ -0,0 +1,67 @@ +import { useCallback } from 'react'; + +import { messages } from '../../../../../../shared/gettext'; +import { useAppContext } from '../../../../../context'; +import { useSelector } from '../../../../../redux/store'; +import InfoButton from '../../../../InfoButton'; +import { ModalMessage } from '../../../../Modal'; +import { SettingsListbox } from '../../../../settings-listbox'; + +export function QuantumResistantSetting() { + const { setWireguardQuantumResistant } = useAppContext(); + const quantumResistant = useSelector((state) => state.settings.wireguard.quantumResistant); + + const selectQuantumResistant = useCallback( + async (quantumResistant: boolean | null) => { + await setWireguardQuantumResistant(quantumResistant ?? undefined); + }, + [setWireguardQuantumResistant], + ); + + return ( + <SettingsListbox + anchorId="quantum-resistant-setting" + value={quantumResistant ?? null} + onValueChange={selectQuantumResistant}> + <SettingsListbox.Item> + <SettingsListbox.Content> + <SettingsListbox.Label> + { + // TRANSLATORS: The title for the WireGuard quantum resistance selector. This setting + // TRANSLATORS: makes the cryptography resistant to the future abilities of quantum + // TRANSLATORS: computers. + messages.pgettext('wireguard-settings-view', 'Quantum-resistant tunnel') + } + </SettingsListbox.Label> + <InfoButton> + <> + <ModalMessage> + {messages.pgettext( + 'wireguard-settings-view', + 'This feature makes the WireGuard tunnel resistant to potential attacks from quantum computers.', + )} + </ModalMessage> + <ModalMessage> + {messages.pgettext( + 'wireguard-settings-view', + 'It does this by performing an extra key exchange using a quantum safe algorithm and mixing the result into WireGuard’s regular encryption. This extra step uses approximately 500 kiB of traffic every time a new tunnel is established.', + )} + </ModalMessage> + </> + </InfoButton> + </SettingsListbox.Content> + </SettingsListbox.Item> + <SettingsListbox.Options> + <SettingsListbox.BaseOption value={null}> + {messages.gettext('Automatic')} + </SettingsListbox.BaseOption> + <SettingsListbox.BaseOption value={true}> + {messages.gettext('On')} + </SettingsListbox.BaseOption> + <SettingsListbox.BaseOption value={false}> + {messages.gettext('Off')} + </SettingsListbox.BaseOption> + </SettingsListbox.Options> + </SettingsListbox> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/quantum-resistant-setting/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/quantum-resistant-setting/index.ts new file mode 100644 index 0000000000..9f4ee97b0b --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/components/quantum-resistant-setting/index.ts @@ -0,0 +1 @@ +export * from './QuantumResistantSetting'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/index.ts new file mode 100644 index 0000000000..6f59813edc --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/wireguard-settings/index.ts @@ -0,0 +1 @@ +export * from './WireguardSettingsView'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/hooks/index.ts index 51e9b187a6..2a4f1dfdaf 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/hooks/index.ts +++ b/desktop/packages/mullvad-vpn/src/renderer/hooks/index.ts @@ -4,5 +4,10 @@ export * from './useHasAppUpgradeError'; export * from './useHasAppUpgradeEvent'; export * from './useHasAppUpgradeInitiated'; export * from './useHasAppUpgradeVerifiedInstallerPath'; +export * from './useIsDefaultActiveElementAfterMount'; export * from './useIsPlatformLinux'; export * from './useMeasure'; +export * from './useScrollToReference'; +export * from './useScrollToListItem'; +export * from './useInitialFocus'; +export * from './useFocusReference'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/hooks/useFocusReference.ts b/desktop/packages/mullvad-vpn/src/renderer/hooks/useFocusReference.ts new file mode 100644 index 0000000000..d49264630f --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/hooks/useFocusReference.ts @@ -0,0 +1,12 @@ +import React from 'react'; + +export const useFocusReference = <T extends HTMLElement>( + ref?: React.RefObject<T | null>, + focus?: boolean, +) => { + React.useEffect(() => { + if (focus) { + ref?.current?.focus({ preventScroll: true }); + } + }, [ref, focus]); +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/hooks/useInitialFocus.ts b/desktop/packages/mullvad-vpn/src/renderer/hooks/useInitialFocus.ts new file mode 100644 index 0000000000..72e8355489 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/hooks/useInitialFocus.ts @@ -0,0 +1,23 @@ +import React from 'react'; + +import { useFocusReference } from './useFocusReference'; +import { useIsDefaultActiveElementAfterMount } from './useIsDefaultActiveElementAfterMount'; + +export const useInitialFocus = <T extends HTMLElement = HTMLDivElement>(): { + ref?: React.RefObject<T | null>; +} => { + const ref = React.useRef<T>(null); + + const isDefaultFocus = useIsDefaultActiveElementAfterMount(); + const shouldFocus = isDefaultFocus === true; + + useFocusReference(ref, shouldFocus); + + if (!isDefaultFocus) + return { + ref: undefined, + }; + return { + ref, + }; +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/hooks/useIsDefaultActiveElementAfterMount.ts b/desktop/packages/mullvad-vpn/src/renderer/hooks/useIsDefaultActiveElementAfterMount.ts new file mode 100644 index 0000000000..f79669e766 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/hooks/useIsDefaultActiveElementAfterMount.ts @@ -0,0 +1,23 @@ +import React from 'react'; + +export const useIsDefaultActiveElementAfterMount = () => { + const [isDefaultActiveElementAfterMount, setIsDefaultActiveElementAfterMount] = React.useState< + boolean | undefined + >(undefined); + + React.useEffect(() => { + if (typeof document !== 'undefined') { + const isBodyOrDocumentElement = + document.activeElement === document.body || + document.activeElement === document.documentElement; + + setIsDefaultActiveElementAfterMount(isBodyOrDocumentElement); + } + + return () => { + setIsDefaultActiveElementAfterMount(undefined); + }; + }, []); + + return isDefaultActiveElementAfterMount; +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/hooks/useScrollToListItem.ts b/desktop/packages/mullvad-vpn/src/renderer/hooks/useScrollToListItem.ts new file mode 100644 index 0000000000..34b42cdfc5 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/hooks/useScrollToListItem.ts @@ -0,0 +1,51 @@ +import React from 'react'; + +import { ScrollToAnchorId } from '../../shared/ipc-types'; +import { ListItemAnimation } from '../lib/components/list-item'; +import { useHistory } from '../lib/history'; +import { useFocusReference } from './useFocusReference'; +import { useScrollToReference } from './useScrollToReference'; + +export const useScrollToListItem = <T extends HTMLElement = HTMLDivElement>( + id?: ScrollToAnchorId, +): { + ref?: React.RefObject<T | null>; + animation?: ListItemAnimation; +} => { + const ref = React.useRef<T>(null); + const history = useHistory(); + const { location } = history; + const { state } = location; + + const scrollToAnchorOption = state?.options?.find((option) => option.type === 'scroll-to-anchor'); + const shouldScroll = scrollToAnchorOption && scrollToAnchorOption.id === id; + + const handleScrolled = React.useCallback(() => { + const options = state?.options?.filter((option) => { + if (option.type === 'scroll-to-anchor') { + return option.id !== scrollToAnchorOption?.id; + } + + return true; + }); + + history.replace(location, { + ...state, + options, + }); + }, [history, location, scrollToAnchorOption?.id, state]); + + useScrollToReference(ref, shouldScroll, handleScrolled); + useFocusReference(ref, shouldScroll); + + if (scrollToAnchorOption === undefined) { + return { + ref: undefined, + animation: undefined, + }; + } + return { + ref, + animation: shouldScroll ? 'flash' : 'dim', + }; +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/hooks/useScrollToReference.ts b/desktop/packages/mullvad-vpn/src/renderer/hooks/useScrollToReference.ts new file mode 100644 index 0000000000..fd8919edef --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/hooks/useScrollToReference.ts @@ -0,0 +1,14 @@ +import { useEffect } from 'react'; + +export const useScrollToReference = <T extends Element = HTMLDivElement>( + ref?: React.RefObject<T | null>, + scroll?: boolean, + onScrolled?: () => void, +) => { + useEffect(() => { + if (scroll) { + ref?.current?.scrollIntoView({ behavior: 'instant', block: 'start' }); + onScrolled?.(); + } + }, [onScrolled, ref, scroll]); +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/Accordion.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/Accordion.tsx index 2ed9043972..84a1ce933a 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/Accordion.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/Accordion.tsx @@ -1,35 +1,49 @@ import React from 'react'; -import styled from 'styled-components'; +import { ListItem, ListItemProps } from '../list-item'; import { AccordionProvider } from './AccordionContext'; -import { AccordionHeader, AccordionTrigger } from './components'; -import { AccordionContent } from './components/AccordionContent'; -import { AccordionIcon } from './components/AccordionIcon'; -import { AccordionTitle } from './components/AccordionTitle'; +import { + AccordionContent, + AccordionHeader, + AccordionIcon, + AccordionTitle, + AccordionTrigger, +} from './components'; + +export type AccordionAnimation = 'flash' | 'dim'; export type AccordionProps = { expanded?: boolean; onExpandedChange?: (open: boolean) => void; + disabled?: boolean; + titleId?: string; + animation?: AccordionAnimation; children?: React.ReactNode; -}; - -const StyledAccordion = styled.div` - display: flex; - flex: 1; - flex-direction: column; - width: 100%; -`; +} & ListItemProps; -function Accordion({ expanded = false, onExpandedChange: onOpenChange, children }: AccordionProps) { +function Accordion({ + expanded = false, + onExpandedChange: onOpenChange, + disabled, + animation, + titleId: titleIdProp, + children, + ...props +}: AccordionProps) { const triggerId = React.useId(); const contentId = React.useId(); + const titleId = React.useId(); return ( <AccordionProvider triggerId={triggerId} contentId={contentId} + titleId={titleIdProp ?? titleId} expanded={expanded} - onExpandedChange={onOpenChange}> - <StyledAccordion>{children}</StyledAccordion> + onExpandedChange={onOpenChange} + disabled={disabled}> + <ListItem disabled={disabled} animation={animation} {...props}> + {children} + </ListItem> </AccordionProvider> ); } diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/AccordionContext.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/AccordionContext.tsx index e39295f1a8..acc5756102 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/AccordionContext.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/AccordionContext.tsx @@ -1,12 +1,15 @@ import React from 'react'; -import { AccordionProps } from './Accordion'; +import { AccordionAnimation, AccordionProps } from './Accordion'; interface AccordionContextProps { triggerId: string; contentId: string; + titleId: string; expanded: AccordionProps['expanded']; onExpandedChange?: AccordionProps['onExpandedChange']; + disabled?: boolean; + animation?: AccordionAnimation; } const AccordionContext = React.createContext<AccordionContextProps | undefined>(undefined); @@ -22,21 +25,14 @@ export const useAccordionContext = (): AccordionContextProps => { interface AccordionProviderProps { triggerId: string; contentId: string; + titleId: string; expanded: boolean; onExpandedChange?: (open: boolean) => void; + disabled?: boolean; + animation?: AccordionAnimation; children: React.ReactNode; } -export function AccordionProvider({ - triggerId, - contentId, - expanded, - onExpandedChange, - children, -}: AccordionProviderProps) { - return ( - <AccordionContext.Provider value={{ triggerId, contentId, expanded, onExpandedChange }}> - {children} - </AccordionContext.Provider> - ); +export function AccordionProvider({ children, ...props }: AccordionProviderProps) { + return <AccordionContext.Provider value={props}>{children}</AccordionContext.Provider>; } diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionHeader.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionHeader.tsx index b71effcfba..eee672f61b 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionHeader.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionHeader.tsx @@ -1,30 +1,35 @@ -import styled from 'styled-components'; +import styled, { css, RuleSet } from 'styled-components'; import { colors } from '../../../foundations'; import { Flex } from '../../flex'; -import { StyledAccordionIcon } from './AccordionIcon'; +import { ListItem } from '../../list-item'; export type AccordionHeaderProps = { children?: React.ReactNode; }; -export const StyledAccordionHeader = styled(Flex)` - background-color: ${colors.blue}; - width: 100%; - min-height: 48px; - margin-bottom: 1px; +export const StyledAccordionHeader = styled(Flex)<{ + $animation?: RuleSet<object>; + $disabled?: boolean; +}>` + ${({ $animation, $disabled }) => { + const backgroundColor = $disabled ? colors.blue40 : colors.blue; + return css` + --background-color: ${backgroundColor}; - && > ${StyledAccordionIcon} { - margin-left: auto; - } + margin-bottom: 1px; + background-color: var(--background-color); + min-height: 48px; + width: 100%; + ${$animation} + `; + }} `; export function AccordionHeader({ children }: AccordionHeaderProps) { return ( - <StyledAccordionHeader - $padding={{ horizontal: 'medium', vertical: 'small' }} - $alignItems="center"> - {children} - </StyledAccordionHeader> + <ListItem.Item> + <ListItem.Content>{children}</ListItem.Content> + </ListItem.Item> ); } diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionIcon.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionIcon.tsx index 043a72fa00..33b3936978 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionIcon.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionIcon.tsx @@ -1,18 +1,13 @@ -import styled from 'styled-components'; - -import { Icon, IconProps } from '../../icon'; +import { IconProps } from '../../icon'; +import { ListItem } from '../../list-item'; import { useAccordionContext } from '../AccordionContext'; export type AccordionIconProps = Omit<IconProps, 'icon'> & { icon?: IconProps['icon']; }; -export const StyledAccordionIcon = styled(Icon)` - flex-shrink: 0; -`; - -export function AccordionIcon({ icon, color = 'whiteAlpha80', ...props }: AccordionIconProps) { - const { expanded: open } = useAccordionContext(); - const iconName = icon || (open ? 'chevron-up' : 'chevron-down'); - return <StyledAccordionIcon icon={iconName} color={color} {...props} />; +export function AccordionIcon({ icon, ...props }: AccordionIconProps) { + const { expanded } = useAccordionContext(); + const iconName = icon || (expanded ? 'chevron-up' : 'chevron-down'); + return <ListItem.Icon icon={iconName} {...props} />; } diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionTitle.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionTitle.tsx index e9d287b261..fab41c638c 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionTitle.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionTitle.tsx @@ -1,17 +1,10 @@ -import styled from 'styled-components'; +import { ListItem } from '../../list-item'; +import { TextProps } from '../../typography'; +import { useAccordionContext } from '../AccordionContext'; -import { Text } from '../../typography'; - -export type AccordionTitleProps = { - children?: React.ReactNode; -}; - -export const StyledTitleLabel = styled(Text)``; +export type AccordionTitleProps = TextProps; export function AccordionTitle({ children }: AccordionTitleProps) { - return ( - <StyledTitleLabel $padding="medium" color="white" variant="titleMedium"> - {children} - </StyledTitleLabel> - ); + const { titleId } = useAccordionContext(); + return <ListItem.Label id={titleId}>{children}</ListItem.Label>; } diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionTrigger.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionTrigger.tsx index ff852e4ea1..dbf1016d90 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionTrigger.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/AccordionTrigger.tsx @@ -1,30 +1,14 @@ import React from 'react'; -import styled from 'styled-components'; -import { colors } from '../../../foundations'; +import { ListItem } from '../../list-item'; import { useAccordionContext } from '../AccordionContext'; -import { StyledAccordionHeader } from './AccordionHeader'; export type AccordionTriggerProps = { children?: React.ReactNode; -}; - -const StyledAccordionTrigger = styled.button` - background-color: transparent; - &&:hover > ${StyledAccordionHeader} { - background-color: ${colors.blue60}; - } - &&:active > ${StyledAccordionHeader} { - background-color: ${colors.blue40}; - } - &&:focus-visible { - outline: 2px solid ${colors.white}; - outline-offset: -2px; - } -`; +} & React.ButtonHTMLAttributes<HTMLButtonElement>; export function AccordionTrigger({ children }: AccordionTriggerProps) { - const { contentId, triggerId, expanded, onExpandedChange } = useAccordionContext(); + const { contentId, triggerId, titleId, expanded, onExpandedChange } = useAccordionContext(); const onClick = React.useCallback( (e: React.MouseEvent<HTMLButtonElement>) => { @@ -35,12 +19,13 @@ export function AccordionTrigger({ children }: AccordionTriggerProps) { ); return ( - <StyledAccordionTrigger + <ListItem.Trigger id={triggerId} + aria-labelledby={titleId} aria-controls={contentId} aria-expanded={expanded ? 'true' : 'false'} onClick={onClick}> {children} - </StyledAccordionTrigger> + </ListItem.Trigger> ); } diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/index.ts index abd6b8865b..7727d016fa 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/index.ts +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/accordion/components/index.ts @@ -1,2 +1,5 @@ +export * from './AccordionContent'; export * from './AccordionHeader'; export * from './AccordionTrigger'; +export * from './AccordionTitle'; +export * from './AccordionIcon'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/feature-indicator/FeatureIndicator.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/feature-indicator/FeatureIndicator.tsx new file mode 100644 index 0000000000..5535e7212d --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/feature-indicator/FeatureIndicator.tsx @@ -0,0 +1,104 @@ +import styled, { css } from 'styled-components'; + +import { colors, Radius } from '../../foundations'; +import { Flex } from '../flex'; +import { FeatureIndicatorText } from './components'; +import { FeatureIndicatorProvider } from './FeatureIndicatorContext'; + +export type FeatureIndicatorProps = { + variant?: 'primary' | 'transparent'; +} & React.ComponentPropsWithRef<'button'>; + +const styles = { + radius: Radius.radius4, + variants: { + primary: { + backgroundColor: colors.blue10, + borderColor: colors.blue, + borderColorHover: colors.whiteAlpha80, + borderColorPressed: colors.white, + }, + transparent: { + backgroundColor: 'transparent', + borderColor: 'transparent', + borderColorHover: 'transparent', + borderColorPressed: 'transparent', + }, + }, +}; + +const StyledFeatureIndicator = styled.button<{ + $variant: FeatureIndicatorProps['variant']; + $clickable?: boolean; +}>` + ${({ $variant: variantProp = 'primary', $clickable }) => { + const variant = styles.variants[variantProp]; + return css` + display: flex; + align-items: center; + + border-radius: ${Radius.radius8}; + background: ${variant.backgroundColor}; + border: 1px solid ${variant.borderColor}; + + ${() => { + if ($clickable) { + return css` + &&:not(:disabled):hover { + border-color: ${variant.borderColorHover}; + } + &&:not(:disabled):active { + border-color: ${variant.borderColorPressed}; + } + `; + } + return null; + }} + + &&:disabled { + background: var(--disabled); + } + &&:focus-visible { + outline: 2px solid ${colors.white}; + outline-offset: -2px; + } + `; + }} +`; + +const StyledFlex = styled(Flex)` + padding: 2px 8px; +`; + +function FeatureIndicator({ + ref, + variant, + children, + disabled, + style, + onClick, + ...props +}: FeatureIndicatorProps) { + const clickable = !disabled && !!onClick; + return ( + <FeatureIndicatorProvider disabled={disabled}> + <StyledFeatureIndicator + ref={ref} + $variant={variant} + $clickable={clickable} + disabled={disabled} + onClick={onClick} + {...props}> + <StyledFlex $flex={1} $alignItems="center"> + {children} + </StyledFlex> + </StyledFeatureIndicator> + </FeatureIndicatorProvider> + ); +} + +const FeatureIndicatorNamespace = Object.assign(FeatureIndicator, { + Text: FeatureIndicatorText, +}); + +export { FeatureIndicatorNamespace as FeatureIndicator }; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/feature-indicator/FeatureIndicatorContext.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/feature-indicator/FeatureIndicatorContext.tsx new file mode 100644 index 0000000000..cfe16337c1 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/feature-indicator/FeatureIndicatorContext.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +interface FeatureIndicatorContextProps { + disabled?: boolean; +} +const FeatureIndicatorContext = React.createContext<FeatureIndicatorContextProps | undefined>( + undefined, +); + +export const useFeatureIndicatorContext = (): FeatureIndicatorContextProps => { + const context = React.useContext(FeatureIndicatorContext); + if (!context) { + throw new Error('useFeatureIndicatorContext must be used within a FeatureIndicatorProvider'); + } + return context; +}; + +interface FeatureIndicatorProviderProps { + disabled?: boolean; + children: React.ReactNode; +} + +export const FeatureIndicatorProvider = ({ disabled, children }: FeatureIndicatorProviderProps) => { + return ( + <FeatureIndicatorContext.Provider value={{ disabled }}> + {children} + </FeatureIndicatorContext.Provider> + ); +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/feature-indicator/components/FeatureIndicatorText.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/feature-indicator/components/FeatureIndicatorText.tsx new file mode 100644 index 0000000000..49d8d5fac9 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/feature-indicator/components/FeatureIndicatorText.tsx @@ -0,0 +1,16 @@ +import styled from 'styled-components'; + +import { BodySmallSemiBoldProps, LabelTiny } from '../../typography'; +import { useFeatureIndicatorContext } from '../FeatureIndicatorContext'; + +export type FeatureIndicatorTextProps<T extends React.ElementType = 'span'> = + BodySmallSemiBoldProps<T>; + +export const StyledFeatureIndicatorText = styled(LabelTiny)``; + +export const FeatureIndicatorText = <T extends React.ElementType = 'span'>( + props: FeatureIndicatorTextProps<T>, +) => { + const { disabled } = useFeatureIndicatorContext(); + return <StyledFeatureIndicatorText color={disabled ? 'whiteAlpha40' : 'white'} {...props} />; +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/feature-indicator/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/feature-indicator/components/index.ts new file mode 100644 index 0000000000..a55496b31e --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/feature-indicator/components/index.ts @@ -0,0 +1 @@ +export * from './FeatureIndicatorText'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/feature-indicator/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/feature-indicator/index.ts new file mode 100644 index 0000000000..1b3d6b0bde --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/feature-indicator/index.ts @@ -0,0 +1 @@ +export * from './FeatureIndicator'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/icon/Icon.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/icon/Icon.tsx index fe0edcefef..8a46443933 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/icon/Icon.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/icon/Icon.tsx @@ -13,6 +13,7 @@ export type IconProps = { const StyledIcon = styled.div<{ $color: string; $size: number; $src: string }>` ${({ $size, $src, $color }) => { return css` + flex-shrink: 0; width: ${$size}px; height: ${$size}px; mask: url(${$src}) no-repeat center; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/index.ts index a425560461..fce0e5c7ca 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/index.ts +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/index.ts @@ -2,6 +2,7 @@ export * from './box'; export * from './button'; export * from './filter-chip'; export * from './container'; +export * from './feature-indicator'; export * from './flex'; export * from './image'; export * from './icon'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/ListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/ListItem.tsx index 1a37760d97..eb2abd95c2 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/ListItem.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/ListItem.tsx @@ -1,6 +1,6 @@ -import styled from 'styled-components'; +import React from 'react'; +import styled, { css, RuleSet } from 'styled-components'; -import { Flex } from '../flex'; import { ListItemContent, ListItemFooter, @@ -9,27 +9,45 @@ import { ListItemItem, ListItemLabel, ListItemText, + ListItemTextField, ListItemTrigger, } from './components'; +import { useListItemAnimation } from './hooks'; import { levels } from './levels'; import { ListItemProvider } from './ListItemContext'; -export interface ListItemProps { +export type ListItemAnimation = 'flash' | 'dim'; + +export const StyledListItem = styled.div<{ + $animation?: RuleSet<object>; +}>` + ${({ $animation }) => { + return css` + ${$animation} + `; + }} +`; + +export type ListItemProps = { level?: keyof typeof levels; disabled?: boolean; + animation?: ListItemAnimation | false; children: React.ReactNode; -} - -const StyledFlex = styled(Flex)` - margin-bottom: 1px; -`; +} & React.ComponentPropsWithRef<'div'>; -const ListItem = ({ level = 0, disabled, children }: ListItemProps) => { +const ListItem = ({ + level = 0, + disabled, + animation: animationProp, + children, + ...props +}: ListItemProps) => { + const animation = useListItemAnimation(animationProp); return ( - <ListItemProvider level={level} disabled={disabled}> - <StyledFlex $flexDirection="column" $gap="tiny" $flex={1}> + <ListItemProvider level={level} disabled={disabled} animation={animationProp}> + <StyledListItem $animation={animationProp == 'dim' ? animation : undefined} {...props}> {children} - </StyledFlex> + </StyledListItem> </ListItemProvider> ); }; @@ -43,6 +61,7 @@ const ListItemNamespace = Object.assign(ListItem, { Item: ListItemItem, Footer: ListItemFooter, Icon: ListItemIcon, + TextField: ListItemTextField, }); export { ListItemNamespace as ListItem }; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/ListItemContext.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/ListItemContext.tsx index 348f916a41..8c75627059 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/ListItemContext.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/ListItemContext.tsx @@ -1,25 +1,23 @@ -import { createContext, ReactNode, useContext } from 'react'; +import { createContext, useContext } from 'react'; import { levels } from './levels'; +import { ListItemProps } from './ListItem'; -interface ListItemContextType { +type ListItemContextType = { level: keyof typeof levels; disabled?: boolean; -} + animation?: ListItemProps['animation']; +}; const ListItemContext = createContext<ListItemContextType | undefined>(undefined); -interface ListItemProviderProps extends ListItemContextType { - children: ReactNode; -} +type ListItemProviderProps = React.PropsWithChildren<ListItemContextType>; -export const ListItemProvider = ({ level, disabled, children }: ListItemProviderProps) => { - return ( - <ListItemContext.Provider value={{ level, disabled }}>{children}</ListItemContext.Provider> - ); +export const ListItemProvider = ({ children, ...props }: ListItemProviderProps) => { + return <ListItemContext.Provider value={props}>{children}</ListItemContext.Provider>; }; -export const useListItem = (): ListItemContextType => { +export const useListItemContext = (): ListItemContextType => { const context = useContext(ListItemContext); if (!context) { throw new Error('useListItem must be used within a ListItemProvider'); diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/index.ts index 1b7906d8a1..0ed8151241 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/index.ts +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/index.ts @@ -6,3 +6,4 @@ export * from './list-item-label'; export * from './list-item-text'; export * from './list-item-trigger'; export * from './list-item-footer'; +export * from './list-item-text-field'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-content/ListItemContent.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-content/ListItemContent.tsx index 6065e56871..71b9527d03 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-content/ListItemContent.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-content/ListItemContent.tsx @@ -1,6 +1,8 @@ import styled, { css } from 'styled-components'; +import { spacings } from '../../../../foundations'; import { Flex, FlexProps } from '../../../flex'; +import { useIndent } from './hooks'; const sizes = { full: '100%', @@ -11,13 +13,16 @@ type Size = keyof typeof sizes; const StyledFlex = styled(Flex)<{ $size: Size; + $paddingLeft: string; }>` - ${({ $size }) => { + ${({ $size, $paddingLeft }) => { const size = sizes[$size]; return css` --size: ${size}; width: var(--size); height: 100%; + padding-left: ${$paddingLeft}; + padding-right: ${spacings.medium}; &&:has(> :last-child:nth-child(1)) { &&:has(img) { justify-content: center; @@ -32,15 +37,15 @@ export interface ListItemContentProps extends FlexProps { } export function ListItemContent({ size = 'full', ...props }: ListItemContentProps) { + const leftPadding = useIndent(); + return ( <StyledFlex $size={size} $alignItems="center" $justifyContent="space-between" $gap="small" - $padding={{ - horizontal: 'medium', - }} + $paddingLeft={leftPadding} {...props} /> ); diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-content/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-content/hooks/index.ts new file mode 100644 index 0000000000..2dafc71f24 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-content/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-indent'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-content/hooks/use-indent.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-content/hooks/use-indent.ts new file mode 100644 index 0000000000..a6b89ce13c --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-content/hooks/use-indent.ts @@ -0,0 +1,7 @@ +import { levels } from '../../../levels'; +import { useListItemContext } from '../../../ListItemContext'; + +export const useIndent = () => { + const { level } = useListItemContext(); + return levels[level].indent; +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-footer/ListItemFooter.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-footer/ListItemFooter.tsx index c5b0de9355..2224633df0 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-footer/ListItemFooter.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-footer/ListItemFooter.tsx @@ -3,5 +3,5 @@ import { Flex, FlexProps } from '../../../flex'; export type ListItemFooterProps = FlexProps; export const ListItemFooter = (props: ListItemFooterProps) => { - return <Flex $padding={{ horizontal: 'medium' }} {...props} />; + return <Flex $padding={{ horizontal: 'medium' }} $margin={{ top: 'tiny' }} {...props} />; }; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-icon/ListItemIcon.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-icon/ListItemIcon.tsx index afb83c9a7c..323fcdea0d 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-icon/ListItemIcon.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-icon/ListItemIcon.tsx @@ -1,9 +1,9 @@ import { Icon, IconProps } from '../../../icon'; -import { useListItem } from '../../ListItemContext'; +import { useListItemContext } from '../../ListItemContext'; export type ListItemIconProps = Omit<IconProps, 'size'>; export function ListItemIcon({ ...props }: ListItemIconProps) { - const { disabled } = useListItem(); + const { disabled } = useListItemContext(); return <Icon aria-hidden="true" color={disabled ? 'whiteAlpha40' : 'white'} {...props} />; } diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-item/ListItemItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-item/ListItemItem.tsx index 626f7327e6..2cd67c7c1e 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-item/ListItemItem.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-item/ListItemItem.tsx @@ -1,15 +1,23 @@ -import styled, { css } from 'styled-components'; +import React from 'react'; +import styled, { css, RuleSet } from 'styled-components'; +import { useListItemAnimation } from '../../hooks'; +import { useListItemContext } from '../../ListItemContext'; import { useBackgroundColor } from './hooks'; -export interface ListItemItemProps { +export type ListItemItemProps = { children: React.ReactNode; -} +} & React.ComponentPropsWithRef<'div'>; -const StyledDiv = styled.div<{ $backgroundColor: string }>` - ${({ $backgroundColor }) => { +export const StyledListItemItem = styled.div<{ + $backgroundColor: string; + $animation?: RuleSet<object>; +}>` + ${({ $backgroundColor, $animation }) => { return css` --background-color: ${$backgroundColor}; + + margin-bottom: 1px; background-color: var(--background-color); min-height: 48px; width: 100%; @@ -19,11 +27,21 @@ const StyledDiv = styled.div<{ $backgroundColor: string }>` &&:has(> :last-child:nth-child(2)) { grid-template-columns: 1fr 56px; } + ${$animation} `; }} `; -export function ListItemItem({ children }: ListItemItemProps) { +export function ListItemItem({ children, ...props }: ListItemItemProps) { const backgroundColor = useBackgroundColor(); - return <StyledDiv $backgroundColor={backgroundColor}>{children}</StyledDiv>; + const { animation: contextAnimation } = useListItemContext(); + const animation = useListItemAnimation(contextAnimation); + return ( + <StyledListItemItem + $backgroundColor={backgroundColor} + $animation={contextAnimation === 'flash' ? animation : undefined} + {...props}> + {children} + </StyledListItemItem> + ); } diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-item/hooks/useBackgroundColor.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-item/hooks/useBackgroundColor.tsx index 68bc841f48..3f7a8e045e 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-item/hooks/useBackgroundColor.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-item/hooks/useBackgroundColor.tsx @@ -1,7 +1,7 @@ import { levels } from '../../../levels'; -import { useListItem } from '../../../ListItemContext'; +import { useListItemContext } from '../../../ListItemContext'; export const useBackgroundColor = () => { - const { level, disabled } = useListItem(); + const { level, disabled } = useListItemContext(); return disabled ? levels[level].disabled : levels[level].enabled; }; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-label/ListItemLabel.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-label/ListItemLabel.tsx index c5231e9fe1..9ee4c75daf 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-label/ListItemLabel.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-label/ListItemLabel.tsx @@ -1,11 +1,11 @@ import { LabelTinyProps, TitleMedium } from '../../../typography'; -import { useListItem } from '../../ListItemContext'; +import { useListItemContext } from '../../ListItemContext'; export type ListItemLabelProps<E extends React.ElementType = 'span'> = LabelTinyProps<E>; export const ListItemLabel = <E extends React.ElementType = 'span'>( props: ListItemLabelProps<E>, ) => { - const { disabled } = useListItem(); + const { disabled } = useListItemContext(); return <TitleMedium color={disabled ? 'whiteAlpha40' : 'white'} {...props} />; }; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-text-field/ListItemTextField.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-text-field/ListItemTextField.tsx new file mode 100644 index 0000000000..05c9b828f1 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-text-field/ListItemTextField.tsx @@ -0,0 +1,22 @@ +import { TextField, TextFieldProps } from '../../../text-field'; +import { ListItemTextFieldInput } from './components'; + +export type ListItemTextFieldProps = TextFieldProps & { + onSubmit?: (event: React.FormEvent) => Promise<void>; +}; + +function ListItemTextField({ invalid, onSubmit, children, ...props }: ListItemTextFieldProps) { + return ( + <form onSubmit={onSubmit}> + <TextField invalid={invalid} {...props}> + {children} + </TextField> + </form> + ); +} + +const ListItemTextFieldNamespace = Object.assign(ListItemTextField, { + Input: ListItemTextFieldInput, +}); + +export { ListItemTextFieldNamespace as ListItemTextField }; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-text-field/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-text-field/components/index.ts new file mode 100644 index 0000000000..28509d072d --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-text-field/components/index.ts @@ -0,0 +1 @@ +export * from './list-item-text-field-input'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-text-field/components/list-item-text-field-input/ListItemTextFieldInput.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-text-field/components/list-item-text-field-input/ListItemTextFieldInput.tsx new file mode 100644 index 0000000000..3eb3342ffc --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-text-field/components/list-item-text-field-input/ListItemTextFieldInput.tsx @@ -0,0 +1,15 @@ +import styled from 'styled-components'; + +import { TextField } from '../../../../../text-field'; +import { TextFieldInputProps } from '../../../../../text-field/components'; + +export type ListItemTextFieldInputProps = TextFieldInputProps; + +const StyledTextFieldInput = styled(TextField.Input)` + box-sizing: border-box; + width: 102px; +`; + +export function ListItemTextFieldInput({ children, ...props }: ListItemTextFieldInputProps) { + return <StyledTextFieldInput {...props}>{children}</StyledTextFieldInput>; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-text-field/components/list-item-text-field-input/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-text-field/components/list-item-text-field-input/index.ts new file mode 100644 index 0000000000..a9da3a4bb4 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-text-field/components/list-item-text-field-input/index.ts @@ -0,0 +1 @@ +export * from './ListItemTextFieldInput'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-text-field/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-text-field/index.ts new file mode 100644 index 0000000000..7f6a499cfb --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-text-field/index.ts @@ -0,0 +1 @@ +export * from './ListItemTextField'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-text/ListItemText.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-text/ListItemText.tsx index 423c9c24c4..0b76c45934 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-text/ListItemText.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-text/ListItemText.tsx @@ -1,9 +1,9 @@ import { Text, TextProps } from '../../../typography'; -import { useListItem } from '../../ListItemContext'; +import { useListItemContext } from '../../ListItemContext'; export type ListItemTextProps<E extends React.ElementType = 'span'> = TextProps<E>; export const ListItemText = <E extends React.ElementType = 'span'>(props: ListItemTextProps<E>) => { - const { disabled } = useListItem(); + const { disabled } = useListItemContext(); return <Text variant="labelTiny" color={disabled ? 'whiteAlpha40' : 'whiteAlpha60'} {...props} />; }; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-trigger/ListItemTrigger.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-trigger/ListItemTrigger.tsx index 7cd7b85859..a494a4521c 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-trigger/ListItemTrigger.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-trigger/ListItemTrigger.tsx @@ -1,14 +1,14 @@ +import { forwardRef } from 'react'; import styled, { css } from 'styled-components'; import { colors } from '../../../../foundations'; -import { ListItemProps } from '../../ListItem'; -import { useListItem } from '../../ListItemContext'; +import { useListItemContext } from '../../ListItemContext'; +import { StyledListItemItem } from '../list-item-item'; -const StyledButton = styled.button<Pick<ListItemProps, 'disabled'>>` +const StyledButton = styled.button<{ $disabled?: boolean }>` display: flex; width: 100%; - --background: transparent; - background-color: var(--background); + background-color: transparent; &&:focus-visible { outline: 2px solid ${colors.white}; @@ -19,16 +19,16 @@ const StyledButton = styled.button<Pick<ListItemProps, 'disabled'>>` ${({ disabled }) => { if (!disabled) { return css` - --background: ${colors.blue}; - &:hover { - --background: ${colors.whiteOnBlue10}; - background-color: var(--background); + ${StyledListItemItem} { + background-color: ${colors.whiteOnBlue10}; + } } &:active { - --background: ${colors.whiteOnBlue20}; - background-color: var(--background); + ${StyledListItemItem} { + background-color: ${colors.whiteOnBlue20}; + } } `; } @@ -39,7 +39,9 @@ const StyledButton = styled.button<Pick<ListItemProps, 'disabled'>>` export type ListItemTriggerProps = React.HtmlHTMLAttributes<HTMLButtonElement>; -export function ListItemTrigger(props: ListItemTriggerProps) { - const { disabled } = useListItem(); - return <StyledButton disabled={disabled} {...props} />; -} +export const ListItemTrigger = forwardRef<HTMLButtonElement, ListItemTriggerProps>((props, ref) => { + const { disabled } = useListItemContext(); + return <StyledButton ref={ref} disabled={disabled} {...props} />; +}); + +ListItemTrigger.displayName = 'ListItemTrigger'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/hooks/index.ts new file mode 100644 index 0000000000..a0fc82ef55 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-list-item-animation'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/hooks/use-list-item-animation.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/hooks/use-list-item-animation.ts new file mode 100644 index 0000000000..75a6a26a76 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/hooks/use-list-item-animation.ts @@ -0,0 +1,34 @@ +import { css, keyframes } from 'styled-components'; + +import { colors } from '../../../foundations'; +import { ListItemAnimation } from '../ListItem'; + +const flash = keyframes` + from { background-color: var(--background-color) } + to { background-color: ${colors.whiteOnBlue20} } +`; + +const dim = keyframes` + 0% { opacity: 100% } + 10% { opacity: 50% } + 50% { opacity: 50% } + 90% { opacity: 50% } + 100% { opacity: 100% } +`; + +export const useListItemAnimation = (animation?: ListItemAnimation | false) => { + const flashDuration = 200; + const flashDelay = 450; + const dimDuration = (flashDelay + flashDuration * 4) * 1.1; + if (animation === 'flash') { + return css` + animation: ${flash} ${flashDuration}ms ease-in-out ${flashDelay}ms 4 alternate; + `; + } + if (animation === 'dim') { + return css` + animation: ${dim} ${dimDuration}ms ease-in-out 0ms normal; + `; + } + return undefined; +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/levels.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/levels.ts index c3700a3761..aa9268ee83 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/levels.ts +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/levels.ts @@ -1,9 +1,9 @@ -import { colors } from '../../foundations'; +import { colors, spacings } from '../../foundations'; export const levels = { - 0: { enabled: colors.blue, disabled: colors.blue40 }, - 1: { enabled: colors.blue60, disabled: colors.blue40 }, - 2: { enabled: colors.blue40, disabled: colors.blue20 }, - 3: { enabled: colors.blue20, disabled: colors.blue10 }, - 4: { enabled: colors.blue10, disabled: colors.blue10 }, + 0: { enabled: colors.blue, disabled: colors.blue40, indent: spacings.medium }, + 1: { enabled: colors.blue60, disabled: colors.blue40, indent: spacings.medium }, + 2: { enabled: colors.blue40, disabled: colors.blue20, indent: spacings.big }, + 3: { enabled: colors.blue20, disabled: colors.blue10, indent: '48px' }, + 4: { enabled: colors.blue10, disabled: colors.blue10, indent: '64px' }, } as const; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/Listbox.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/Listbox.tsx new file mode 100644 index 0000000000..d4635d49fe --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/Listbox.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import { ListItem, ListItemProps } from '../list-item'; +import { ListboxLabel, ListboxOption, ListboxOptions } from './components'; +import { ListboxProvider } from './ListboxContext'; + +export type ListboxProps<T> = ListItemProps & { + onValueChange?: (value: T) => Promise<void>; + value?: T; + labelId?: string; +}; + +function Listbox<T>({ + value, + onValueChange, + labelId: labelIdProp, + children, + ...props +}: ListboxProps<T>) { + const labelId = React.useId(); + + return ( + <ListboxProvider labelId={labelIdProp ?? labelId} value={value} onValueChange={onValueChange}> + <ListItem {...props}>{children}</ListItem> + </ListboxProvider> + ); +} + +const ListboxNamespace = Object.assign(Listbox, { + Item: ListItem.Item, + Content: ListItem.Content, + Label: ListboxLabel, + Group: ListItem.Group, + Text: ListItem.Text, + Footer: ListItem.Footer, + Icon: ListItem.Icon, + Option: ListboxOption, + Options: ListboxOptions, +}); + +export { ListboxNamespace as Listbox }; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/ListboxContext.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/ListboxContext.tsx new file mode 100644 index 0000000000..55b1a20699 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/ListboxContext.tsx @@ -0,0 +1,48 @@ +import React from 'react'; + +import { ListboxProps } from './Listbox'; + +type ListboxContext<T> = Pick<ListboxProps<T>, 'value' | 'onValueChange'> & { + labelId: string; + optionsRef: React.RefObject<HTMLUListElement | null>; + focusedIndex?: number; + setFocusedIndex: React.Dispatch<React.SetStateAction<number | undefined>>; +}; + +type ListboxProviderProps<T> = Pick<ListboxContext<T>, 'value' | 'onValueChange' | 'labelId'> & { + children: React.ReactNode; +}; + +const ListboxContext = React.createContext<ListboxContext<unknown> | undefined>(undefined); + +export function useListboxContext<T>(): ListboxContext<T> { + const context = React.useContext(ListboxContext) as ListboxContext<T> | undefined; + if (!context) { + throw new Error('useListboxContext must be used within a ListboxProvider'); + } + return context; +} + +export function ListboxProvider<T>({ + value, + onValueChange, + labelId, + children, +}: ListboxProviderProps<T>) { + const TypedListboxContext = ListboxContext as React.Context<ListboxContext<T>>; + const [focusedIndex, setFocusedIndex] = React.useState<number | undefined>(undefined); + const optionsRef = React.useRef<HTMLUListElement>(null); + return ( + <TypedListboxContext.Provider + value={{ + value, + onValueChange, + labelId, + focusedIndex, + setFocusedIndex, + optionsRef, + }}> + {children} + </TypedListboxContext.Provider> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/index.ts new file mode 100644 index 0000000000..4808f57313 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/index.ts @@ -0,0 +1,3 @@ +export * from './listbox-label'; +export * from './listbox-option'; +export * from './listbox-options'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-label/ListboxLabel.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-label/ListboxLabel.tsx new file mode 100644 index 0000000000..fc0c0acbc3 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-label/ListboxLabel.tsx @@ -0,0 +1,9 @@ +import { ListItemLabel, ListItemLabelProps } from '../../../list-item/components'; +import { useListboxContext } from '../../'; + +export type ListboxLabelProps = ListItemLabelProps; + +export const ListboxLabel = (props: ListboxLabelProps) => { + const { labelId } = useListboxContext(); + return <ListItemLabel id={labelId} {...props} />; +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-label/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-label/index.ts new file mode 100644 index 0000000000..0fb8f47138 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-label/index.ts @@ -0,0 +1 @@ +export * from './ListboxLabel'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/ListboxOption.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/ListboxOption.tsx new file mode 100644 index 0000000000..139d40b1a9 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/ListboxOption.tsx @@ -0,0 +1,32 @@ +import { ListItem, ListItemProps } from '../../../list-item'; +import { + ListboxOptionIcon, + ListboxOptionItem, + ListboxOptionLabel, + ListboxOptionTrigger, +} from './components'; +import { ListboxOptionProvider } from './ListboxOptionContext'; + +export type ListboxOptionProps<T> = ListItemProps & { + value: T; +}; + +function ListboxOption<T>({ value, children, ...props }: ListboxOptionProps<T>) { + return ( + <ListboxOptionProvider value={value}> + <ListItem {...props}>{children}</ListItem> + </ListboxOptionProvider> + ); +} + +const ListboxOptionNamespace = Object.assign(ListboxOption, { + Content: ListItem.Content, + Group: ListItem.Group, + Trigger: ListboxOptionTrigger, + Item: ListboxOptionItem, + Footer: ListItem.Footer, + Icon: ListboxOptionIcon, + Label: ListboxOptionLabel, +}); + +export { ListboxOptionNamespace as ListboxOption }; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/ListboxOptionContext.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/ListboxOptionContext.tsx new file mode 100644 index 0000000000..2f4e23450b --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/ListboxOptionContext.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +import { ListboxOptionProps } from './ListboxOption'; + +type ListboxOptionContext<T> = Pick<ListboxOptionProps<T>, 'value'>; + +type ListboxOptionProviderProps<T> = ListboxOptionContext<T> & { + children: React.ReactNode; +}; + +const ListboxOptionContext = React.createContext<ListboxOptionContext<unknown> | undefined>( + undefined, +); + +export function useListboxOptionContext<T>(): ListboxOptionContext<T> { + const context = React.useContext(ListboxOptionContext) as ListboxOptionContext<T> | undefined; + if (!context) { + throw new Error('useListboxOptionContext must be used within a ListboxOptionProvider'); + } + return context; +} + +export function ListboxOptionProvider<T>({ children, ...props }: ListboxOptionProviderProps<T>) { + const TypedListboxOptionContext = ListboxOptionContext as React.Context<ListboxOptionContext<T>>; + return ( + <TypedListboxOptionContext.Provider value={props}> + {children} + </TypedListboxOptionContext.Provider> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/index.ts new file mode 100644 index 0000000000..19d312f8dc --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/index.ts @@ -0,0 +1,4 @@ +export * from './listbox-option-icon'; +export * from './listbox-option-label'; +export * from './listbox-option-item'; +export * from './listbox-option-trigger'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/listbox-option-icon/ListboxOptionIcon.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/listbox-option-icon/ListboxOptionIcon.tsx new file mode 100644 index 0000000000..8ace35d43f --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/listbox-option-icon/ListboxOptionIcon.tsx @@ -0,0 +1,20 @@ +import styled from 'styled-components'; + +import { ListItem } from '../../../../../list-item'; +import { ListItemIconProps } from '../../../../../list-item/components'; +import { useListboxContext } from '../../../../'; +import { useListboxOptionContext } from '../../'; + +export type ListboxOptionIconProps = ListItemIconProps; + +export const StyledListboxOptionIcon = styled(ListItem.Icon)<{ $selected: boolean }>` + visibility: ${({ $selected }) => ($selected ? 'visible' : 'hidden')}; +`; + +export function ListboxOptionIcon(props: ListboxOptionIconProps) { + const { value: selectedValue } = useListboxContext(); + const { value } = useListboxOptionContext(); + const selected = value === selectedValue; + + return <StyledListboxOptionIcon $selected={selected} {...props} />; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/listbox-option-icon/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/listbox-option-icon/index.ts new file mode 100644 index 0000000000..561961e7b2 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/listbox-option-icon/index.ts @@ -0,0 +1 @@ +export * from './ListboxOptionIcon'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/listbox-option-item/ListboxOptionItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/listbox-option-item/ListboxOptionItem.tsx new file mode 100644 index 0000000000..a434c676c4 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/listbox-option-item/ListboxOptionItem.tsx @@ -0,0 +1,24 @@ +import styled, { css } from 'styled-components'; + +import { colors } from '../../../../../../foundations'; +import { ListItem } from '../../../../../list-item'; +import { ListItemItemProps } from '../../../../../list-item/components'; +import { useListboxContext } from '../../../../'; +import { useListboxOptionContext } from '../../'; + +export type ListItemOptionItemProps = ListItemItemProps; + +export const StyledListItemOptionItem = styled(ListItem.Item)<{ $selected: boolean }>` + ${({ $selected }) => { + return css` + background-color: ${$selected ? colors.green : undefined}; + `; + }} +`; + +export function ListboxOptionItem(props: ListItemOptionItemProps) { + const { value: selectedValue } = useListboxContext(); + const { value } = useListboxOptionContext(); + const selected = value === selectedValue; + return <StyledListItemOptionItem $selected={selected} {...props} />; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/listbox-option-item/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/listbox-option-item/index.ts new file mode 100644 index 0000000000..0855e6efa9 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/listbox-option-item/index.ts @@ -0,0 +1 @@ +export * from './ListboxOptionItem'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/listbox-option-label/ListboxOptionLabel.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/listbox-option-label/ListboxOptionLabel.tsx new file mode 100644 index 0000000000..baf26fedd8 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/listbox-option-label/ListboxOptionLabel.tsx @@ -0,0 +1,11 @@ +import { Text, TextProps } from '../../../../..'; +import { useListItemContext } from '../../../../../list-item/ListItemContext'; + +export type SelectListItemOptionLabelProps<E extends React.ElementType = 'span'> = TextProps<E>; + +export const ListboxOptionLabel = <E extends React.ElementType = 'span'>( + props: SelectListItemOptionLabelProps<E>, +) => { + const { disabled } = useListItemContext(); + return <Text variant="bodySmall" color={disabled ? 'whiteAlpha40' : 'white'} {...props} />; +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/listbox-option-label/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/listbox-option-label/index.ts new file mode 100644 index 0000000000..33dd9dfcc2 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/listbox-option-label/index.ts @@ -0,0 +1 @@ +export * from './ListboxOptionLabel'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/listbox-option-trigger/ListboxOptionTrigger.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/listbox-option-trigger/ListboxOptionTrigger.tsx new file mode 100644 index 0000000000..383a0aac32 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/listbox-option-trigger/ListboxOptionTrigger.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import styled, { css } from 'styled-components'; + +import { colors } from '../../../../../../foundations'; +import { useListItemContext } from '../../../../../list-item/ListItemContext'; +import { useListboxContext } from '../../../../'; +import { useListboxOptionContext } from '../../'; +import { StyledListItemOptionItem } from '../'; + +export type ListboxOptionTriggerProps = React.ComponentPropsWithRef<'li'>; + +export const StyledListItemOptionTrigger = styled.li<{ $disabled?: boolean }>` + display: flex; + width: 100%; + background-color: transparent; + + &&:focus-visible { + outline: 2px solid ${colors.white}; + outline-offset: -2px; + z-index: 10; + } + + ${({ $disabled }) => { + if (!$disabled) { + return css` + &&:hover { + ${StyledListItemOptionItem} { + background-color: ${colors.whiteOnBlue10}; + } + } + + &&:active { + ${StyledListItemOptionItem} { + background-color: ${colors.whiteOnBlue20}; + } + } + + &&[aria-selected='true'] { + ${StyledListItemOptionItem} { + ${StyledListItemOptionItem} { + background-color: ${colors.green}; + } + } + &&:hover { + ${StyledListItemOptionItem} { + background-color: ${colors.green}; + } + } + &&:active { + ${StyledListItemOptionItem} { + background-color: ${colors.green}; + } + } + } + `; + } + + return null; + }} +`; + +export const ListboxOptionTrigger = ({ children, ...props }: ListboxOptionTriggerProps) => { + const { value } = useListboxOptionContext(); + const { disabled } = useListItemContext(); + const triggerRef = React.useRef<HTMLLIElement>(null); + + const { value: selectedValue, onValueChange } = useListboxContext(); + const selected = value === selectedValue; + + const handleTriggerClick = React.useCallback(async () => { + if (onValueChange) { + await onValueChange(value); + } + }, [onValueChange, value]); + + const handleClick = !disabled ? handleTriggerClick : undefined; + + const handleKeyDown = React.useCallback( + async (event: React.KeyboardEvent) => { + if (disabled) return; + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + if (onValueChange) { + await onValueChange(value); + } + } + }, + [disabled, onValueChange, value], + ); + + return ( + <StyledListItemOptionTrigger + ref={triggerRef} + role="option" + aria-selected={selected} + aria-disabled={disabled} + tabIndex={-1} + onClick={handleClick} + onKeyDown={handleKeyDown} + $disabled={disabled} + {...props}> + {children} + </StyledListItemOptionTrigger> + ); +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/listbox-option-trigger/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/listbox-option-trigger/index.ts new file mode 100644 index 0000000000..8eb2999c36 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/components/listbox-option-trigger/index.ts @@ -0,0 +1 @@ +export * from './ListboxOptionTrigger'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/index.ts new file mode 100644 index 0000000000..00a7794f36 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-option/index.ts @@ -0,0 +1,2 @@ +export * from './ListboxOption'; +export * from './ListboxOptionContext'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/ListboxOptions.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/ListboxOptions.tsx new file mode 100644 index 0000000000..6af21a10cb --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/ListboxOptions.tsx @@ -0,0 +1,65 @@ +import React from 'react'; + +import { useListboxContext } from '../../'; +import { useHandleKeyboardNavigation } from './hooks'; +import { getInitialOption, getOptions } from './utils'; + +export type ListboxOptionsProps = { + children: React.ReactNode[]; +}; + +export function ListboxOptions({ children }: ListboxOptionsProps) { + const { labelId, optionsRef, setFocusedIndex } = useListboxContext(); + const [tabIndex, setTabIndex] = React.useState<number>(0); + + const handleFocus = React.useCallback( + (event: React.FocusEvent) => { + if (!optionsRef.current?.isSameNode(event.target)) return; + + const options = getOptions(optionsRef.current); + + const initialOption = getInitialOption(options); + if (initialOption) { + setTabIndex(-1); + initialOption.focus(); + } + }, + [optionsRef], + ); + + const handleKeyboardNavigation = useHandleKeyboardNavigation(); + + const onKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + handleKeyboardNavigation(event); + }, + [handleKeyboardNavigation], + ); + + const handleBlur = React.useCallback( + (event: React.FocusEvent<HTMLUListElement>) => { + const container = optionsRef.current; + const nextFocus = event.relatedTarget as Node | null; + + // If focus moves outside the listbox + if (!container || !nextFocus || !container.contains(nextFocus)) { + setFocusedIndex(undefined); + setTabIndex(0); + } + }, + [optionsRef, setFocusedIndex], + ); + + return ( + <ul + ref={optionsRef} + role="listbox" + aria-labelledby={labelId} + onKeyDown={onKeyDown} + onBlur={handleBlur} + onFocus={handleFocus} + tabIndex={tabIndex}> + {children} + </ul> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/hooks/index.ts new file mode 100644 index 0000000000..64b446899b --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/hooks/index.ts @@ -0,0 +1 @@ +export * from './useHandleKeyboardNavigation'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/hooks/useFocusOptionByIndex.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/hooks/useFocusOptionByIndex.ts new file mode 100644 index 0000000000..8e75d70912 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/hooks/useFocusOptionByIndex.ts @@ -0,0 +1,17 @@ +import React from 'react'; + +import { useListboxContext } from '../../../ListboxContext'; +import { getOptions } from '../utils'; + +export const useFocusOptionByIndex = () => { + const { setFocusedIndex, optionsRef } = useListboxContext(); + return React.useCallback( + (index: number) => { + const options = getOptions(optionsRef.current); + setFocusedIndex(index); + const option = options[index]; + option.focus(); + }, + [optionsRef, setFocusedIndex], + ); +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/hooks/useGetInitialFocusIndex.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/hooks/useGetInitialFocusIndex.ts new file mode 100644 index 0000000000..43205589cd --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/hooks/useGetInitialFocusIndex.ts @@ -0,0 +1,19 @@ +import React from 'react'; + +import { useListboxContext } from '../../../ListboxContext'; +import { getOptions, getSelectedOptionIndex } from '../utils'; + +export const useGetInitialFocusIndex = () => { + const { focusedIndex, optionsRef } = useListboxContext(); + return React.useCallback(() => { + const options = getOptions(optionsRef.current); + if (focusedIndex !== undefined) { + return focusedIndex; + } + const selectedOptionIndex = getSelectedOptionIndex(options); + if (selectedOptionIndex !== -1) { + return selectedOptionIndex; + } + return 0; + }, [focusedIndex, optionsRef]); +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/hooks/useHandleKeyboardNavigation.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/hooks/useHandleKeyboardNavigation.ts new file mode 100644 index 0000000000..ba7b61bd67 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/hooks/useHandleKeyboardNavigation.ts @@ -0,0 +1,41 @@ +import React from 'react'; + +import { useListboxContext } from '../../../'; +import { getOptions } from '../utils'; +import { useFocusOptionByIndex } from './useFocusOptionByIndex'; +import { useGetInitialFocusIndex } from './useGetInitialFocusIndex'; + +export const useHandleKeyboardNavigation = () => { + const { optionsRef } = useListboxContext(); + const getInitialFocusIndex = useGetInitialFocusIndex(); + const focusOptionByIndex = useFocusOptionByIndex(); + + return React.useCallback( + (event: React.KeyboardEvent) => { + const options = getOptions(optionsRef.current); + + const initialFocusedIndex = getInitialFocusIndex(); + + if (event.key === 'ArrowUp') { + event.preventDefault(); + if (initialFocusedIndex > 0) { + const newFocusedIndex = initialFocusedIndex - 1; + focusOptionByIndex(newFocusedIndex); + } + } else if (event.key === 'ArrowDown') { + event.preventDefault(); + if (initialFocusedIndex < options.length - 1) { + const newFocusedIndex = initialFocusedIndex + 1; + focusOptionByIndex(newFocusedIndex); + } + } else if (event.key === 'Home') { + event.preventDefault(); + focusOptionByIndex(0); + } else if (event.key === 'End') { + event.preventDefault(); + focusOptionByIndex(options.length - 1); + } + }, + [focusOptionByIndex, getInitialFocusIndex, optionsRef], + ); +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/index.ts new file mode 100644 index 0000000000..ba3a33d072 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/index.ts @@ -0,0 +1 @@ +export * from './ListboxOptions'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/get-initial-option.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/get-initial-option.ts new file mode 100644 index 0000000000..e2278b5237 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/get-initial-option.ts @@ -0,0 +1,10 @@ +import { getSelectedOption } from './get-selected-option'; + +export const getInitialOption = (options: HTMLElement[]) => { + const selectedOption = getSelectedOption(options); + if (selectedOption) { + return selectedOption; + } + + return options.length ? options[0] : undefined; +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/get-is-option-selected.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/get-is-option-selected.ts new file mode 100644 index 0000000000..6433e20232 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/get-is-option-selected.ts @@ -0,0 +1,3 @@ +export const getIsOptionSelected = (option: HTMLElement) => { + return option.getAttribute('aria-selected') === 'true'; +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/get-options.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/get-options.ts new file mode 100644 index 0000000000..d37647936c --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/get-options.ts @@ -0,0 +1,11 @@ +export const getOptions = (container: HTMLElement | null) => { + const options = container?.querySelectorAll<HTMLElement>( + '[role="option"]:not([aria-disabled="true"])', + ); + + if (options) { + return Array.from(options); + } + + return []; +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/get-selected-option-index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/get-selected-option-index.ts new file mode 100644 index 0000000000..2534ef46d1 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/get-selected-option-index.ts @@ -0,0 +1,5 @@ +import { getIsOptionSelected } from './get-is-option-selected'; + +export const getSelectedOptionIndex = (options: HTMLElement[]) => { + return options.findIndex((option) => getIsOptionSelected(option)); +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/get-selected-option.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/get-selected-option.ts new file mode 100644 index 0000000000..dfed49aae7 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/get-selected-option.ts @@ -0,0 +1,5 @@ +import { getIsOptionSelected } from './get-is-option-selected'; + +export const getSelectedOption = (options: HTMLElement[]) => { + return options.find((option) => getIsOptionSelected(option)); +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/index.ts new file mode 100644 index 0000000000..cbd0929620 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/components/listbox-options/utils/index.ts @@ -0,0 +1,4 @@ +export * from './get-initial-option'; +export * from './get-options'; +export * from './get-selected-option-index'; +export * from './get-selected-option'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/index.ts new file mode 100644 index 0000000000..94e7f641ab --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/listbox/index.ts @@ -0,0 +1,2 @@ +export * from './Listbox'; +export * from './ListboxContext'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/navigation-header/components/NavigationHeaderTitle.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/navigation-header/components/NavigationHeaderTitle.tsx index 7559416c16..327f957b4a 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/navigation-header/components/NavigationHeaderTitle.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/navigation-header/components/NavigationHeaderTitle.tsx @@ -1,11 +1,9 @@ import styled from 'styled-components'; -import { TitleMedium } from '../../typography'; +import { TitleMedium, TitleMediumProps } from '../../typography'; import { useNavigationHeader } from '../NavigationHeaderContext'; -export interface NavigationHeaderTitleProps { - children: React.ReactNode; -} +export type NavigationHeaderTitleProps = TitleMediumProps; export const StyledText = styled(TitleMedium)<{ $visible?: boolean }>(({ $visible = true }) => ({ opacity: $visible ? 1 : 0, diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/Switch.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/Switch.tsx new file mode 100644 index 0000000000..8fe92377c9 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/Switch.tsx @@ -0,0 +1,39 @@ +import React from 'react'; + +import { SwitchLabel, SwitchThumb, SwitchTrigger } from './components'; +import { SwitchProvider } from './SwitchContext'; + +export interface SwitchProps { + checked?: boolean; + onCheckedChange?: (checked: boolean) => void; + labelId?: string; + disabled?: boolean; + children: React.ReactNode; +} + +function Switch({ + labelId: labelIdProp, + checked, + onCheckedChange, + disabled, + children, +}: SwitchProps) { + const labelId = React.useId(); + return ( + <SwitchProvider + labelId={labelIdProp ?? labelId} + checked={checked} + onCheckedChange={onCheckedChange} + disabled={disabled}> + {children} + </SwitchProvider> + ); +} + +const SwitchNamespace = Object.assign(Switch, { + Label: SwitchLabel, + Thumb: SwitchThumb, + Trigger: SwitchTrigger, +}); + +export { SwitchNamespace as Switch }; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/SwitchContext.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/SwitchContext.tsx new file mode 100644 index 0000000000..78520c0e9a --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/SwitchContext.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +import { SwitchProps } from './Switch'; + +interface SwitchContextProps { + labelId: string; + disabled: SwitchProps['disabled']; + checked?: SwitchProps['checked']; + onCheckedChange?: SwitchProps['onCheckedChange']; +} + +const SwitchContext = React.createContext<SwitchContextProps | undefined>(undefined); + +export const useSwitchContext = (): SwitchContextProps => { + const context = React.useContext(SwitchContext); + if (!context) { + throw new Error('useSwitchContext must be used within a SwitchProvider'); + } + return context; +}; + +interface SwitchProviderProps { + labelId: string; + disabled: SwitchProps['disabled']; + checked?: SwitchProps['checked']; + onCheckedChange?: SwitchProps['onCheckedChange']; + children: React.ReactNode; +} + +export function SwitchProvider({ children, ...props }: SwitchProviderProps) { + return <SwitchContext.Provider value={props}>{children}</SwitchContext.Provider>; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/components/index.ts new file mode 100644 index 0000000000..8d691b187b --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/components/index.ts @@ -0,0 +1,3 @@ +export * from './switch-thumb'; +export * from './switch-trigger'; +export * from './switch-label'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/components/switch-label/SwitchLabel.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/components/switch-label/SwitchLabel.tsx new file mode 100644 index 0000000000..2a2481863a --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/components/switch-label/SwitchLabel.tsx @@ -0,0 +1,14 @@ +import { Text, TextProps } from '../../../typography'; +import { useSwitchContext } from '../../'; + +export type SwitchLabelProps = TextProps; + +export function SwitchLabel({ children, ...props }: SwitchLabelProps) { + const { labelId, disabled } = useSwitchContext(); + + return ( + <Text id={labelId} color={disabled ? 'whiteAlpha40' : 'white'} {...props}> + {children} + </Text> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/components/switch-label/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/components/switch-label/index.ts new file mode 100644 index 0000000000..fec6b853a8 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/components/switch-label/index.ts @@ -0,0 +1 @@ +export * from './SwitchLabel'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/components/switch-thumb/SwitchThumb.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/components/switch-thumb/SwitchThumb.tsx new file mode 100644 index 0000000000..2a7cbb075e --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/components/switch-thumb/SwitchThumb.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import styled, { css } from 'styled-components'; + +import { colors } from '../../../../foundations'; +import { Dot } from '../../../dot'; +import { useSwitchContext } from '../../'; +import { useBackgroundColor, useBorderColor } from './hooks'; + +export type SwitchThumbProps = React.HtmlHTMLAttributes<HTMLDivElement>; + +const StyledSwitchThumbIndicator = styled(Dot)<{ + $checked?: boolean; + $backgroundColor?: string; +}>` + ${({ $checked, $backgroundColor }) => { + return css` + position: absolute; + left: 2px; + background-color: ${$backgroundColor}; + transform: translateX(${$checked ? '11px' : '1px'}); + transition: + width 150ms ease, + height 150ms ease, + transform 150ms ease, + background-color 100ms linear; + `; + }} +`; + +const StyledSwitchThumbTrack = styled.div<{ $borderColor: string }>` + ${({ $borderColor }) => { + return css` + position: relative; + display: flex; + align-items: center; + width: 32px; + height: 20px; + border: 2px solid ${$borderColor}; + border-radius: 100px; + transition: border-color 200ms ease; + + &:focus-visible { + outline: 2px solid ${colors.white}; + outline-offset: 2px; + } + `; + }} +`; + +export function SwitchThumb(props: SwitchThumbProps) { + const { checked } = useSwitchContext(); + const backgroundColor = useBackgroundColor(); + const borderColor = useBorderColor(); + return ( + <StyledSwitchThumbTrack $borderColor={colors[borderColor]} {...props}> + <StyledSwitchThumbIndicator $checked={checked} $backgroundColor={colors[backgroundColor]} /> + </StyledSwitchThumbTrack> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/components/switch-thumb/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/components/switch-thumb/hooks/index.ts new file mode 100644 index 0000000000..56a7f81dd4 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/components/switch-thumb/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useBackgroundColor'; +export * from './useBorderColor'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/components/switch-thumb/hooks/useBackgroundColor.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/components/switch-thumb/hooks/useBackgroundColor.ts new file mode 100644 index 0000000000..38c6eba50e --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/components/switch-thumb/hooks/useBackgroundColor.ts @@ -0,0 +1,12 @@ +import { Colors } from '../../../../../foundations'; +import { useSwitchContext } from '../../../SwitchContext'; + +export const useBackgroundColor = (): Colors => { + const { disabled, checked } = useSwitchContext(); + if (disabled) { + if (checked) return 'green40'; + else return 'red40'; + } + if (checked) return 'green'; + else return 'red'; +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/components/switch-thumb/hooks/useBorderColor.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/components/switch-thumb/hooks/useBorderColor.ts new file mode 100644 index 0000000000..51ee5d53f5 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/components/switch-thumb/hooks/useBorderColor.ts @@ -0,0 +1,8 @@ +import { Colors } from '../../../../../foundations'; +import { useSwitchContext } from '../../../SwitchContext'; + +export const useBorderColor = (): Colors => { + const { disabled } = useSwitchContext(); + if (disabled) return 'whiteAlpha20'; + return 'whiteAlpha80'; +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/components/switch-thumb/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/components/switch-thumb/index.ts new file mode 100644 index 0000000000..4f3b2c8b31 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/components/switch-thumb/index.ts @@ -0,0 +1 @@ +export * from './SwitchThumb'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/components/switch-trigger/SwitchTrigger.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/components/switch-trigger/SwitchTrigger.tsx new file mode 100644 index 0000000000..a14883e4f6 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/components/switch-trigger/SwitchTrigger.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import styled from 'styled-components'; + +import { colors } from '../../../../foundations'; +import { useSwitchContext } from '../../'; + +export type SwitchTriggerProps = React.ComponentPropsWithRef<'button'>; + +export const StyledSwitchTrigger = styled.button<{ $checked?: boolean }>` + background-color: transparent; + width: fit-content; + + &&:focus-visible { + outline: 2px solid ${colors.white}; + outline-offset: -1px; + } +`; + +export function SwitchTrigger(props: SwitchTriggerProps) { + const { labelId, checked, disabled, onCheckedChange } = useSwitchContext(); + const handleClick = React.useCallback(() => { + if (onCheckedChange) { + onCheckedChange(!checked); + } + }, [checked, onCheckedChange]); + + return ( + <StyledSwitchTrigger + onClick={handleClick} + disabled={disabled} + role="switch" + aria-checked={checked ? 'true' : 'false'} + aria-labelledby={labelId} + {...props} + /> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/components/switch-trigger/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/components/switch-trigger/index.ts new file mode 100644 index 0000000000..a32b52020b --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/components/switch-trigger/index.ts @@ -0,0 +1 @@ +export * from './SwitchTrigger'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/index.ts new file mode 100644 index 0000000000..48400ce7ec --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/switch/index.ts @@ -0,0 +1,2 @@ +export * from './Switch'; +export * from './SwitchContext'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/TextField.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/TextField.tsx new file mode 100644 index 0000000000..2e531a7766 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/TextField.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import { FlexColumn } from '../flex-column'; +import { TextFieldInput, TextFieldLabel } from './components'; +import { TextFieldProvider } from './TextFieldContext'; + +export type TextFieldProps = React.PropsWithChildren<{ + invalid?: boolean; + value?: string; + disabled?: boolean; +}>; + +function TextField({ children, ...props }: TextFieldProps) { + const labelId = React.useId(); + return ( + <TextFieldProvider labelId={labelId} {...props}> + <FlexColumn $gap="tiny">{children}</FlexColumn> + </TextFieldProvider> + ); +} + +const TextFieldNamespace = Object.assign(TextField, { + Input: TextFieldInput, + Label: TextFieldLabel, +}); + +export { TextFieldNamespace as TextField }; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/TextFieldContext.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/TextFieldContext.tsx new file mode 100644 index 0000000000..aa58c69b34 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/TextFieldContext.tsx @@ -0,0 +1,25 @@ +import { createContext, ReactNode, useContext } from 'react'; + +import { TextFieldProps } from './TextField'; + +type TextFieldContextType = TextFieldProps & { + labelId: string; +}; + +const TextFieldContext = createContext<TextFieldContextType | undefined>(undefined); + +type TextFieldProviderProps = TextFieldContextType & { + children: ReactNode; +}; + +export const TextFieldProvider = ({ children, ...props }: TextFieldProviderProps) => { + return <TextFieldContext.Provider value={props}>{children}</TextFieldContext.Provider>; +}; + +export const useTextFieldContext = (): TextFieldContextType => { + const context = useContext(TextFieldContext); + if (!context) { + throw new Error('useTextField must be used within a TextFieldProvider'); + } + return context; +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/index.ts new file mode 100644 index 0000000000..411fe35816 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/index.ts @@ -0,0 +1,2 @@ +export * from './text-field-input'; +export * from './text-field-label'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/text-field-input/TextFieldInput.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/text-field-input/TextFieldInput.tsx new file mode 100644 index 0000000000..e2e15bb541 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/text-field-input/TextFieldInput.tsx @@ -0,0 +1,52 @@ +import styled, { css } from 'styled-components'; + +import { colors, Radius, spacings } from '../../../../foundations'; +import { useTextFieldContext } from '../../'; + +export type TextFieldInputProps = React.ComponentPropsWithRef<'input'>; + +export const StyledTextField = styled.input<{ $disabled?: boolean; $invalid?: boolean }>` + ${({ $invalid, $disabled }) => { + const borderColor = $invalid ? colors.newRed : colors.chalkAlpha40; + const backgroundColor = $disabled ? colors.whiteOnDarkBlue5 : colors.blue40; + const color = $disabled ? colors.whiteAlpha20 : colors.white; + return css` + all: unset; + font-family: var(--font-family-open-sans); + background-color: ${backgroundColor}; + padding: ${spacings.small}; + border: 1px solid ${colors.whiteAlpha60}; + border-color: ${borderColor}; + font-size: 14px; + border-radius: ${Radius.radius4}; + color: ${color}; + width: 100%; + + &&::placeholder { + color: ${colors.whiteAlpha60}; + } + + &&:not(:disabled):not([aria-invalid='true']):hover { + border-color: ${colors.chalkAlpha80}; + } + &&:not(:disabled):not([aria-invalid='true']):focus-visible { + border-color: ${colors.chalk}; + } + `; + }} +`; + +export function TextFieldInput(props: TextFieldInputProps) { + const { disabled, invalid } = useTextFieldContext(); + + return ( + <StyledTextField + type="text" + $disabled={disabled} + disabled={disabled} + $invalid={invalid} + aria-invalid={invalid} + {...props} + /> + ); +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/text-field-input/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/text-field-input/index.ts new file mode 100644 index 0000000000..80197f01a4 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/text-field-input/index.ts @@ -0,0 +1 @@ +export * from './TextFieldInput'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/text-field-label/TextFieldLabel.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/text-field-label/TextFieldLabel.tsx new file mode 100644 index 0000000000..dad212cf43 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/text-field-label/TextFieldLabel.tsx @@ -0,0 +1,9 @@ +import { Text, TextProps } from '../../../typography'; +import { useTextFieldContext } from '../../'; + +export type TextFieldLabelProps = TextProps; + +export const TextFieldLabel = (props: TextFieldLabelProps) => { + const { labelId } = useTextFieldContext(); + return <Text id={labelId} variant="labelTiny" {...props} />; +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/text-field-label/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/text-field-label/index.ts new file mode 100644 index 0000000000..e8e12f5750 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/components/text-field-label/index.ts @@ -0,0 +1 @@ +export * from './TextFieldLabel'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/hooks/index.ts new file mode 100644 index 0000000000..106172db8b --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-text-field'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/hooks/use-text-field/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/hooks/use-text-field/index.ts new file mode 100644 index 0000000000..106172db8b --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/hooks/use-text-field/index.ts @@ -0,0 +1 @@ +export * from './use-text-field'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/hooks/use-text-field/use-text-field.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/hooks/use-text-field/use-text-field.ts new file mode 100644 index 0000000000..f6a99682cd --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/hooks/use-text-field/use-text-field.ts @@ -0,0 +1,59 @@ +import React from 'react'; + +export type UseTextFieldProps = { + inputRef: React.RefObject<HTMLInputElement | null>; + defaultValue?: string; + validate?: (value: string) => boolean; + format?: (value: string) => string; +}; + +export type UseTextFieldState = { + value: string; + invalid: boolean; + dirty: boolean; + reset: () => void; + focus: () => void; + blur: () => void; + handleChange: (event: React.ChangeEvent<HTMLInputElement>) => void; + inputRef: React.RefObject<HTMLInputElement | null>; +}; + +export function useTextField({ + inputRef, + defaultValue, + format, + validate, +}: UseTextFieldProps): UseTextFieldState { + const [value, setValue] = React.useState(defaultValue ?? ''); + const [invalid, setInvalid] = React.useState(validate ? !validate(value) : false); + const [dirty, setDirty] = React.useState(false); + + const reset = React.useCallback(() => { + setValue(defaultValue ?? ''); + setInvalid(false); + setDirty(false); + }, [defaultValue]); + + const focus = React.useCallback(() => { + inputRef.current?.focus(); + }, [inputRef]); + + const blur = React.useCallback(() => { + inputRef.current?.blur(); + }, [inputRef]); + + const handleChange = React.useCallback( + (event: React.ChangeEvent<HTMLInputElement>) => { + const newValue = event.target.value; + const formattedValue = format ? format(newValue) : newValue; + const invalid = validate ? !validate(formattedValue) : false; + setInvalid(invalid); + setValue(formattedValue); + setDirty(true); + }, + + [format, validate], + ); + + return { value, invalid, dirty, reset, blur, focus, handleChange, inputRef }; +} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/index.ts new file mode 100644 index 0000000000..a1efba7306 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/text-field/index.ts @@ -0,0 +1,3 @@ +export * from './TextField'; +export * from './TextFieldContext'; +export * from './hooks'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/foundations/tokens/color-tokens.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/foundations/tokens/color-tokens.ts index 72833f427a..ef72191c54 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/foundations/tokens/color-tokens.ts +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/foundations/tokens/color-tokens.ts @@ -9,6 +9,7 @@ export const colorTokens = { blackAlpha50: 'rgba(0, 0, 0, 0.5)', red: 'rgb(227, 64, 57)', + newRed: 'rgb(235, 93, 64)', redAlpha40: 'rgba(227, 64, 57, 0.4)', red80: 'rgb(187, 60, 59)', red40: 'rgb(106, 53, 64)', diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/foundations/variables/color-variables.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/foundations/variables/color-variables.ts index 15279631a2..acd44be1cd 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/foundations/variables/color-variables.ts +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/foundations/variables/color-variables.ts @@ -11,6 +11,7 @@ export const colorPrimitives = { '--color-black-alpha50': colorTokens.blackAlpha50, '--color-red': colorTokens.red, + '--color-new-red': colorTokens.newRed, '--color-red-alpha40': colorTokens.redAlpha40, '--color-red80': colorTokens.red80, '--color-red40': colorTokens.red40, @@ -75,6 +76,7 @@ export const colors: Record<keyof typeof colorTokens, `var(${keyof typeof colorP blackAlpha50: 'var(--color-black-alpha50)', red: 'var(--color-red)', + newRed: 'var(--color-new-red)', redAlpha40: 'var(--color-red-alpha40)', red80: 'var(--color-red80)', red40: 'var(--color-red40)', diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/history.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/history.tsx index bd2cd15883..f661a5f191 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/history.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/history.tsx @@ -138,8 +138,14 @@ export default class History { public block(): never { throw Error('Not implemented'); } - public replace(): never { - throw Error('Not implemented'); + + public replace( + replacementLocation: LocationDescriptor, + replacementState?: Partial<LocationState>, + ) { + const location = this.createLocation(replacementLocation, replacementState); + this.lastAction = 'REPLACE'; + this.entries.splice(this.index, 1, location); } public go(): never { throw Error('Not implemented'); diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/types/polymorphic-props.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/types/polymorphic-props.ts index d0b9895d35..8b55bcdfdc 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/types/polymorphic-props.ts +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/types/polymorphic-props.ts @@ -1,4 +1,4 @@ export type PolymorphicProps<E extends React.ElementType, Props = object> = Props & - Omit<React.ComponentPropsWithoutRef<E>, keyof Props> & { + Omit<React.ComponentPropsWithRef<E>, keyof Props> & { as?: E; }; diff --git a/desktop/packages/mullvad-vpn/src/shared/ipc-types.ts b/desktop/packages/mullvad-vpn/src/shared/ipc-types.ts index c3fe8ef2b0..bf8fa6996e 100644 --- a/desktop/packages/mullvad-vpn/src/shared/ipc-types.ts +++ b/desktop/packages/mullvad-vpn/src/shared/ipc-types.ts @@ -17,7 +17,27 @@ export type SuppressOutdatedVersionOption = { type: 'suppress-outdated-version-warning'; }; -export type LocationStateOptions = SuppressOutdatedVersionOption; +export type ScrollToAnchorId = + | 'daita-enable-setting' + | 'multihop-setting' + | 'custom-dns-settings' + | 'allow-lan-setting' + | 'lockdown-mode-setting' + | 'dns-blocker-setting' + | 'mtu-setting' + | 'obfuscation-setting' + | 'port-setting' + | 'bridge-mode-setting' + | 'mss-fix-setting' + | 'quantum-resistant-setting' + | 'tunnel-protocol-setting'; + +export type ScrollToAnchorOption = { + type: 'scroll-to-anchor'; + id: ScrollToAnchorId; +}; + +export type LocationStateOptions = SuppressOutdatedVersionOption | ScrollToAnchorOption; export type IChangelog = Array<string>; diff --git a/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/custom-bridge.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/custom-bridge.spec.ts index 9be24d838b..53f66ccf98 100644 --- a/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/custom-bridge.spec.ts +++ b/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/custom-bridge.spec.ts @@ -36,7 +36,8 @@ test('App should enable bridge mode', async () => { await page.getByText('OpenVPN settings').click(); await util.waitForRoute(RoutePath.openVpnSettings); - const bridgeModeOnButton = page.getByTestId('bridge-mode-on'); + const bridgeModeListox = page.getByRole('listbox', { name: 'Bridge mode' }); + const bridgeModeOnButton = bridgeModeListox.getByRole('option', { name: 'On', exact: true }); await bridgeModeOnButton.click(); await expect(bridgeModeOnButton).toHaveAttribute('aria-selected', 'true'); diff --git a/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/obfuscation.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/obfuscation.spec.ts index d1d3cb2e01..4bea6d34e0 100644 --- a/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/obfuscation.spec.ts +++ b/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/obfuscation.spec.ts @@ -35,8 +35,13 @@ test('App should have automatic obfuscation', async () => { await page.getByText('WireGuard settings').click(); await util.waitForRoute(RoutePath.wireguardSettings); - const automatic = page.getByTestId('automatic-obfuscation'); - await expect(automatic).toHaveCSS('background-color', colorTokens.green); + const obfuscationListbox = page.getByRole('listbox', { name: 'Obfuscation' }); + await obfuscationListbox.highlight(); + const automaticOption = obfuscationListbox.getByRole('option', { + name: 'Automatic', + exact: true, + }); + await expect(automaticOption).toHaveAttribute('aria-selected', 'true'); const cliObfuscation = execSync('mullvad obfuscation get').toString().split('\n'); expect(cliObfuscation[0]).toEqual('Obfuscation mode: auto'); diff --git a/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/vpn-settings/vpn-settings.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/vpn-settings/vpn-settings.spec.ts index 0e5c6dd349..767a2c8ea4 100644 --- a/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/vpn-settings/vpn-settings.spec.ts +++ b/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/vpn-settings/vpn-settings.spec.ts @@ -30,6 +30,11 @@ test.describe('VPN settings', () => { await page.close(); }); + test('Should focus header heading on load', async () => { + const heading = routes.vpnSettings.selectors.heading(); + await expect(heading).toBeFocused(); + }); + test.describe('Launch on startup and auto-connect', () => { test.afterEach(async () => { await routes.vpnSettings.setAutoConnectSwitch(false); diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/feature-indicators.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/feature-indicators.spec.ts deleted file mode 100644 index 9145068c58..0000000000 --- a/desktop/packages/mullvad-vpn/test/e2e/mocked/feature-indicators.spec.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { Page } from 'playwright'; - -import { FeatureIndicator, ILocation, ITunnelEndpoint } from '../../../src/shared/daemon-rpc-types'; -import { RoutePath } from '../../../src/shared/routes'; -import { expectConnected } from '../shared/tunnel-state'; -import { MockedTestUtils, startMockedApp } from './mocked-utils'; - -const endpoint: ITunnelEndpoint = { - address: 'wg10:80', - protocol: 'tcp', - quantumResistant: false, - tunnelType: 'wireguard', - daita: false, -}; - -const mockDisconnectedLocation: ILocation = { - country: 'Sweden', - city: 'Gothenburg', - latitude: 58, - longitude: 12, - mullvadExitIp: false, -}; - -const mockConnectedLocation: ILocation = { ...mockDisconnectedLocation, mullvadExitIp: true }; - -let page: Page; -let util: MockedTestUtils; - -test.beforeAll(async () => { - ({ page, util } = await startMockedApp()); - await util.waitForRoute(RoutePath.main); -}); - -test.afterAll(async () => { - await page.close(); -}); - -test('App should show no feature indicators', async () => { - await util.ipc.tunnel[''].notify({ - state: 'connected', - details: { endpoint, location: mockConnectedLocation }, - featureIndicators: undefined, - }); - - await expectConnected(page); - await expectFeatureIndicators(page, []); - - const ellipsis = page.getByText(/^\d more.../); - await expect(ellipsis).not.toBeVisible(); - - await page.getByTestId('connection-panel-chevron').click(); - await expect(ellipsis).not.toBeVisible(); - - await expectFeatureIndicators(page, []); - await page.getByTestId('connection-panel-chevron').click(); -}); - -test('App should show feature indicators', async () => { - await util.ipc.tunnel[''].notify({ - state: 'connected', - details: { endpoint, location: mockConnectedLocation }, - featureIndicators: [ - FeatureIndicator.daita, - FeatureIndicator.udp2tcp, - FeatureIndicator.customMssFix, - FeatureIndicator.customMtu, - FeatureIndicator.lanSharing, - FeatureIndicator.serverIpOverride, - FeatureIndicator.customDns, - FeatureIndicator.lockdownMode, - FeatureIndicator.quantumResistance, - FeatureIndicator.multihop, - ], - }); - - // Make sure panel is collapsed before checking indicator visibility. - const ellipsis = page.getByText(/^\d more.../); - await expect(ellipsis).toBeVisible(); - - await expectConnected(page); - await expectFeatureIndicators(page, ['DAITA', 'Quantum resistance'], false); - await expectHiddenFeatureIndicator(page, 'Mssfix'); - - await page.getByTestId('connection-panel-chevron').click(); - await expect(ellipsis).not.toBeVisible(); - - await expectFeatureIndicators(page, [ - 'DAITA', - 'Quantum resistance', - 'Mssfix', - 'MTU', - 'Obfuscation', - 'Local network sharing', - 'Lockdown mode', - 'Multihop', - 'Custom DNS', - 'Server IP override', - ]); -}); - -async function expectHiddenFeatureIndicator(page: Page, hiddenIndicator: string) { - const indicators = page.getByTestId('feature-indicator'); - const indicator = indicators.getByText(hiddenIndicator, { exact: true }); - - // Make sure at least one is visible to not run the "not visible" check before they become - // visible. - await expect(indicators.first()).toBeVisible(); - - await expect(indicator).toHaveCount(1); - await expect(indicator).not.toBeVisible(); -} - -async function expectFeatureIndicators(page: Page, expectedIndicators: Array<string>, only = true) { - const indicators = page.getByTestId('feature-indicator'); - if (only) { - await expect(indicators).toHaveCount(expectedIndicators.length); - } - - for (const indicator of expectedIndicators) { - await expect(indicators.getByText(indicator, { exact: true })).toBeVisible(); - } -} diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/feature-indicators/feature-indicators.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/feature-indicators/feature-indicators.spec.ts new file mode 100644 index 0000000000..98b43bcf7f --- /dev/null +++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/feature-indicators/feature-indicators.spec.ts @@ -0,0 +1,279 @@ +import { expect, Locator, test } from '@playwright/test'; +import { Page } from 'playwright'; + +import { FeatureIndicator } from '../../../../src/shared/daemon-rpc-types'; +import { RoutePath } from '../../../../src/shared/routes'; +import { RoutesObjectModel } from '../../route-object-models'; +import { MockedTestUtils, startMockedApp } from '../mocked-utils'; +import { createHelpers, FeatureIndicatorsHelpers } from './helpers'; + +let page: Page; +let util: MockedTestUtils; +let routes: RoutesObjectModel; +let helpers: FeatureIndicatorsHelpers; + +type FeatureIndicatorTestOption = { + testId: string; + featureIndicator: FeatureIndicator; + featureIndicatorLabel: string; + route: RoutePath; +}; + +type FeatureIndicatorWithOptionTestOption = FeatureIndicatorTestOption & { + option: { + name?: string; + type?: 'switch' | 'listbox' | 'accordion' | 'input'; + }; +}; + +const featureIndicatorWithoutOption: FeatureIndicatorTestOption[] = [ + { + testId: 'DAITA multihop', + featureIndicator: FeatureIndicator.daitaMultihop, + route: RoutePath.daitaSettings, + featureIndicatorLabel: 'DAITA: Multihop', + }, + { + testId: 'split tunneling', + featureIndicator: FeatureIndicator.splitTunneling, + route: RoutePath.splitTunneling, + featureIndicatorLabel: 'Split tunneling', + }, + { + testId: 'server ip override', + featureIndicator: FeatureIndicator.serverIpOverride, + route: RoutePath.settingsImport, + featureIndicatorLabel: 'Server ip override', + }, +]; + +const featureIndicatorWithOption: FeatureIndicatorWithOptionTestOption[] = [ + { + testId: 'DAITA', + featureIndicator: FeatureIndicator.daita, + route: RoutePath.daitaSettings, + featureIndicatorLabel: 'DAITA', + option: { name: 'Enable', type: 'switch' }, + }, + { + testId: 'UDP over TCP', + featureIndicator: FeatureIndicator.udp2tcp, + route: RoutePath.wireguardSettings, + featureIndicatorLabel: 'Obfuscation', + option: { name: 'Obfuscation', type: 'listbox' }, + }, + { + testId: 'shadowsocks', + featureIndicator: FeatureIndicator.shadowsocks, + route: RoutePath.wireguardSettings, + featureIndicatorLabel: 'Obfuscation', + option: { name: 'Obfuscation', type: 'listbox' }, + }, + { + testId: 'QUIC', + featureIndicator: FeatureIndicator.quic, + route: RoutePath.wireguardSettings, + featureIndicatorLabel: 'Obfuscation', + option: { name: 'Obfuscation', type: 'listbox' }, + }, + { + testId: 'multihop', + featureIndicator: FeatureIndicator.multihop, + route: RoutePath.multihopSettings, + featureIndicatorLabel: 'Multihop', + option: { name: 'Enable', type: 'switch' }, + }, + { + testId: 'custom dns', + featureIndicator: FeatureIndicator.customDns, + route: RoutePath.vpnSettings, + featureIndicatorLabel: 'Custom DNS', + option: { name: 'Use custom DNS server', type: 'switch' }, + }, + { + testId: 'MTU', + featureIndicator: FeatureIndicator.customMtu, + route: RoutePath.wireguardSettings, + featureIndicatorLabel: 'MTU', + option: { name: 'MTU', type: 'input' }, + }, + { + testId: 'bridge mode', + featureIndicator: FeatureIndicator.bridgeMode, + route: RoutePath.openVpnSettings, + featureIndicatorLabel: 'Bridge mode', + option: { name: 'Bridge mode', type: 'listbox' }, + }, + { + testId: 'local network sharing', + featureIndicator: FeatureIndicator.lanSharing, + route: RoutePath.vpnSettings, + featureIndicatorLabel: 'Local network sharing', + option: { name: 'Local network sharing', type: 'switch' }, + }, + { + testId: 'Mssfix', + featureIndicator: FeatureIndicator.customMssFix, + route: RoutePath.openVpnSettings, + featureIndicatorLabel: 'Mssfix', + option: { name: 'Mssfix', type: 'input' }, + }, + { + testId: 'lockdown mode', + featureIndicator: FeatureIndicator.lockdownMode, + route: RoutePath.vpnSettings, + featureIndicatorLabel: 'Lockdown mode', + option: { name: 'Lockdown mode', type: 'switch' }, + }, + { + testId: 'quantum resistance', + featureIndicator: FeatureIndicator.quantumResistance, + route: RoutePath.wireguardSettings, + featureIndicatorLabel: 'Quantum resistance', + option: { name: 'Quantum-resistant tunnel', type: 'listbox' }, + }, + { + testId: 'dns content blockers', + featureIndicator: FeatureIndicator.dnsContentBlockers, + route: RoutePath.vpnSettings, + featureIndicatorLabel: 'DNS content blockers', + option: { name: 'DNS content blockers', type: 'accordion' }, + }, +]; + +test.describe('Feature indicators', () => { + test.beforeAll(async () => { + ({ page, util } = await startMockedApp()); + routes = new RoutesObjectModel(page, util); + helpers = createHelpers({ page, routes, utils: util }); + + await util.waitForRoute(RoutePath.main); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test.afterEach(async () => { + await helpers.disconnect(); + await routes.wireguardSettings.gotoRoot(); + await util.waitForRoute(RoutePath.main); + }); + + async function expectFeatureIndicators(expectedIndicators: Array<string>, only = true) { + const indicators = routes.main.selectors.featureIndicators(); + if (only) { + await expect(indicators).toHaveCount(expectedIndicators.length); + } + + for (const indicator of expectedIndicators) { + await expect(routes.main.selectors.featureIndicator(indicator)).toBeVisible(); + } + } + + test('Should show no feature indicators when disconnected', async () => { + await expectFeatureIndicators([]); + await helpers.connectWithFeatures(undefined); + + await expectFeatureIndicators([]); + + const ellipsis = routes.main.selectors.moreFeatureIndicator(); + await expect(ellipsis).not.toBeVisible(); + + await page.getByTestId('connection-panel-chevron').click(); + await expect(ellipsis).not.toBeVisible(); + + await expectFeatureIndicators([]); + await page.getByTestId('connection-panel-chevron').click(); + }); + + test('Should show no feature indicators when connected with no active features', async () => { + await helpers.connectWithFeatures(undefined); + await expectFeatureIndicators([]); + + const ellipsis = routes.main.selectors.moreFeatureIndicator(); + await expect(ellipsis).not.toBeVisible(); + }); + + test('Should show feature indicators when connected with active features', async () => { + await helpers.connectWithFeatures([FeatureIndicator.daita, FeatureIndicator.quantumResistance]); + await expectFeatureIndicators(['DAITA', 'Quantum Resistance']); + }); + + test('Should show a subset of feature indicators when connected with many active features', async () => { + await helpers.connectWithFeatures([ + FeatureIndicator.daita, + FeatureIndicator.udp2tcp, + FeatureIndicator.customMssFix, + FeatureIndicator.customMtu, + FeatureIndicator.lanSharing, + FeatureIndicator.serverIpOverride, + FeatureIndicator.customDns, + FeatureIndicator.lockdownMode, + FeatureIndicator.quantumResistance, + FeatureIndicator.multihop, + ]); + + const ellipsis = routes.main.selectors.moreFeatureIndicator(); + await expect(ellipsis).toBeVisible(); + + await ellipsis.click(); + + await expectFeatureIndicators([ + 'DAITA', + 'Quantum resistance', + 'Mssfix', + 'MTU', + 'Obfuscation', + 'Local network sharing', + 'Lockdown mode', + 'Multihop', + 'Custom DNS', + 'Server IP override', + ]); + }); + + const clickFeatureIndicator = async (featureIndicatorLabel: string, route: RoutePath) => { + const indicator = routes.main.selectors.featureIndicator(featureIndicatorLabel); + await expect(indicator).toBeVisible(); + await indicator.click(); + await util.waitForRoute(route); + }; + + featureIndicatorWithoutOption.forEach( + ({ testId, featureIndicator, route, featureIndicatorLabel }) => { + test(`Should navigate to setting when clicking on ${testId} feature indicator`, async () => { + await helpers.connectWithFeatures([featureIndicator]); + await clickFeatureIndicator(featureIndicatorLabel, route); + + const currentRoute = await util.currentRoute(); + expect(currentRoute).toBe(route); + }); + }, + ); + + featureIndicatorWithOption.forEach( + ({ testId, featureIndicator, route, featureIndicatorLabel, option }) => { + test(`Should navigate to setting when clicking on ${testId} feature indicator`, async () => { + await helpers.connectWithFeatures([featureIndicator]); + await clickFeatureIndicator(featureIndicatorLabel, route); + + const { name, type } = option; + let element: Locator | undefined = undefined; + if (type === 'accordion') { + element = page.getByRole('button', { name }); + } else if (type === 'listbox') { + element = page.getByRole('listbox', { name }); + } else if (type === 'input') { + element = page.getByRole('textbox', { name }); + } else { + element = page.getByRole('switch', { name }); + } + await expect(element).toBeInViewport(); + + const currentRoute = await util.currentRoute(); + expect(currentRoute).toBe(route); + }); + }, + ); +}); diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/feature-indicators/helpers.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/feature-indicators/helpers.ts new file mode 100644 index 0000000000..2b7b9e3762 --- /dev/null +++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/feature-indicators/helpers.ts @@ -0,0 +1,53 @@ +import { Page } from 'playwright'; + +import { + FeatureIndicator, + ILocation, + ITunnelEndpoint, +} from '../../../../src/shared/daemon-rpc-types'; +import { RoutesObjectModel } from '../../route-object-models'; +import { MockedTestUtils } from '../mocked-utils'; + +const endpoint: ITunnelEndpoint = { + address: 'wg10:80', + protocol: 'tcp', + quantumResistant: false, + tunnelType: 'wireguard', + daita: false, +}; + +const mockDisconnectedLocation: ILocation = { + country: 'Sweden', + city: 'Gothenburg', + latitude: 58, + longitude: 12, + mullvadExitIp: false, +}; + +const mockConnectedLocation: ILocation = { ...mockDisconnectedLocation, mullvadExitIp: true }; + +export const createHelpers = ({ + utils, +}: { + page: Page; + routes: RoutesObjectModel; + utils: MockedTestUtils; +}) => { + const connectWithFeatures = async (featureIndicators: FeatureIndicator[] | undefined) => { + await utils.ipc.tunnel[''].notify({ + state: 'connected', + details: { endpoint, location: mockConnectedLocation }, + featureIndicators: featureIndicators, + }); + }; + + const disconnect = () => + utils.ipc.tunnel[''].notify({ + state: 'disconnected', + lockedDown: false, + }); + + return { connectWithFeatures, disconnect }; +}; + +export type FeatureIndicatorsHelpers = ReturnType<typeof createHelpers>; diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/select-location/select-location.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/select-location/select-location.spec.ts index 02a1a403bc..e15c73efa4 100644 --- a/desktop/packages/mullvad-vpn/test/e2e/mocked/select-location/select-location.spec.ts +++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/select-location/select-location.spec.ts @@ -36,6 +36,11 @@ test.describe('Select location', () => { await page.close(); }); + test('Should focus search input on load', async () => { + const input = routes.selectLocation.getSearchInput(); + await expect(input).toBeFocused(); + }); + test.describe('Multihop enabled', () => { test.beforeAll(async () => { await helpers.updateMockSettings({ diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/main/selectors.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/main/selectors.ts index 0e3cbaaa3b..8a0d96ac18 100644 --- a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/main/selectors.ts +++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/main/selectors.ts @@ -5,4 +5,8 @@ export const createSelectors = (page: Page) => ({ selectLocationButton: () => page.getByLabel('Select location'), connectionPanelChevronButton: () => page.getByTestId('connection-panel-chevron'), inIpLabel: () => page.getByTestId('in-ip'), + featureIndicators: () => page.getByTestId('feature-indicator'), + featureIndicator: (name: string) => + page.getByTestId('feature-indicator').filter({ hasText: name }), + moreFeatureIndicator: () => page.getByText(/^\d more.../), }); diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/select-location/select-location-route-object-model.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/select-location/select-location-route-object-model.ts index 7d29b03700..a9881d3a24 100644 --- a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/select-location/select-location-route-object-model.ts +++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/select-location/select-location-route-object-model.ts @@ -28,6 +28,10 @@ export class SelectLocationRouteObjectModel { return this.selectors.exitButton(); } + getSearchInput() { + return this.selectors.searchInput(); + } + getRelaysMatching(relayNames: string[]) { return this.selectors.relaysMatching(relayNames); } diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/select-location/selectors.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/select-location/selectors.ts index 689401ad63..fa52e2a184 100644 --- a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/select-location/selectors.ts +++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/select-location/selectors.ts @@ -10,4 +10,5 @@ export const createSelectors = (page: Page) => ({ expandAccordionButton: (label: string) => page.getByLabel(`Expand ${label}`), relaysMatching: (relayNames: string[]) => page.getByRole('button', { name: new RegExp(relayNames.join('|')) }), + searchInput: () => page.getByPlaceholder('Search for...'), }); diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/vpn-settings/selectors.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/vpn-settings/selectors.ts index 367f84e38d..607756d76f 100644 --- a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/vpn-settings/selectors.ts +++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/vpn-settings/selectors.ts @@ -1,6 +1,7 @@ import { Page } from 'playwright'; export const createSelectors = (page: Page) => ({ + heading: () => page.getByRole('heading', { name: 'VPN settings' }), launchAppOnStartupSwitch: () => page.getByLabel('Launch app on start-up'), autoConnectSwitch: () => page.getByLabel('Auto-connect'), lanSwitch: () => page.getByLabel('Local network sharing'), |
