diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2022-07-22 14:36:26 +0200 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2022-07-22 14:36:26 +0200 |
| commit | baa4802b2e31b55133b3e8fc4ad5058a8ffd0496 (patch) | |
| tree | 3f7cbdae6b87d28594fdf280ed963fcdfdd35105 /gui/src | |
| parent | d79cb0d9744d675b1815729269862c5a44f043d4 (diff) | |
| parent | 250f169d093d3d7e39255f2527b1225668b4b04a (diff) | |
| download | mullvadvpn-baa4802b2e31b55133b3e8fc4ad5058a8ffd0496.tar.xz mullvadvpn-baa4802b2e31b55133b3e8fc4ad5058a8ffd0496.zip | |
Merge branch 'reorganize-settings'
Diffstat (limited to 'gui/src')
36 files changed, 2127 insertions, 2051 deletions
diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx index 78c0b58f6d..7d269b9bdd 100644 --- a/gui/src/renderer/app.tsx +++ b/gui/src/renderer/app.tsx @@ -418,23 +418,23 @@ export default class AppRenderer { void this.openUrl(`${link}?token=${token}`); }; - public async setAllowLan(allowLan: boolean) { + public setAllowLan = async (allowLan: boolean) => { const actions = this.reduxActions; await IpcRendererEventChannel.settings.setAllowLan(allowLan); actions.settings.updateAllowLan(allowLan); - } + }; - public async setShowBetaReleases(showBetaReleases: boolean) { + public setShowBetaReleases = async (showBetaReleases: boolean) => { const actions = this.reduxActions; await IpcRendererEventChannel.settings.setShowBetaReleases(showBetaReleases); actions.settings.updateShowBetaReleases(showBetaReleases); - } + }; - public async setEnableIpv6(enableIpv6: boolean) { + public setEnableIpv6 = async (enableIpv6: boolean) => { const actions = this.reduxActions; await IpcRendererEventChannel.settings.setEnableIpv6(enableIpv6); actions.settings.updateEnableIpv6(enableIpv6); - } + }; public async setBridgeState(bridgeState: BridgeState) { const actions = this.reduxActions; @@ -468,11 +468,11 @@ export default class AppRenderer { IpcRendererEventChannel.guiSettings.setEnableSystemNotifications(flag); } - public setAutoStart(autoStart: boolean): Promise<void> { + public setAutoStart = (autoStart: boolean): Promise<void> => { this.storeAutoStart(autoStart); return IpcRendererEventChannel.autoStart.set(autoStart); - } + }; public setStartMinimized(startMinimized: boolean) { IpcRendererEventChannel.guiSettings.setStartMinimized(startMinimized); @@ -573,11 +573,11 @@ export default class AppRenderer { loadTranslations(relayLocations, translations.locale, translations.relayLocations); } - public getPreferredLocaleDisplayName(localeCode: string): string { + public getPreferredLocaleDisplayName = (localeCode: string): string => { const preferredLocale = this.getPreferredLocaleList().find((item) => item.code === localeCode); return preferredLocale ? preferredLocale.name : ''; - } + }; public setDisplayedChangelog = (): void => { IpcRendererEventChannel.currentVersion.displayedChangelog(); diff --git a/gui/src/renderer/components/AdvancedSettings.tsx b/gui/src/renderer/components/AdvancedSettings.tsx deleted file mode 100644 index b00fc77f4c..0000000000 --- a/gui/src/renderer/components/AdvancedSettings.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import * as React from 'react'; -import { sprintf } from 'sprintf-js'; - -import { strings } from '../../config.json'; -import { TunnelProtocol } from '../../shared/daemon-rpc-types'; -import { messages } from '../../shared/gettext'; -import { - StyledNavigationScrollbars, - StyledSelectorForFooter, - StyledTunnelProtocolContainer, -} from './AdvancedSettingsStyles'; -import * as AppButton from './AppButton'; -import { AriaDescription, AriaInput, AriaInputGroup, AriaLabel } from './AriaGroup'; -import * as Cell from './cell'; -import { ISelectorItem } from './cell/Selector'; -import CustomDnsSettings from './CustomDnsSettings'; -import { BackAction } from './KeyboardNavigation'; -import { Layout, SettingsContainer } from './Layout'; -import { ModalAlert, ModalAlertType, ModalMessage } from './Modal'; -import { NavigationBar, NavigationContainer, NavigationItems, TitleBarItem } from './NavigationBar'; -import SettingsHeader, { HeaderTitle } from './SettingsHeader'; -import Switch from './Switch'; - -type OptionalTunnelProtocol = TunnelProtocol | undefined; - -interface IProps { - enableIpv6: boolean; - blockWhenDisconnected: boolean; - tunnelProtocol?: TunnelProtocol; - setEnableIpv6: (value: boolean) => void; - setBlockWhenDisconnected: (value: boolean) => void; - setTunnelProtocol: (value: OptionalTunnelProtocol) => void; - onViewWireguardSettings: () => void; - onViewOpenVpnSettings: () => void; - onViewSplitTunneling: () => void; - onClose: () => void; -} - -interface IState { - showConfirmBlockWhenDisconnectedAlert: boolean; -} - -export default class AdvancedSettings extends React.Component<IProps, IState> { - public state = { - showConfirmBlockWhenDisconnectedAlert: false, - }; - - private blockWhenDisconnectedRef = React.createRef<Switch>(); - - private tunnelProtocolItems: Array<ISelectorItem<OptionalTunnelProtocol>>; - - public constructor(props: IProps) { - super(props); - - this.tunnelProtocolItems = [ - { - label: messages.gettext('Automatic'), - value: undefined, - }, - { - label: strings.wireguard, - value: 'wireguard', - }, - { - label: strings.openvpn, - value: 'openvpn', - }, - ]; - } - - public render() { - return ( - <BackAction action={this.props.onClose}> - <Layout> - <SettingsContainer> - <NavigationContainer> - <NavigationBar> - <NavigationItems> - <TitleBarItem> - { - // TRANSLATORS: Title label in navigation bar - messages.pgettext('advanced-settings-nav', 'Advanced') - } - </TitleBarItem> - </NavigationItems> - </NavigationBar> - - <StyledNavigationScrollbars> - <SettingsHeader> - <HeaderTitle> - {messages.pgettext('advanced-settings-view', 'Advanced')} - </HeaderTitle> - </SettingsHeader> - - <AriaInputGroup> - <Cell.Container> - <AriaLabel> - <Cell.InputLabel> - {messages.pgettext('advanced-settings-view', 'Enable IPv6')} - </Cell.InputLabel> - </AriaLabel> - <AriaInput> - <Cell.Switch - isOn={this.props.enableIpv6} - onChange={this.props.setEnableIpv6} - /> - </AriaInput> - </Cell.Container> - <Cell.Footer> - <AriaDescription> - <Cell.FooterText> - {messages.pgettext( - 'advanced-settings-view', - 'Enable IPv6 communication through the tunnel.', - )} - </Cell.FooterText> - </AriaDescription> - </Cell.Footer> - </AriaInputGroup> - - <AriaInputGroup> - <Cell.Container> - <AriaLabel> - <Cell.InputLabel> - {messages.pgettext('advanced-settings-view', 'Always require VPN')} - </Cell.InputLabel> - </AriaLabel> - <AriaInput> - <Cell.Switch - ref={this.blockWhenDisconnectedRef} - isOn={this.props.blockWhenDisconnected} - onChange={this.setBlockWhenDisconnected} - /> - </AriaInput> - </Cell.Container> - <Cell.Footer> - <AriaDescription> - <Cell.FooterText> - {messages.pgettext( - 'advanced-settings-view', - 'If you disconnect or quit the app, this setting will block your internet.', - )} - </Cell.FooterText> - </AriaDescription> - </Cell.Footer> - </AriaInputGroup> - - {(window.env.platform === 'linux' || window.env.platform === 'win32') && ( - <Cell.CellButtonGroup> - <Cell.CellButton onClick={this.props.onViewSplitTunneling}> - <Cell.Label>{strings.splitTunneling}</Cell.Label> - <Cell.Icon height={12} width={7} source="icon-chevron" /> - </Cell.CellButton> - </Cell.CellButtonGroup> - )} - - <AriaInputGroup> - <StyledTunnelProtocolContainer> - <StyledSelectorForFooter - title={messages.pgettext('advanced-settings-view', 'Tunnel protocol')} - values={this.tunnelProtocolItems} - value={this.props.tunnelProtocol} - onSelect={this.onSelectTunnelProtocol} - /> - </StyledTunnelProtocolContainer> - </AriaInputGroup> - - <Cell.CellButtonGroup> - <Cell.CellButton - onClick={this.props.onViewWireguardSettings} - disabled={this.props.tunnelProtocol === 'openvpn'}> - <Cell.Label> - {sprintf( - // TRANSLATORS: %(wireguard)s will be replaced with the string "WireGuard" - messages.pgettext('advanced-settings-view', '%(wireguard)s settings'), - { wireguard: strings.wireguard }, - )} - </Cell.Label> - <Cell.Icon height={12} width={7} source="icon-chevron" /> - </Cell.CellButton> - - <Cell.CellButton - onClick={this.props.onViewOpenVpnSettings} - disabled={this.props.tunnelProtocol === 'wireguard'}> - <Cell.Label> - {sprintf( - // TRANSLATORS: %(openvpn)s will be replaced with the string "OpenVPN" - messages.pgettext('advanced-settings-view', '%(openvpn)s settings'), - { openvpn: strings.openvpn }, - )} - </Cell.Label> - <Cell.Icon height={12} width={7} source="icon-chevron" /> - </Cell.CellButton> - </Cell.CellButtonGroup> - - <CustomDnsSettings /> - </StyledNavigationScrollbars> - </NavigationContainer> - </SettingsContainer> - - {this.renderConfirmBlockWhenDisconnectedAlert()} - </Layout> - </BackAction> - ); - } - - private renderConfirmBlockWhenDisconnectedAlert = () => { - return ( - <ModalAlert - isOpen={this.state.showConfirmBlockWhenDisconnectedAlert} - type={ModalAlertType.caution} - buttons={[ - <AppButton.RedButton key="confirm" onClick={this.confirmEnableBlockWhenDisconnected}> - {messages.gettext('Enable anyway')} - </AppButton.RedButton>, - <AppButton.BlueButton key="back" onClick={this.hideConfirmBlockWhenDisconnectedAlert}> - {messages.gettext('Back')} - </AppButton.BlueButton>, - ]} - close={this.hideConfirmBlockWhenDisconnectedAlert}> - <ModalMessage> - {messages.pgettext( - 'advanced-settings-view', - 'Attention: enabling this will always require a Mullvad VPN connection in order to reach the internet.', - )} - </ModalMessage> - <ModalMessage> - {messages.pgettext( - 'advanced-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> - ); - }; - - private setBlockWhenDisconnected = (newValue: boolean) => { - if (newValue) { - this.setState({ showConfirmBlockWhenDisconnectedAlert: true }); - } else { - this.props.setBlockWhenDisconnected(false); - } - }; - - private hideConfirmBlockWhenDisconnectedAlert = () => { - this.setState({ showConfirmBlockWhenDisconnectedAlert: false }); - }; - - private confirmEnableBlockWhenDisconnected = () => { - this.setState({ showConfirmBlockWhenDisconnectedAlert: false }); - this.props.setBlockWhenDisconnected(true); - }; - - private onSelectTunnelProtocol = (protocol?: TunnelProtocol) => { - this.props.setTunnelProtocol(protocol); - }; -} diff --git a/gui/src/renderer/components/AdvancedSettingsStyles.tsx b/gui/src/renderer/components/AdvancedSettingsStyles.tsx deleted file mode 100644 index 06a0103855..0000000000 --- a/gui/src/renderer/components/AdvancedSettingsStyles.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import styled from 'styled-components'; - -import Selector from './cell/Selector'; -import { NavigationScrollbars } from './NavigationBar'; - -export const StyledSelectorContainer = styled.div({ - flex: 0, -}); - -export const StyledSelectorForFooter = (styled(Selector)({ - marginBottom: 0, -}) as unknown) as new <T>() => Selector<T>; - -export const StyledTunnelProtocolContainer = styled(StyledSelectorContainer)({ - marginBottom: '20px', -}); - -export const StyledNavigationScrollbars = styled(NavigationScrollbars)({ - flex: 1, -}); diff --git a/gui/src/renderer/components/AppRouter.tsx b/gui/src/renderer/components/AppRouter.tsx index db69cbb80f..75892805e9 100644 --- a/gui/src/renderer/components/AppRouter.tsx +++ b/gui/src/renderer/components/AppRouter.tsx @@ -3,14 +3,11 @@ import * as React from 'react'; import { Route, Switch } from 'react-router'; import AccountPage from '../containers/AccountPage'; -import AdvancedSettingsPage from '../containers/AdvancedSettingsPage'; import LoginPage from '../containers/LoginPage'; import OpenVPNSettingsPage from '../containers/OpenVPNSettingsPage'; -import PreferencesPage from '../containers/PreferencesPage'; +import ProblemReportPage from '../containers/ProblemReportPage'; import SelectLanguagePage from '../containers/SelectLanguagePage'; import SelectLocationPage from '../containers/SelectLocationPage'; -import SettingsPage from '../containers/SettingsPage'; -import SupportPage from '../containers/SupportPage'; import WireguardSettingsPage from '../containers/WireguardSettingsPage'; import withAppContext, { IAppContext } from '../context'; import { IHistoryProps, ITransitionSpecification, transitions, withHistory } from '../lib/history'; @@ -24,11 +21,15 @@ import { } from './ExpiredAccountAddTime'; import Filter from './Filter'; import Focus, { IFocusHandle } from './Focus'; +import InterfaceSettings from './InterfaceSettings'; import Launch from './Launch'; import MainView from './MainView'; +import Settings from './Settings'; import SplitTunnelingSettings from './SplitTunnelingSettings'; +import Support from './Support'; import TooManyDevices from './TooManyDevices'; import TransitionContainer, { TransitionView } from './TransitionContainer'; +import VpnSettings from './VpnSettings'; interface IAppRoutesState { currentLocation: IHistoryProps['history']['location']; @@ -86,15 +87,16 @@ class AppRouter extends React.Component<IHistoryProps & IAppContext, IAppRoutesS <Route exact path={RoutePath.voucherSuccess} component={VoucherVerificationSuccess} /> <Route exact path={RoutePath.timeAdded} component={TimeAdded} /> <Route exact path={RoutePath.setupFinished} component={SetupFinished} /> - <Route exact path={RoutePath.settings} component={SettingsPage} /> + <Route exact path={RoutePath.settings} component={Settings} /> <Route exact path={RoutePath.selectLanguage} component={SelectLanguagePage} /> <Route exact path={RoutePath.accountSettings} component={AccountPage} /> - <Route exact path={RoutePath.preferences} component={PreferencesPage} /> - <Route exact path={RoutePath.advancedSettings} component={AdvancedSettingsPage} /> + <Route exact path={RoutePath.interfaceSettings} component={InterfaceSettings} /> + <Route exact path={RoutePath.vpnSettings} component={VpnSettings} /> <Route exact path={RoutePath.wireguardSettings} component={WireguardSettingsPage} /> <Route exact path={RoutePath.openVpnSettings} component={OpenVPNSettingsPage} /> <Route exact path={RoutePath.splitTunneling} component={SplitTunnelingSettings} /> - <Route exact path={RoutePath.support} component={SupportPage} /> + <Route exact path={RoutePath.support} component={Support} /> + <Route exact path={RoutePath.problemReport} component={ProblemReportPage} /> <Route exact path={RoutePath.selectLocation} component={SelectLocationPage} /> <Route exact path={RoutePath.filter} component={Filter} /> </Switch> diff --git a/gui/src/renderer/components/CustomDnsSettings.tsx b/gui/src/renderer/components/CustomDnsSettings.tsx index 0e9dccc79e..4fb785d686 100644 --- a/gui/src/renderer/components/CustomDnsSettings.tsx +++ b/gui/src/renderer/components/CustomDnsSettings.tsx @@ -6,7 +6,6 @@ import { messages } from '../../shared/gettext'; import { useAppContext } from '../context'; import { IpAddress } from '../lib/ip'; import { useBoolean, useMounted } from '../lib/utilityHooks'; -import { formatMarkdown } from '../markdown-formatter'; import { useSelector } from '../redux/store'; import Accordion from './Accordion'; import * as AppButton from './AppButton'; @@ -25,7 +24,6 @@ import { StyledButton, StyledContainer, StyledCustomDnsFooter, - StyledCustomDnsSwitchContainer, StyledLabel, StyledRemoveButton, StyledRemoveIcon, @@ -217,11 +215,11 @@ export default function CustomDnsSettings() { return ( <> - <StyledCustomDnsSwitchContainer disabled={!featureAvailable}> + <Cell.Container disabled={!featureAvailable}> <AriaInputGroup> <AriaLabel> <Cell.InputLabel> - {messages.pgettext('advanced-settings-view', 'Use custom DNS server')} + {messages.pgettext('vpn-settings-view', 'Use custom DNS server')} </Cell.InputLabel> </AriaLabel> <AriaInput> @@ -232,7 +230,7 @@ export default function CustomDnsSettings() { /> </AriaInput> </AriaInputGroup> - </StyledCustomDnsSwitchContainer> + </Cell.Container> <Accordion expanded={listExpanded}> <Cell.Section role="listbox"> <List @@ -254,7 +252,7 @@ export default function CustomDnsSettings() { {inputVisible && ( <div ref={inputContainerRef}> <Cell.RowInput - placeholder={messages.pgettext('advanced-settings-view', 'Enter IP')} + placeholder={messages.pgettext('vpn-settings-view', 'Enter IP')} onSubmit={onAdd} onChange={setValid} invalid={invalid} @@ -271,7 +269,7 @@ export default function CustomDnsSettings() { disabled={inputVisible} tabIndex={-1}> <StyledAddCustomDnsLabel tabIndex={-1}> - {messages.pgettext('advanced-settings-view', 'Add a server')} + {messages.pgettext('vpn-settings-view', 'Add a server')} </StyledAddCustomDnsLabel> <Cell.Icon source="icon-add" @@ -286,11 +284,18 @@ export default function CustomDnsSettings() { <StyledCustomDnsFooter> <Cell.FooterText> - {featureAvailable ? ( - messages.pgettext('advanced-settings-view', 'Enable to add at least one DNS server.') - ) : ( - <DisabledMessage /> - )} + {featureAvailable + ? messages.pgettext('vpn-settings-view', 'Enable to add at least one DNS server.') + : // This line makes sure that the next one isn't prefixed by the color. + // TRANSLATORS: This is displayed when either or both of the block ads/trackers settings are + // TRANSLATORS: turned on which makes the custom DNS setting disabled. The text enclosed in "**" + // TRANSLATORS: will appear bold. + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(preferencesPageName)s - The page title showed on top in the preferences page. + messages.pgettext( + 'vpn-settings-view', + 'Disable all content blockers to activate this setting.', + )} </Cell.FooterText> </StyledCustomDnsFooter> @@ -304,22 +309,6 @@ export default function CustomDnsSettings() { ); } -function DisabledMessage() { - const preferencesPageName = messages.pgettext('preferences-nav', 'Preferences'); - - // TRANSLATORS: This is displayed when either or both of the block ads/trackers settings are - // TRANSLATORS: turned on which makes the custom DNS setting disabled. The text enclosed in "**" - // TRANSLATORS: will appear bold. - // TRANSLATORS: Available placeholders: - // TRANSLATORS: %(preferencesPageName)s - The page title showed on top in the preferences page. - const customDnsDisabledMessage = messages.pgettext( - 'preferences-view', - 'Disable all content blockers (under %(preferencesPageName)s) to activate this setting.', - ); - - return formatMarkdown(sprintf(customDnsDisabledMessage, { preferencesPageName })); -} - interface ICellListItemProps { willShowConfirmationDialog: React.RefObject<boolean>; onRemove: (application: string) => void; @@ -372,7 +361,7 @@ function CellListItem(props: ICellListItemProps) { <div ref={inputContainerRef}> <Cell.RowInput initialValue={props.children} - placeholder={messages.pgettext('advanced-settings-view', 'Enter IP')} + placeholder={messages.pgettext('vpn-settings-view', 'Enter IP')} onSubmit={onSubmit} onChange={setValid} invalid={invalid} @@ -417,7 +406,7 @@ function ConfirmationDialog(props: IConfirmationDialogProps) { let message; if (props.isLocal.current) { message = messages.pgettext( - 'advanced-settings-view', + 'vpn-settings-view', 'The DNS server you want to add is a private IP. You must ensure that your network interfaces are configured to use it.', ); } else { @@ -426,12 +415,12 @@ function ConfirmationDialog(props: IConfirmationDialogProps) { // TRANSLATORS: %(tunnelProtocol)s - the name of the tunnel protocol setting // TRANSLATORS: %(wireguard)s - will be replaced with "WireGuard" messages.pgettext( - 'advanced-settings-view', + 'vpn-settings-view', 'The DNS server you want to add is public and will only work with %(wireguard)s. To ensure that it always works, set the "%(tunnelProtocol)s" (in Advanced settings) to %(wireguard)s.', ), { wireguard: strings.wireguard, - tunnelProtocol: messages.pgettext('advanced-settings-view', 'Tunnel protocol'), + tunnelProtocol: messages.pgettext('vpn-settings-view', 'Tunnel protocol'), }, ); } @@ -441,7 +430,7 @@ function ConfirmationDialog(props: IConfirmationDialogProps) { type={ModalAlertType.caution} buttons={[ <AppButton.RedButton key="confirm" onClick={props.confirm}> - {messages.pgettext('advanced-settings-view', 'Add anyway')} + {messages.pgettext('vpn-settings-view', 'Add anyway')} </AppButton.RedButton>, <AppButton.BlueButton key="back" onClick={props.abort}> {messages.gettext('Back')} diff --git a/gui/src/renderer/components/CustomDnsSettingsStyles.tsx b/gui/src/renderer/components/CustomDnsSettingsStyles.tsx index b8ec59ec36..9d927ba52d 100644 --- a/gui/src/renderer/components/CustomDnsSettingsStyles.tsx +++ b/gui/src/renderer/components/CustomDnsSettingsStyles.tsx @@ -4,10 +4,6 @@ import { colors } from '../../config.json'; import * as Cell from './cell'; import ImageView from './ImageView'; -export const StyledCustomDnsSwitchContainer = styled(Cell.Container)({ - marginBottom: '1px', -}); - export const StyledCustomDnsFooter = styled(Cell.Footer)({ marginBottom: '2px', }); @@ -32,7 +28,6 @@ export const StyledAddCustomDnsLabel = styled(Cell.Label)( export const StyledContainer = styled(Cell.Container)({ display: 'flex', - marginBottom: '1px', backgroundColor: colors.blue40, }); diff --git a/gui/src/renderer/components/Filter.tsx b/gui/src/renderer/components/Filter.tsx index d974fa7f6a..1798985070 100644 --- a/gui/src/renderer/components/Filter.tsx +++ b/gui/src/renderer/components/Filter.tsx @@ -288,7 +288,6 @@ const StyledRow = styled.div({ height: '44px', alignItems: 'center', padding: '0 22px', - marginBottom: '1px', backgroundColor: colors.blue, }); diff --git a/gui/src/renderer/components/InterfaceSettings.tsx b/gui/src/renderer/components/InterfaceSettings.tsx new file mode 100644 index 0000000000..bc7f823e7c --- /dev/null +++ b/gui/src/renderer/components/InterfaceSettings.tsx @@ -0,0 +1,240 @@ +import { useCallback } from 'react'; +import styled from 'styled-components'; + +import { colors } from '../../config.json'; +import { messages } from '../../shared/gettext'; +import { useAppContext } from '../context'; +import { useHistory } from '../lib/history'; +import { RoutePath } from '../lib/routes'; +import { useSelector } from '../redux/store'; +import { AriaDescription, AriaInput, AriaInputGroup, AriaLabel } from './AriaGroup'; +import * as Cell from './cell'; +import { BackAction } from './KeyboardNavigation'; +import { Container, Layout } from './Layout'; +import { + NavigationBar, + NavigationContainer, + NavigationItems, + NavigationScrollbars, + TitleBarItem, +} from './NavigationBar'; +import SettingsHeader, { HeaderTitle } from './SettingsHeader'; + +const StyledContainer = styled(Container)({ + backgroundColor: colors.darkBlue, +}); + +const StyledContent = styled.div({ + display: 'flex', + flexDirection: 'column', + flex: 1, + marginBottom: '2px', +}); + +const StyledCellIcon = styled(Cell.UntintedIcon)({ + marginRight: '8px', +}); + +export default function InterfaceSettings() { + const { pop } = useHistory(); + const unpinnedWindow = useSelector((state) => state.settings.guiSettings.unpinnedWindow); + + return ( + <BackAction action={pop}> + <Layout> + <StyledContainer> + <NavigationContainer> + <NavigationBar> + <NavigationItems> + <TitleBarItem> + { + // TRANSLATORS: Title label in navigation bar + messages.pgettext('interface-settings-view', 'Interface settings') + } + </TitleBarItem> + </NavigationItems> + </NavigationBar> + + <NavigationScrollbars> + <SettingsHeader> + <HeaderTitle> + {messages.pgettext('interface-settings-view', 'Interface settings')} + </HeaderTitle> + </SettingsHeader> + + <StyledContent> + <Cell.Group> + <NotificationsSetting /> + </Cell.Group> + <Cell.Group> + <MonochromaticTrayIconSetting /> + </Cell.Group> + + <Cell.Group> + <LanguageButton /> + </Cell.Group> + + {(window.env.platform === 'win32' || + (window.env.platform === 'darwin' && window.env.development)) && ( + <Cell.Group> + <UnpinnedWindowSetting /> + </Cell.Group> + )} + + {unpinnedWindow && ( + <Cell.Group> + <StartMinimizedSetting /> + </Cell.Group> + )} + </StyledContent> + </NavigationScrollbars> + </NavigationContainer> + </StyledContainer> + </Layout> + </BackAction> + ); +} + +function NotificationsSetting() { + const enableSystemNotifications = useSelector( + (state) => state.settings.guiSettings.enableSystemNotifications, + ); + const { setEnableSystemNotifications } = useAppContext(); + + return ( + <AriaInputGroup> + <Cell.Container> + <AriaLabel> + <Cell.InputLabel> + {messages.pgettext('interface-settings-view', 'Notifications')} + </Cell.InputLabel> + </AriaLabel> + <AriaInput> + <Cell.Switch isOn={enableSystemNotifications} onChange={setEnableSystemNotifications} /> + </AriaInput> + </Cell.Container> + <Cell.Footer> + <AriaDescription> + <Cell.FooterText> + {messages.pgettext( + 'interface-settings-view', + 'Enable or disable system notifications. The critical notifications will always be displayed.', + )} + </Cell.FooterText> + </AriaDescription> + </Cell.Footer> + </AriaInputGroup> + ); +} + +function MonochromaticTrayIconSetting() { + const monochromaticIcon = useSelector((state) => state.settings.guiSettings.monochromaticIcon); + const { setMonochromaticIcon } = useAppContext(); + + return ( + <AriaInputGroup> + <Cell.Container> + <AriaLabel> + <Cell.InputLabel> + {messages.pgettext('interface-settings-view', 'Monochromatic tray icon')} + </Cell.InputLabel> + </AriaLabel> + <AriaInput> + <Cell.Switch isOn={monochromaticIcon} onChange={setMonochromaticIcon} /> + </AriaInput> + </Cell.Container> + <Cell.Footer> + <AriaDescription> + <Cell.FooterText> + {messages.pgettext( + 'interface-settings-view', + 'Use a monochromatic tray icon instead of a colored one.', + )} + </Cell.FooterText> + </AriaDescription> + </Cell.Footer> + </AriaInputGroup> + ); +} + +function UnpinnedWindowSetting() { + const unpinnedWindow = useSelector((state) => state.settings.guiSettings.unpinnedWindow); + const { setUnpinnedWindow } = useAppContext(); + + return ( + <AriaInputGroup> + <Cell.Container> + <AriaLabel> + <Cell.InputLabel> + {messages.pgettext('interface-settings-view', 'Unpin app from taskbar')} + </Cell.InputLabel> + </AriaLabel> + <AriaInput> + <Cell.Switch isOn={unpinnedWindow} onChange={setUnpinnedWindow} /> + </AriaInput> + </Cell.Container> + <Cell.Footer> + <AriaDescription> + <Cell.FooterText> + {messages.pgettext( + 'interface-settings-view', + 'Enable to move the app around as a free-standing window.', + )} + </Cell.FooterText> + </AriaDescription> + </Cell.Footer> + </AriaInputGroup> + ); +} + +function StartMinimizedSetting() { + const startMinimized = useSelector((state) => state.settings.guiSettings.startMinimized); + const { setStartMinimized } = useAppContext(); + + return ( + <AriaInputGroup> + <Cell.Container> + <AriaLabel> + <Cell.InputLabel> + {messages.pgettext('interface-settings-view', 'Start minimized')} + </Cell.InputLabel> + </AriaLabel> + <AriaInput> + <Cell.Switch isOn={startMinimized} onChange={setStartMinimized} /> + </AriaInput> + </Cell.Container> + <Cell.Footer> + <AriaDescription> + <Cell.FooterText> + {messages.pgettext( + 'interface-settings-view', + 'Show only the tray icon when the app starts.', + )} + </Cell.FooterText> + </AriaDescription> + </Cell.Footer> + </AriaInputGroup> + ); +} + +function LanguageButton() { + const history = useHistory(); + const { getPreferredLocaleDisplayName } = useAppContext(); + const preferredLocale = useSelector((state) => state.settings.guiSettings.preferredLocale); + const localeDisplayName = getPreferredLocaleDisplayName(preferredLocale); + + const navigate = useCallback(() => history.push(RoutePath.selectLanguage), [history]); + + return ( + <Cell.CellNavigationButton onClick={navigate}> + <StyledCellIcon width={24} height={24} source="icon-language" /> + <Cell.Label> + { + // TRANSLATORS: Navigation button to the 'Language' settings view + messages.pgettext('interface-settings-view', 'Language') + } + </Cell.Label> + <Cell.SubText>{localeDisplayName}</Cell.SubText> + </Cell.CellNavigationButton> + ); +} diff --git a/gui/src/renderer/components/List.tsx b/gui/src/renderer/components/List.tsx index 2d4f73b7f1..2915a95c1b 100644 --- a/gui/src/renderer/components/List.tsx +++ b/gui/src/renderer/components/List.tsx @@ -1,10 +1,18 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import styled from 'styled-components'; import { Scheduler } from '../../shared/scheduler'; import Accordion from './Accordion'; export const stringValueAsKey = (value: string): string => value; +const StyledListItem = styled.div({ + marginBottom: '1px', + display: 'flex', + flex: 1, + flexDirection: 'column', +}); + interface ListProps<T> { items: Array<T>; getKey: (data: T) => string; @@ -112,7 +120,7 @@ function ListItem<T>(props: ListItemProps<T>) { return ( <Accordion expanded={expanded} onTransitionEnd={onTransitionEnd}> - {props.render(props.data.data)} + <StyledListItem>{props.render(props.data.data)}</StyledListItem> </Accordion> ); } diff --git a/gui/src/renderer/components/LocationList.tsx b/gui/src/renderer/components/LocationList.tsx index 50419bc838..7b2b0e9b20 100644 --- a/gui/src/renderer/components/LocationList.tsx +++ b/gui/src/renderer/components/LocationList.tsx @@ -329,7 +329,7 @@ export class RelayLocations extends React.PureComponent< public render() { return ( - <> + <Cell.Group noMarginBottom> {this.state.countries.map((relayCountry) => { const countryLocation: RelayLocation = { country: relayCountry.code }; @@ -384,7 +384,7 @@ export class RelayLocations extends React.PureComponent< </LocationRow> ); })} - </> + </Cell.Group> ); } diff --git a/gui/src/renderer/components/LocationRow.tsx b/gui/src/renderer/components/LocationRow.tsx index fd406f2487..f3d0927c6f 100644 --- a/gui/src/renderer/components/LocationRow.tsx +++ b/gui/src/renderer/components/LocationRow.tsx @@ -41,7 +41,6 @@ const buttonColor = (props: IButtonColorProps) => { const Container = styled(Cell.Container)({ display: 'flex', padding: 0, - marginBottom: '1px', background: 'none', }); @@ -154,7 +153,7 @@ function LocationRow(props: IProps, ref: React.Ref<HTMLDivElement>) { onWillExpand={onWillExpand} onTransitionEnd={props.onTransitionEnd} animationDuration={150}> - {props.children} + <Cell.Group noMarginBottom>{props.children}</Cell.Group> </Accordion> )} </> diff --git a/gui/src/renderer/components/Login.tsx b/gui/src/renderer/components/Login.tsx index 282f49d01c..c2c8b9d177 100644 --- a/gui/src/renderer/components/Login.tsx +++ b/gui/src/renderer/components/Login.tsx @@ -442,10 +442,7 @@ function BlockMessage() { } }, [blockWhenDisconnected, tunnelState, setBlockWhenDisconnected, disconnectTunnel]); - const alwaysRequireVpnSettingsName = messages.pgettext( - 'advanced-settings-view', - 'Always require VPN', - ); + const alwaysRequireVpnSettingsName = messages.pgettext('vpn-settings-view', 'Lockdown mode'); const message = formatMarkdown( blockWhenDisconnected ? sprintf( diff --git a/gui/src/renderer/components/OpenVPNSettings.tsx b/gui/src/renderer/components/OpenVPNSettings.tsx index 4aac83014f..1b82c9971a 100644 --- a/gui/src/renderer/components/OpenVPNSettings.tsx +++ b/gui/src/renderer/components/OpenVPNSettings.tsx @@ -119,120 +119,126 @@ export default class OpenVpnSettings extends React.Component<IProps, IState> { </HeaderTitle> </SettingsHeader> - <StyledSelectorContainer> - <AriaInputGroup> - <Selector - title={messages.pgettext('openvpn-settings-view', 'Transport protocol')} - values={this.protocolItems(this.props.bridgeState !== 'on')} - value={this.props.openvpn.protocol} - onSelect={this.onSelectOpenvpnProtocol} - hasFooter={this.props.bridgeState === 'on'} - /> - {this.props.bridgeState === 'on' && ( - <Cell.Footer> - <AriaDescription> - <Cell.FooterText> - {formatMarkdown( - // TRANSLATORS: This is used to instruct users how to make UDP mode - // TRANSLATORS: available. - messages.pgettext( - 'openvpn-settings-view', - 'To activate UDP, change **Bridge mode** to **Automatic** or **Off**.', - ), - )} - </Cell.FooterText> - </AriaDescription> - </Cell.Footer> - )} - </AriaInputGroup> - </StyledSelectorContainer> + <Cell.Group> + <StyledSelectorContainer> + <AriaInputGroup> + <Selector + title={messages.pgettext('openvpn-settings-view', 'Transport protocol')} + values={this.protocolItems(this.props.bridgeState !== 'on')} + value={this.props.openvpn.protocol} + onSelect={this.onSelectOpenvpnProtocol} + /> + {this.props.bridgeState === 'on' && ( + <Cell.Footer> + <AriaDescription> + <Cell.FooterText> + {formatMarkdown( + // TRANSLATORS: This is used to instruct users how to make UDP mode + // TRANSLATORS: available. + messages.pgettext( + 'openvpn-settings-view', + 'To activate UDP, change **Bridge mode** to **Automatic** or **Off**.', + ), + )} + </Cell.FooterText> + </AriaDescription> + </Cell.Footer> + )} + </AriaInputGroup> + </StyledSelectorContainer> + </Cell.Group> + + {this.props.openvpn.protocol ? ( + <Cell.Group> + <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: this.props.openvpn.protocol.toUpperCase(), + }, + )} + values={this.portItems[this.props.openvpn.protocol]} + value={this.props.openvpn.port} + onSelect={this.onSelectOpenVpnPort} + /> + </AriaInputGroup> + </StyledSelectorContainer> + </Cell.Group> + ) : undefined} - <StyledSelectorContainer> + <Cell.Group> <AriaInputGroup> - {this.props.openvpn.protocol ? ( + <StyledSelectorContainer> <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: this.props.openvpn.protocol.toUpperCase(), - }, + title={ + // TRANSLATORS: The title for the shadowsocks bridge selector section. + messages.pgettext('openvpn-settings-view', 'Bridge mode') + } + values={this.bridgeStateItems( + this.props.bridgeModeAvailablity === BridgeModeAvailability.available, )} - values={this.portItems[this.props.openvpn.protocol]} - value={this.props.openvpn.port} - onSelect={this.onSelectOpenVpnPort} + value={this.props.bridgeState} + onSelect={this.onSelectBridgeState} /> - ) : undefined} + </StyledSelectorContainer> + <Cell.Footer> + <AriaDescription> + <Cell.FooterText>{this.bridgeModeFooterText()}</Cell.FooterText> + </AriaDescription> + </Cell.Footer> </AriaInputGroup> - </StyledSelectorContainer> + </Cell.Group> - <AriaInputGroup> - <StyledSelectorContainer> - <Selector - title={ - // TRANSLATORS: The title for the shadowsocks bridge selector section. - messages.pgettext('openvpn-settings-view', 'Bridge mode') - } - values={this.bridgeStateItems( - this.props.bridgeModeAvailablity === BridgeModeAvailability.available, - )} - value={this.props.bridgeState} - onSelect={this.onSelectBridgeState} - hasFooter - /> - </StyledSelectorContainer> - <Cell.Footer> - <AriaDescription> - <Cell.FooterText>{this.bridgeModeFooterText()}</Cell.FooterText> - </AriaDescription> - </Cell.Footer> - </AriaInputGroup> - - <AriaInputGroup> - <Cell.Container> - <AriaLabel> - <Cell.InputLabel> - {messages.pgettext('openvpn-settings-view', 'Mssfix')} - </Cell.InputLabel> - </AriaLabel> - <AriaInput> - <Cell.AutoSizingTextInput - value={this.props.mssfix ? this.props.mssfix.toString() : ''} - inputMode={'numeric'} - maxLength={4} - placeholder={messages.gettext('Default')} - onSubmitValue={this.onMssfixSubmit} - validateValue={OpenVpnSettings.mssfixIsValid} - submitOnBlur={true} - modifyValue={OpenVpnSettings.removeNonNumericCharacters} - /> - </AriaInput> - </Cell.Container> - <Cell.Footer> - <AriaDescription> - <Cell.FooterText> - {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.FooterText> - </AriaDescription> - </Cell.Footer> - </AriaInputGroup> + <Cell.Group> + <AriaInputGroup> + <Cell.Container> + <AriaLabel> + <Cell.InputLabel> + {messages.pgettext('openvpn-settings-view', 'Mssfix')} + </Cell.InputLabel> + </AriaLabel> + <AriaInput> + <Cell.AutoSizingTextInput + value={this.props.mssfix ? this.props.mssfix.toString() : ''} + inputMode={'numeric'} + maxLength={4} + placeholder={messages.gettext('Default')} + onSubmitValue={this.onMssfixSubmit} + validateValue={OpenVpnSettings.mssfixIsValid} + submitOnBlur={true} + modifyValue={OpenVpnSettings.removeNonNumericCharacters} + /> + </AriaInput> + </Cell.Container> + <Cell.Footer> + <AriaDescription> + <Cell.FooterText> + {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.FooterText> + </AriaDescription> + </Cell.Footer> + </AriaInputGroup> + </Cell.Group> </StyledNavigationScrollbars> </NavigationContainer> </SettingsContainer> @@ -321,7 +327,7 @@ export default class OpenVpnSettings extends React.Component<IProps, IState> { 'To activate Bridge mode, go back and change **%(tunnelProtocol)s** to **%(openvpn)s**.', ), { - tunnelProtocol: messages.pgettext('advanced-settings-view', 'Tunnel protocol'), + tunnelProtocol: messages.pgettext('vpn-settings-view', 'Tunnel protocol'), openvpn: strings.openvpn, }, ), diff --git a/gui/src/renderer/components/Preferences.tsx b/gui/src/renderer/components/Preferences.tsx deleted file mode 100644 index 001f367f87..0000000000 --- a/gui/src/renderer/components/Preferences.tsx +++ /dev/null @@ -1,571 +0,0 @@ -import * as React from 'react'; -import { sprintf } from 'sprintf-js'; - -import { IDnsOptions } from '../../shared/daemon-rpc-types'; -import { messages } from '../../shared/gettext'; -import { formatMarkdown } from '../markdown-formatter'; -import * as AppButton from './AppButton'; -import { AriaDescription, AriaDetails, AriaInput, AriaInputGroup, AriaLabel } from './AriaGroup'; -import * as Cell from './cell'; -import InfoButton from './InfoButton'; -import { BackAction } from './KeyboardNavigation'; -import { Layout } from './Layout'; -import { ModalAlert, ModalAlertType, ModalMessage } from './Modal'; -import { - NavigationBar, - NavigationContainer, - NavigationItems, - NavigationScrollbars, - TitleBarItem, -} from './NavigationBar'; -import { - StyledContainer, - StyledContent, - StyledInfoIcon, - StyledSeparator, -} from './PreferencesStyles'; -import SettingsHeader, { HeaderTitle } from './SettingsHeader'; - -export interface IProps { - autoStart: boolean; - autoConnect: boolean; - allowLan: boolean; - showBetaReleases: boolean; - isBeta: boolean; - enableSystemNotifications: boolean; - monochromaticIcon: boolean; - startMinimized: boolean; - unpinnedWindow: boolean; - dns: IDnsOptions; - setAutoStart: (autoStart: boolean) => void; - setEnableSystemNotifications: (flag: boolean) => void; - setAutoConnect: (autoConnect: boolean) => void; - setAllowLan: (allowLan: boolean) => void; - setShowBetaReleases: (showBetaReleases: boolean) => void; - setStartMinimized: (startMinimized: boolean) => void; - setMonochromaticIcon: (monochromaticIcon: boolean) => void; - setUnpinnedWindow: (unpinnedWindow: boolean) => void; - setDnsOptions: (dns: IDnsOptions) => Promise<void>; - onClose: () => void; -} - -interface IState { - showKillSwitchInfo: boolean; -} - -export default class Preferences extends React.Component<IProps, IState> { - public state = { showKillSwitchInfo: false }; - - public render() { - return ( - <BackAction action={this.props.onClose}> - <Layout> - <StyledContainer> - <NavigationContainer> - <NavigationBar> - <NavigationItems> - <TitleBarItem> - { - // TRANSLATORS: Title label in navigation bar - messages.pgettext('preferences-nav', 'Preferences') - } - </TitleBarItem> - </NavigationItems> - </NavigationBar> - - <NavigationScrollbars> - <SettingsHeader> - <HeaderTitle>{messages.pgettext('preferences-view', 'Preferences')}</HeaderTitle> - </SettingsHeader> - - <StyledContent> - <Cell.CellButton onClick={this.showKillSwitchInfo}> - <AriaInputGroup> - <AriaLabel> - <Cell.InputLabel> - {messages.pgettext('preferences-view', 'Kill switch')} - </Cell.InputLabel> - </AriaLabel> - <StyledInfoIcon /> - <AriaInput> - <Cell.Switch isOn disabled /> - </AriaInput> - </AriaInputGroup> - </Cell.CellButton> - <StyledSeparator height={20} /> - - <AriaInputGroup> - <Cell.Container> - <AriaLabel> - <Cell.InputLabel> - {messages.pgettext('preferences-view', 'Launch app on start-up')} - </Cell.InputLabel> - </AriaLabel> - <AriaInput> - <Cell.Switch - isOn={this.props.autoStart} - onChange={this.props.setAutoStart} - /> - </AriaInput> - </Cell.Container> - </AriaInputGroup> - <StyledSeparator /> - - <AriaInputGroup> - <Cell.Container> - <AriaLabel> - <Cell.InputLabel> - {messages.pgettext('preferences-view', 'Auto-connect')} - </Cell.InputLabel> - </AriaLabel> - <AriaInput> - <Cell.Switch - isOn={this.props.autoConnect} - onChange={this.props.setAutoConnect} - /> - </AriaInput> - </Cell.Container> - <Cell.Footer> - <AriaDescription> - <Cell.FooterText> - {messages.pgettext( - 'preferences-view', - 'Automatically connect to a server when the app launches.', - )} - </Cell.FooterText> - </AriaDescription> - </Cell.Footer> - </AriaInputGroup> - - <AriaInputGroup> - <Cell.Container disabled={this.props.dns.state === 'custom'}> - <AriaLabel> - <Cell.InputLabel> - {messages.pgettext('preferences-view', 'Block ads')} - </Cell.InputLabel> - </AriaLabel> - <AriaDetails> - <InfoButton> - <ModalMessage> - {messages.pgettext( - 'preferences-view', - 'When enabled, this feature stops the device from contacting certain known ad domains.', - )} - </ModalMessage> - <ModalMessage> - {messages.pgettext( - 'preferences-view', - 'Warning: This might cause issues on certain websites, services, and programs.', - )} - </ModalMessage> - </InfoButton> - </AriaDetails> - <AriaInput> - <Cell.Switch - isOn={ - this.props.dns.state === 'default' && - this.props.dns.defaultOptions.blockAds - } - onChange={this.setBlockAds} - /> - </AriaInput> - </Cell.Container> - </AriaInputGroup> - <StyledSeparator /> - <AriaInputGroup> - <Cell.Container disabled={this.props.dns.state === 'custom'}> - <AriaLabel> - <Cell.InputLabel> - {messages.pgettext('preferences-view', 'Block trackers')} - </Cell.InputLabel> - </AriaLabel> - <AriaDetails> - <InfoButton> - <ModalMessage> - {messages.pgettext( - 'preferences-view', - 'When enabled, this feature stops the device from contacting certain domains known to track users.', - )} - </ModalMessage> - <ModalMessage> - {messages.pgettext( - 'preferences-view', - 'Warning: This might cause issues on certain websites, services, and programs.', - )} - </ModalMessage> - </InfoButton> - </AriaDetails> - <AriaInput> - <Cell.Switch - isOn={ - this.props.dns.state === 'default' && - this.props.dns.defaultOptions.blockTrackers - } - onChange={this.setBlockTrackers} - /> - </AriaInput> - </Cell.Container> - </AriaInputGroup> - <StyledSeparator /> - <AriaInputGroup> - <Cell.Container disabled={this.props.dns.state === 'custom'}> - <AriaLabel> - <Cell.InputLabel> - {messages.pgettext('preferences-view', 'Block malware')} - </Cell.InputLabel> - </AriaLabel> - <AriaDetails> - <InfoButton> - <ModalMessage> - {messages.pgettext( - 'preferences-view', - 'When enabled, this feature stops the device from contacting certain domains known to host malware.', - )} - </ModalMessage> - <ModalMessage> - {messages.pgettext( - 'preferences-view', - 'Warning: This is not an anti-virus and should not be treated as such, this is just an extra layer of protection.', - )} - </ModalMessage> - </InfoButton> - </AriaDetails> - <AriaInput> - <Cell.Switch - isOn={ - this.props.dns.state === 'default' && - this.props.dns.defaultOptions.blockMalware - } - onChange={this.setBlockMalware} - /> - </AriaInput> - </Cell.Container> - </AriaInputGroup> - <StyledSeparator /> - <AriaInputGroup> - <Cell.Container disabled={this.props.dns.state === 'custom'}> - <AriaLabel> - <Cell.InputLabel> - {messages.pgettext('preferences-view', 'Block adult content')} - </Cell.InputLabel> - </AriaLabel> - <AriaDetails> - <InfoButton - message={messages.pgettext( - 'preferences-view', - 'When enabled, this feature stops the device from contacting certain websites and services known to host adult content.', - )} - /> - </AriaDetails> - <AriaInput> - <Cell.Switch - isOn={ - this.props.dns.state === 'default' && - this.props.dns.defaultOptions.blockAdultContent - } - onChange={this.setBlockAdultContent} - /> - </AriaInput> - </Cell.Container> - </AriaInputGroup> - <StyledSeparator /> - <AriaInputGroup> - <Cell.Container disabled={this.props.dns.state === 'custom'}> - <AriaLabel> - <Cell.InputLabel> - {messages.pgettext('preferences-view', 'Block gambling')} - </Cell.InputLabel> - </AriaLabel> - <AriaDetails> - <InfoButton - message={messages.pgettext( - 'preferences-view', - 'When enabled, this feature stops the device from contacting certain websites and services known to host gambling content.', - )} - /> - </AriaDetails> - <AriaInput> - <Cell.Switch - isOn={ - this.props.dns.state === 'default' && - this.props.dns.defaultOptions.blockGambling - } - onChange={this.setBlockGambling} - /> - </AriaInput> - </Cell.Container> - {this.props.dns.state === 'custom' && <CustomDnsEnabledFooter />} - </AriaInputGroup> - - {this.props.dns.state !== 'custom' && <StyledSeparator height={20} />} - - <AriaInputGroup> - <Cell.Container> - <AriaLabel> - <Cell.InputLabel> - {messages.pgettext('preferences-view', 'Local network sharing')} - </Cell.InputLabel> - </AriaLabel> - <AriaInput> - <Cell.Switch isOn={this.props.allowLan} onChange={this.props.setAllowLan} /> - </AriaInput> - </Cell.Container> - <Cell.Footer> - <AriaDescription> - <Cell.FooterText> - {messages.pgettext( - 'preferences-view', - 'Allows access to other devices on the same network for sharing, printing etc.', - )} - </Cell.FooterText> - </AriaDescription> - </Cell.Footer> - </AriaInputGroup> - - <AriaInputGroup> - <Cell.Container> - <AriaLabel> - <Cell.InputLabel> - {messages.pgettext('preferences-view', 'Notifications')} - </Cell.InputLabel> - </AriaLabel> - <AriaInput> - <Cell.Switch - isOn={this.props.enableSystemNotifications} - onChange={this.props.setEnableSystemNotifications} - /> - </AriaInput> - </Cell.Container> - <Cell.Footer> - <AriaDescription> - <Cell.FooterText> - {messages.pgettext( - 'preferences-view', - 'Enable or disable system notifications. The critical notifications will always be displayed.', - )} - </Cell.FooterText> - </AriaDescription> - </Cell.Footer> - </AriaInputGroup> - - <AriaInputGroup> - <Cell.Container> - <AriaLabel> - <Cell.InputLabel> - {messages.pgettext('preferences-view', 'Monochromatic tray icon')} - </Cell.InputLabel> - </AriaLabel> - <AriaInput> - <Cell.Switch - isOn={this.props.monochromaticIcon} - onChange={this.props.setMonochromaticIcon} - /> - </AriaInput> - </Cell.Container> - <Cell.Footer> - <AriaDescription> - <Cell.FooterText> - {messages.pgettext( - 'preferences-view', - 'Use a monochromatic tray icon instead of a colored one.', - )} - </Cell.FooterText> - </AriaDescription> - </Cell.Footer> - </AriaInputGroup> - - {(window.env.platform === 'win32' || - (window.env.platform === 'darwin' && window.env.development)) && ( - <AriaInputGroup> - <Cell.Container> - <AriaLabel> - <Cell.InputLabel> - {messages.pgettext('preferences-view', 'Unpin app from taskbar')} - </Cell.InputLabel> - </AriaLabel> - <AriaInput> - <Cell.Switch - isOn={this.props.unpinnedWindow} - onChange={this.props.setUnpinnedWindow} - /> - </AriaInput> - </Cell.Container> - <Cell.Footer> - <AriaDescription> - <Cell.FooterText> - {messages.pgettext( - 'preferences-view', - 'Enable to move the app around as a free-standing window.', - )} - </Cell.FooterText> - </AriaDescription> - </Cell.Footer> - </AriaInputGroup> - )} - - {this.props.unpinnedWindow && ( - <React.Fragment> - <AriaInputGroup> - <Cell.Container> - <AriaLabel> - <Cell.InputLabel> - {messages.pgettext('preferences-view', 'Start minimized')} - </Cell.InputLabel> - </AriaLabel> - <AriaInput> - <Cell.Switch - isOn={this.props.startMinimized} - onChange={this.props.setStartMinimized} - /> - </AriaInput> - </Cell.Container> - <Cell.Footer> - <AriaDescription> - <Cell.FooterText> - {messages.pgettext( - 'preferences-view', - 'Show only the tray icon when the app starts.', - )} - </Cell.FooterText> - </AriaDescription> - </Cell.Footer> - </AriaInputGroup> - </React.Fragment> - )} - - <AriaInputGroup> - <Cell.Container disabled={this.props.isBeta}> - <AriaLabel> - <Cell.InputLabel> - {messages.pgettext('preferences-view', 'Beta program')} - </Cell.InputLabel> - </AriaLabel> - <AriaInput> - <Cell.Switch - isOn={this.props.showBetaReleases} - onChange={this.props.setShowBetaReleases} - /> - </AriaInput> - </Cell.Container> - <Cell.Footer> - <AriaDescription> - <Cell.FooterText> - {this.props.isBeta - ? messages.pgettext( - 'preferences-view', - 'This option is unavailable while using a beta version.', - ) - : messages.pgettext( - 'preferences-view', - 'Enable to get notified when new beta versions of the app are released.', - )} - </Cell.FooterText> - </AriaDescription> - </Cell.Footer> - </AriaInputGroup> - </StyledContent> - </NavigationScrollbars> - </NavigationContainer> - </StyledContainer> - - <ModalAlert - isOpen={this.state.showKillSwitchInfo} - message={messages.pgettext( - 'preferences-view', - 'The app has a built in kill switch that is enabled by default and cannot be disabled. This is to prevent your traffic from leaking outside of the VPN tunnel if your network suddenly stops working or if the tunnel fails for any reason. Mullvad automatically protects your data until your connection is reestablished.', - )} - type={ModalAlertType.info} - buttons={[ - <AppButton.BlueButton key="back" onClick={this.hideKillSwitchInfo}> - {messages.gettext('Got it!')} - </AppButton.BlueButton>, - ]} - close={this.hideKillSwitchInfo} - /> - </Layout> - </BackAction> - ); - } - - private setBlockAds = async (enabled: boolean) => { - await this.props.setDnsOptions({ - ...this.props.dns, - defaultOptions: { - ...this.props.dns.defaultOptions, - blockAds: enabled, - }, - }); - }; - - private setBlockTrackers = async (enabled: boolean) => { - await this.props.setDnsOptions({ - ...this.props.dns, - defaultOptions: { - ...this.props.dns.defaultOptions, - blockTrackers: enabled, - }, - }); - }; - - private setBlockMalware = async (enabled: boolean) => { - await this.props.setDnsOptions({ - ...this.props.dns, - defaultOptions: { - ...this.props.dns.defaultOptions, - blockMalware: enabled, - }, - }); - }; - - private setBlockAdultContent = async (enabled: boolean) => { - await this.props.setDnsOptions({ - ...this.props.dns, - defaultOptions: { - ...this.props.dns.defaultOptions, - blockAdultContent: enabled, - }, - }); - }; - - private setBlockGambling = async (enabled: boolean) => { - await this.props.setDnsOptions({ - ...this.props.dns, - defaultOptions: { - ...this.props.dns.defaultOptions, - blockGambling: enabled, - }, - }); - }; - - private showKillSwitchInfo = () => { - this.setState({ showKillSwitchInfo: true }); - }; - - private hideKillSwitchInfo = () => { - this.setState({ showKillSwitchInfo: false }); - }; -} - -function CustomDnsEnabledFooter() { - const customDnsFeatureName = messages.pgettext('advanced-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 "**" will appear bold. - // TRANSLATORS: Advanced settings refer to the name of the page with the title "Advanced". - // TRANSLATORS: Available placeholders: - // TRANSLATORS: %(customDnsFeatureName)s - The name displayed next to the custom DNS toggle. - const blockingDisabledText = messages.pgettext( - 'preferences-view', - 'Disable **%(customDnsFeatureName)s** (under Advanced settings) to activate these settings.', - ); - - return ( - <Cell.Footer> - <AriaDescription> - <Cell.FooterText> - {formatMarkdown(sprintf(blockingDisabledText, { customDnsFeatureName }))} - </Cell.FooterText> - </AriaDescription> - </Cell.Footer> - ); -} diff --git a/gui/src/renderer/components/PreferencesStyles.tsx b/gui/src/renderer/components/PreferencesStyles.tsx deleted file mode 100644 index 81ee5c1928..0000000000 --- a/gui/src/renderer/components/PreferencesStyles.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import styled from 'styled-components'; - -import { colors } from '../../config.json'; -import { InfoIcon } from './InfoButton'; -import { Container } from './Layout'; - -export const StyledContainer = styled(Container)({ - backgroundColor: colors.darkBlue, -}); - -export const StyledContent = styled.div({ - display: 'flex', - flexDirection: 'column', - flex: 1, - marginBottom: '2px', -}); - -export const StyledSeparator = styled.div((props: { height?: number }) => ({ - height: `${props.height ?? 1}px`, -})); - -export const StyledInfoIcon = styled(InfoIcon)({ - marginRight: '16px', -}); diff --git a/gui/src/renderer/components/ProblemReport.tsx b/gui/src/renderer/components/ProblemReport.tsx new file mode 100644 index 0000000000..d98520640a --- /dev/null +++ b/gui/src/renderer/components/ProblemReport.tsx @@ -0,0 +1,459 @@ +import * as React from 'react'; + +import { links } from '../../config.json'; +import { AccountToken } from '../../shared/daemon-rpc-types'; +import { messages } from '../../shared/gettext'; +import { IProblemReportForm } from '../redux/support/actions'; +import * as AppButton from './AppButton'; +import { AriaDescribed, AriaDescription, AriaDescriptionGroup } from './AriaGroup'; +import ImageView from './ImageView'; +import { BackAction } from './KeyboardNavigation'; +import { Layout } from './Layout'; +import { ModalAlert, ModalAlertType } from './Modal'; +import { NavigationBar, NavigationItems, TitleBarItem } from './NavigationBar'; +import { + StyledBlueButton, + StyledContainer, + StyledContent, + StyledContentContainer, + StyledEmail, + StyledEmailInput, + StyledFooter, + StyledForm, + StyledFormEmailRow, + StyledFormMessageRow, + StyledMessageInput, + StyledSendStatus, + StyledSentMessage, + StyledStatusIcon, + StyledThanks, +} from './ProblemReportStyles'; +import SettingsHeader, { HeaderSubTitle, HeaderTitle } from './SettingsHeader'; + +enum SendState { + initial, + confirm, + sending, + success, + failed, +} + +interface IProblemReportState { + email: string; + message: string; + savedReportId?: string; + sendState: SendState; + disableActions: boolean; + showOutdatedVersionWarning: boolean; +} + +interface IProblemReportProps { + defaultEmail: string; + defaultMessage: string; + accountHistory?: AccountToken; + isOffline: boolean; + onClose: () => void; + viewLog: (path: string) => void; + saveReportForm: (form: IProblemReportForm) => void; + clearReportForm: () => void; + collectProblemReport: (accountToRedact?: string) => Promise<string>; + sendProblemReport: (email: string, message: string, savedReportId: string) => Promise<void>; + outdatedVersion: boolean; + suggestedIsBeta: boolean; + onExternalLink: (url: string) => void; +} + +export default class ProblemReport extends React.Component< + IProblemReportProps, + IProblemReportState +> { + public state = { + email: '', + message: '', + savedReportId: undefined, + sendState: SendState.initial, + disableActions: false, + showOutdatedVersionWarning: false, + }; + + private collectLogPromise?: Promise<string>; + + constructor(props: IProblemReportProps) { + super(props); + + // seed initial data from props + this.state.email = props.defaultEmail; + this.state.message = props.defaultMessage; + this.state.showOutdatedVersionWarning = props.outdatedVersion; + } + + public validate() { + return this.state.message.trim().length > 0; + } + + public onChangeEmail = (event: React.ChangeEvent<HTMLInputElement>) => { + this.setState({ email: event.target.value }, () => { + this.saveFormData(); + }); + }; + + public onChangeDescription = (event: React.ChangeEvent<HTMLTextAreaElement>) => { + this.setState({ message: event.target.value }, () => { + this.saveFormData(); + }); + }; + + public onViewLog = () => { + this.performWithActionsDisabled(async () => { + try { + const reportId = await this.collectLog(); + this.props.viewLog(reportId); + } catch (error) { + // TODO: handle error + } + }); + }; + + public onSend = async (): Promise<void> => { + const sendState = this.state.sendState; + if (sendState === SendState.initial && this.state.email.length === 0) { + this.setState({ sendState: SendState.confirm }); + } else if ( + sendState === SendState.initial || + sendState === SendState.confirm || + sendState === SendState.failed + ) { + try { + await this.sendReport(); + } catch (error) { + // No-op + } + } + }; + + public onCancelNoEmailDialog = () => { + this.setState({ sendState: SendState.initial }); + }; + + public render() { + const { sendState } = this.state; + const header = ( + <SettingsHeader> + <HeaderTitle>{messages.pgettext('support-view', 'Report a problem')}</HeaderTitle> + {(sendState === SendState.initial || sendState === SendState.confirm) && ( + <HeaderSubTitle> + {messages.pgettext( + 'support-view', + "To help you more effectively, your app's log file will be attached to this message. Your data will remain secure and private, as it is anonymised before being sent over an encrypted channel.", + )} + </HeaderSubTitle> + )} + </SettingsHeader> + ); + + const content = this.renderContent(); + + return ( + <BackAction action={this.props.onClose}> + <Layout> + <StyledContainer> + <NavigationBar> + <NavigationItems> + <TitleBarItem> + { + // TRANSLATORS: Title label in navigation bar + messages.pgettext('support-view', 'Report a problem') + } + </TitleBarItem> + </NavigationItems> + </NavigationBar> + <StyledContentContainer> + {header} + {content} + </StyledContentContainer> + + {this.renderNoEmailDialog()} + {this.renderOutdateVersionWarningDialog()} + </StyledContainer> + </Layout> + </BackAction> + ); + } + + private saveFormData() { + this.props.saveReportForm({ + email: this.state.email, + message: this.state.message, + }); + } + + private async collectLog(): Promise<string> { + if (this.collectLogPromise) { + return this.collectLogPromise; + } else { + const collectPromise = this.props.collectProblemReport(this.props.accountHistory); + + // save promise to prevent subsequent requests + this.collectLogPromise = collectPromise; + + try { + const reportId = await collectPromise; + return new Promise((resolve) => { + this.setState({ savedReportId: reportId }, () => resolve(reportId)); + }); + } catch (error) { + this.collectLogPromise = undefined; + + throw error; + } + } + } + + private sendReport(): Promise<void> { + return new Promise((resolve, reject) => { + this.setState({ sendState: SendState.sending }, async () => { + try { + const { email, message } = this.state; + const reportId = await this.collectLog(); + await this.props.sendProblemReport(email, message, reportId); + this.props.clearReportForm(); + this.setState({ sendState: SendState.success }, () => { + resolve(); + }); + } catch (error) { + this.setState({ sendState: SendState.failed }, () => { + reject(error); + }); + } + }); + }); + } + + private renderContent() { + switch (this.state.sendState) { + case SendState.initial: + case SendState.confirm: + return this.renderForm(); + case SendState.sending: + return this.renderSending(); + case SendState.success: + return this.renderSent(); + case SendState.failed: + return this.renderFailed(); + default: + return null; + } + } + + private renderNoEmailDialog() { + const message = messages.pgettext( + 'support-view', + 'You are about to send the problem report without a way for us to get back to you. If you want an answer to your report you will have to enter an email address.', + ); + return ( + <ModalAlert + isOpen={this.state.sendState === SendState.confirm} + type={ModalAlertType.warning} + message={message} + buttons={[ + <AppButton.RedButton key="proceed" onClick={this.onSend}> + {messages.pgettext('support-view', 'Send anyway')} + </AppButton.RedButton>, + <AppButton.BlueButton key="cancel" onClick={this.onCancelNoEmailDialog}> + {messages.gettext('Back')} + </AppButton.BlueButton>, + ]} + close={this.onCancelNoEmailDialog} + /> + ); + } + + private acknowledgeOutdateVersion = () => { + this.setState({ showOutdatedVersionWarning: false }); + }; + + private openDownloadLink = () => + this.props.onExternalLink(this.props.suggestedIsBeta ? links.betaDownload : links.download); + + private renderOutdateVersionWarningDialog() { + const message = messages.pgettext( + 'support-view', + 'You are using an old version of the app. Please upgrade and see if the problem still exists before sending a report.', + ); + return ( + <ModalAlert + isOpen={this.state.showOutdatedVersionWarning} + type={ModalAlertType.warning} + message={message} + buttons={[ + <AriaDescriptionGroup key="upgrade"> + <AriaDescribed> + <AppButton.GreenButton + disabled={this.props.isOffline} + onClick={this.openDownloadLink}> + <AppButton.Label> + {messages.pgettext('support-view', 'Upgrade app')} + </AppButton.Label> + <AriaDescription> + <AppButton.Icon + height={16} + width={16} + source="icon-extLink" + aria-label={messages.pgettext('accessibility', 'Opens externally')} + /> + </AriaDescription> + </AppButton.GreenButton> + </AriaDescribed> + </AriaDescriptionGroup>, + <AppButton.RedButton key="proceed" onClick={this.acknowledgeOutdateVersion}> + {messages.pgettext('support-view', 'Continue anyway')} + </AppButton.RedButton>, + <AppButton.BlueButton key="cancel" onClick={this.outdatedVersionCancel}> + {messages.gettext('Cancel')} + </AppButton.BlueButton>, + ]} + close={this.props.onClose} + /> + ); + } + + private outdatedVersionCancel = () => { + this.acknowledgeOutdateVersion(); + this.props.onClose(); + }; + + private renderForm() { + return ( + <StyledContent> + <StyledForm> + <StyledFormEmailRow> + <StyledEmailInput + placeholder={messages.pgettext('support-view', 'Your email (optional)')} + defaultValue={this.state.email} + onChange={this.onChangeEmail} + /> + </StyledFormEmailRow> + <StyledFormMessageRow> + <StyledMessageInput + placeholder={messages.pgettext( + 'support-view', + 'Please describe your problem in English or Swedish.', + )} + defaultValue={this.state.message} + onChange={this.onChangeDescription} + /> + </StyledFormMessageRow> + </StyledForm> + <StyledFooter> + <AriaDescriptionGroup> + <AriaDescribed> + <StyledBlueButton onClick={this.onViewLog} disabled={this.state.disableActions}> + <AppButton.Label> + {messages.pgettext('support-view', 'View app logs')} + </AppButton.Label> + <AriaDescription> + <AppButton.Icon + source="icon-extLink" + height={16} + width={16} + aria-label={messages.pgettext('accessibility', 'Opens externally')} + /> + </AriaDescription> + </StyledBlueButton> + </AriaDescribed> + </AriaDescriptionGroup> + <AppButton.GreenButton + disabled={!this.validate() || this.state.disableActions} + onClick={this.onSend}> + {messages.pgettext('support-view', 'Send')} + </AppButton.GreenButton> + </StyledFooter> + </StyledContent> + ); + } + + private renderSending() { + return ( + <StyledContent> + <StyledForm> + <StyledStatusIcon> + <ImageView source="icon-spinner" height={60} width={60} /> + </StyledStatusIcon> + <StyledSendStatus>{messages.pgettext('support-view', 'Sending...')}</StyledSendStatus> + </StyledForm> + </StyledContent> + ); + } + + private renderSent() { + const reachBackMessage: React.ReactNodeArray = + // TRANSLATORS: The message displayed to the user after submitting the problem report, given that the user left his or her email for us to reach back. + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(email)s + messages + .pgettext('support-view', 'If needed we will contact you at %(email)s') + .split('%(email)s', 2); + reachBackMessage.splice(1, 0, <StyledEmail key="email">{this.state.email}</StyledEmail>); + + return ( + <StyledContent> + <StyledForm> + <StyledStatusIcon> + <ImageView source="icon-success" height={60} width={60} /> + </StyledStatusIcon> + <StyledSendStatus>{messages.pgettext('support-view', 'Sent')}</StyledSendStatus> + + <StyledSentMessage> + <StyledThanks>{messages.pgettext('support-view', 'Thanks!')} </StyledThanks> + {messages.pgettext('support-view', 'We will look into this.')} + </StyledSentMessage> + {this.state.email.trim().length > 0 ? ( + <StyledSentMessage>{reachBackMessage}</StyledSentMessage> + ) : null} + </StyledForm> + </StyledContent> + ); + } + + private renderFailed() { + return ( + <StyledContent> + <StyledForm> + <StyledStatusIcon> + <ImageView source="icon-fail" height={60} width={60} /> + </StyledStatusIcon> + <StyledSendStatus>{messages.pgettext('support-view', 'Failed to send')}</StyledSendStatus> + <StyledSentMessage> + {messages.pgettext( + 'support-view', + 'If you exit the form and try again later, the information you already entered will still be here.', + )} + </StyledSentMessage> + </StyledForm> + <StyledFooter> + <StyledBlueButton onClick={this.handleEditMessage}> + {messages.pgettext('support-view', 'Edit message')} + </StyledBlueButton> + <AppButton.GreenButton onClick={this.onSend}> + {messages.pgettext('support-view', 'Try again')} + </AppButton.GreenButton> + </StyledFooter> + </StyledContent> + ); + } + + private handleEditMessage = () => { + this.setState({ sendState: SendState.initial }); + }; + + private performWithActionsDisabled(work: () => Promise<void>) { + this.setState({ disableActions: true }, async () => { + try { + await work(); + } catch { + // TODO: handle error + } + this.setState({ disableActions: false }); + }); + } +} diff --git a/gui/src/renderer/components/SupportStyles.tsx b/gui/src/renderer/components/ProblemReportStyles.tsx index bc5166297c..bc5166297c 100644 --- a/gui/src/renderer/components/SupportStyles.tsx +++ b/gui/src/renderer/components/ProblemReportStyles.tsx diff --git a/gui/src/renderer/components/Settings.tsx b/gui/src/renderer/components/Settings.tsx index 2deb1ee0e7..812deef142 100644 --- a/gui/src/renderer/components/Settings.tsx +++ b/gui/src/renderer/components/Settings.tsx @@ -1,10 +1,12 @@ -import * as React from 'react'; +import { useCallback, useEffect } from 'react'; import { colors, links } from '../../config.json'; import { formatRemainingTime, hasExpired } from '../../shared/account-expiry'; import { messages } from '../../shared/gettext'; -import History from '../lib/history'; -import { LoginState } from '../redux/account/reducers'; +import { useAppContext } from '../context'; +import { useHistory } from '../lib/history'; +import { RoutePath } from '../lib/routes'; +import { useSelector } from '../redux/store'; import { AriaDescribed, AriaDescription, AriaDescriptionGroup } from './AriaGroup'; import * as Cell from './cell'; import { BackAction } from './KeyboardNavigation'; @@ -13,7 +15,6 @@ import { NavigationBar, NavigationContainer, NavigationItems, TitleBarItem } fro import SettingsHeader, { HeaderTitle } from './SettingsHeader'; import { StyledCellIcon, - StyledCellSpacer, StyledContainer, StyledContent, StyledNavigationScrollbars, @@ -22,238 +23,226 @@ import { StyledSettingsContent, } from './SettingsStyles'; -export interface IProps { - preferredLocaleDisplayName: string; - loginState: LoginState; - connectedToDaemon: boolean; - accountExpiry?: string; - appVersion: string; - consistentVersion: boolean; - upToDateVersion: boolean; - suggestedIsBeta: boolean; - isOffline: boolean; - onQuit: () => void; - onClose: () => void; - onViewSelectLanguage: () => void; - onViewAccount: () => void; - onViewSupport: () => void; - onViewPreferences: () => void; - onViewAdvancedSettings: () => void; - onExternalLink: (url: string) => void; - updateAccountData: () => void; - history: History; -} +export default function Support() { + const history = useHistory(); + const { updateAccountData } = useAppContext(); -export default class Settings extends React.Component<IProps> { - public componentDidMount() { - if (this.props.history.action === 'PUSH') { - this.props.updateAccountData(); + useEffect(() => { + if (history.action === 'PUSH') { + updateAccountData(); } - } + }, []); + + const loginState = useSelector((state) => state.account.status); + const connectedToDaemon = useSelector((state) => state.userInterface.connectedToDaemon); + + const showLargeTitle = loginState.type !== 'ok'; + const showSubSettings = loginState.type === 'ok' && connectedToDaemon; - public render() { - const showLargeTitle = this.props.loginState.type !== 'ok'; + return ( + <BackAction icon="close" action={history.dismiss}> + <Layout> + <StyledContainer> + <NavigationContainer> + <NavigationBar alwaysDisplayBarTitle={!showLargeTitle}> + <NavigationItems> + <TitleBarItem> + { + // TRANSLATORS: Title label in navigation bar + messages.pgettext('navigation-bar', 'Settings') + } + </TitleBarItem> + </NavigationItems> + </NavigationBar> - return ( - <BackAction icon="close" action={this.props.onClose}> - <Layout> - <StyledContainer> - <NavigationContainer> - <NavigationBar alwaysDisplayBarTitle={!showLargeTitle}> - <NavigationItems> - <TitleBarItem> - { - // TRANSLATORS: Title label in navigation bar - messages.pgettext('navigation-bar', 'Settings') - } - </TitleBarItem> - </NavigationItems> - </NavigationBar> + <StyledNavigationScrollbars fillContainer> + <StyledContent> + {showLargeTitle && ( + <SettingsHeader> + <HeaderTitle>{messages.pgettext('navigation-bar', 'Settings')}</HeaderTitle> + </SettingsHeader> + )} - <StyledNavigationScrollbars fillContainer> - <StyledContent> - {showLargeTitle && ( - <SettingsHeader> - <HeaderTitle>{messages.pgettext('navigation-bar', 'Settings')}</HeaderTitle> - </SettingsHeader> + <StyledSettingsContent> + {showSubSettings && ( + <> + <Cell.Group> + <AccountButton /> + <InterfaceSettingsButton /> + <VpnSettingsButton /> + </Cell.Group> + + {(window.env.platform === 'linux' || window.env.platform === 'win32') && ( + <Cell.Group> + <SplitTunnelingButton /> + </Cell.Group> + )} + </> )} - <StyledSettingsContent> - {this.renderTopButtons()} - {this.renderMiddleButtons()} - {this.renderBottomButtons()} - </StyledSettingsContent> - </StyledContent> + <Cell.Group> + <SupportButton /> + <AppVersionButton /> + </Cell.Group> + </StyledSettingsContent> + </StyledContent> - {this.renderQuitButton()} - </StyledNavigationScrollbars> - </NavigationContainer> - </StyledContainer> - </Layout> - </BackAction> - ); - } + <QuitButton /> + </StyledNavigationScrollbars> + </NavigationContainer> + </StyledContainer> + </Layout> + </BackAction> + ); +} - private openDownloadLink = () => - this.props.onExternalLink(this.props.suggestedIsBeta ? links.betaDownload : links.download); - private openFaqLink = () => this.props.onExternalLink(links.faq); +function AccountButton() { + const history = useHistory(); + const navigate = useCallback(() => history.push(RoutePath.accountSettings), [history]); - private renderQuitButton() { - return ( - <StyledQuitButton onClick={this.props.onQuit}> - {messages.pgettext('settings-view', 'Quit app')} - </StyledQuitButton> - ); - } + const accountExpiry = useSelector((state) => state.account.expiry); + const isOutOfTime = accountExpiry ? hasExpired(accountExpiry) : false; + const formattedExpiry = accountExpiry ? formatRemainingTime(accountExpiry).toUpperCase() : ''; + const outOfTimeMessage = messages.pgettext('settings-view', 'OUT OF TIME'); - private renderTopButtons() { - const isLoggedIn = this.props.loginState.type === 'ok'; - if (!isLoggedIn || !this.props.connectedToDaemon) { - return null; - } + return ( + <Cell.CellNavigationButton onClick={navigate}> + <Cell.Label> + { + // TRANSLATORS: Navigation button to the 'Account' view + messages.pgettext('settings-view', 'Account') + } + </Cell.Label> + <StyledOutOfTimeSubText isOutOfTime={isOutOfTime}> + {isOutOfTime ? outOfTimeMessage : formattedExpiry} + </StyledOutOfTimeSubText> + </Cell.CellNavigationButton> + ); +} - const isOutOfTime = this.props.accountExpiry ? hasExpired(this.props.accountExpiry) : false; - const formattedExpiry = this.props.accountExpiry - ? formatRemainingTime(this.props.accountExpiry).toUpperCase() - : ''; +function InterfaceSettingsButton() { + const history = useHistory(); + const navigate = useCallback(() => history.push(RoutePath.interfaceSettings), [history]); - const outOfTimeMessage = messages.pgettext('settings-view', 'OUT OF TIME'); + return ( + <Cell.CellNavigationButton onClick={navigate}> + <Cell.Label> + { + // TRANSLATORS: Navigation button to the 'Interface settings' view + messages.pgettext('settings-view', 'Interface settings') + } + </Cell.Label> + </Cell.CellNavigationButton> + ); +} - return ( - <> - <Cell.CellButton onClick={this.props.onViewAccount}> - <Cell.Label> - { - // TRANSLATORS: Navigation button to the 'Account' view - messages.pgettext('settings-view', 'Account') - } - </Cell.Label> - <StyledOutOfTimeSubText isOutOfTime={isOutOfTime}> - {isOutOfTime ? outOfTimeMessage : formattedExpiry} - </StyledOutOfTimeSubText> - <Cell.Icon height={12} width={7} source="icon-chevron" /> - </Cell.CellButton> +function VpnSettingsButton() { + const history = useHistory(); + const navigate = useCallback(() => history.push(RoutePath.vpnSettings), [history]); - <Cell.CellButton onClick={this.props.onViewPreferences}> - <Cell.Label> - { - // TRANSLATORS: Navigation button to the 'Preferences' view - messages.pgettext('settings-view', 'Preferences') - } - </Cell.Label> - <Cell.Icon height={12} width={7} source="icon-chevron" /> - </Cell.CellButton> + return ( + <Cell.CellNavigationButton onClick={navigate}> + <Cell.Label> + { + // TRANSLATORS: Navigation button to the 'VPN settings' view + messages.pgettext('settings-view', 'VPN settings') + } + </Cell.Label> + </Cell.CellNavigationButton> + ); +} - <Cell.CellButton onClick={this.props.onViewAdvancedSettings}> - <Cell.Label> - { - // TRANSLATORS: Navigation button to the 'Advanced' settings view - messages.pgettext('settings-view', 'Advanced') - } - </Cell.Label> - <Cell.Icon height={12} width={7} source="icon-chevron" /> - </Cell.CellButton> - <StyledCellSpacer /> - </> - ); - } +function SplitTunnelingButton() { + const history = useHistory(); + const navigate = useCallback(() => history.push(RoutePath.splitTunneling), [history]); + + return ( + <Cell.CellNavigationButton onClick={navigate}> + <Cell.Label> + { + // TRANSLATORS: Navigation button to the 'Split tunneling' view + messages.pgettext('settings-view', 'Split tunneling') + } + </Cell.Label> + </Cell.CellNavigationButton> + ); +} - private renderMiddleButtons() { - let icon; - let footer; - if (!this.props.consistentVersion || !this.props.upToDateVersion) { - const inconsistentVersionMessage = messages.pgettext( - 'settings-view', - 'App is out of sync. Please quit and restart.', - ); +function AppVersionButton() { + const appVersion = useSelector((state) => state.version.current); + const consistentVersion = useSelector((state) => state.version.consistent); + const upToDateVersion = useSelector((state) => (state.version.suggestedUpgrade ? false : true)); + const suggestedIsBeta = useSelector((state) => state.version.suggestedIsBeta ?? false); + const isOffline = useSelector((state) => state.connection.isBlocked); - const updateAvailableMessage = messages.pgettext( - 'settings-view', - 'Update available. Install the latest app version to stay up to date.', - ); + const { openUrl } = useAppContext(); + const openDownloadLink = useCallback( + () => openUrl(suggestedIsBeta ? links.betaDownload : links.download), + [openUrl, suggestedIsBeta], + ); - const message = !this.props.consistentVersion - ? inconsistentVersionMessage - : updateAvailableMessage; + let icon; + let footer; + if (!consistentVersion || !upToDateVersion) { + const inconsistentVersionMessage = messages.pgettext( + 'settings-view', + 'App is out of sync. Please quit and restart.', + ); - icon = <StyledCellIcon source="icon-alert" width={18} tintColor={colors.red} />; - footer = ( - <Cell.Footer> - <Cell.FooterText>{message}</Cell.FooterText> - </Cell.Footer> - ); - } else { - footer = <StyledCellSpacer />; - } + const updateAvailableMessage = messages.pgettext( + 'settings-view', + 'Update available. Install the latest app version to stay up to date.', + ); - return ( - <AriaDescriptionGroup> - <AriaDescribed> - <Cell.CellButton disabled={this.props.isOffline} onClick={this.openDownloadLink}> - {icon} - <Cell.Label>{messages.pgettext('settings-view', 'App version')}</Cell.Label> - <Cell.SubText>{this.props.appVersion}</Cell.SubText> - <AriaDescription> - <Cell.Icon - height={16} - width={16} - source="icon-extLink" - aria-label={messages.pgettext('accessibility', 'Opens externally')} - /> - </AriaDescription> - </Cell.CellButton> - </AriaDescribed> - {footer} - </AriaDescriptionGroup> + const message = !consistentVersion ? inconsistentVersionMessage : updateAvailableMessage; + + icon = <StyledCellIcon source="icon-alert" width={18} tintColor={colors.red} />; + footer = ( + <Cell.Footer> + <Cell.FooterText>{message}</Cell.FooterText> + </Cell.Footer> ); } - private renderBottomButtons() { - return ( - <> - <Cell.CellButton onClick={this.props.onViewSupport}> - <Cell.Label> - { - // TRANSLATORS: Navigation button to the 'Report a problem' help view - messages.pgettext('settings-view', 'Report a problem') - } - </Cell.Label> - <Cell.Icon height={12} width={7} source="icon-chevron" /> + return ( + <AriaDescriptionGroup> + <AriaDescribed> + <Cell.CellButton disabled={isOffline} onClick={openDownloadLink}> + {icon} + <Cell.Label>{messages.pgettext('settings-view', 'App version')}</Cell.Label> + <Cell.SubText>{appVersion}</Cell.SubText> + <AriaDescription> + <Cell.Icon + height={16} + width={16} + source="icon-extLink" + aria-label={messages.pgettext('accessibility', 'Opens externally')} + /> + </AriaDescription> </Cell.CellButton> + </AriaDescribed> + {footer} + </AriaDescriptionGroup> + ); +} - <AriaDescriptionGroup> - <AriaDescribed> - <Cell.CellButton disabled={this.props.isOffline} onClick={this.openFaqLink}> - <Cell.Label> - { - // TRANSLATORS: Link to the webpage - messages.pgettext('settings-view', 'FAQs & Guides') - } - </Cell.Label> - <AriaDescription> - <Cell.Icon - height={16} - width={16} - source="icon-extLink" - aria-label={messages.pgettext('accessibility', 'Opens externally')} - /> - </AriaDescription> - </Cell.CellButton> - </AriaDescribed> - </AriaDescriptionGroup> +function SupportButton() { + const history = useHistory(); + const navigate = useCallback(() => history.push(RoutePath.support), [history]); - <Cell.CellButton onClick={this.props.onViewSelectLanguage}> - <StyledCellIcon width={24} height={24} source="icon-language" /> - <Cell.Label> - { - // TRANSLATORS: Navigation button to the 'Language' settings view - messages.pgettext('settings-view', 'Language') - } - </Cell.Label> - <Cell.SubText>{this.props.preferredLocaleDisplayName}</Cell.SubText> - <Cell.Icon height={12} width={7} source="icon-chevron" /> - </Cell.CellButton> - </> - ); - } + return ( + <Cell.CellNavigationButton onClick={navigate}> + <Cell.Label>{messages.pgettext('settings-view', 'Support')}</Cell.Label> + </Cell.CellNavigationButton> + ); +} + +function QuitButton() { + const { quit } = useAppContext(); + + return ( + <StyledQuitButton onClick={quit}> + {messages.pgettext('settings-view', 'Quit app')} + </StyledQuitButton> + ); } diff --git a/gui/src/renderer/components/SettingsStyles.tsx b/gui/src/renderer/components/SettingsStyles.tsx index 88a4c8258a..687b448424 100644 --- a/gui/src/renderer/components/SettingsStyles.tsx +++ b/gui/src/renderer/components/SettingsStyles.tsx @@ -34,12 +34,6 @@ export const StyledSettingsContent = styled.div({ flexDirection: 'column', }); -export const StyledCellSpacer = styled.div({ - height: '20px', - minHeight: '20px', - flex: 0, -}); - export const StyledQuitButton = styled(AppButton.RedButton)({ margin: '20px 22px 22px', }); diff --git a/gui/src/renderer/components/Support.tsx b/gui/src/renderer/components/Support.tsx index 9b445e25c9..ef2c82dd4e 100644 --- a/gui/src/renderer/components/Support.tsx +++ b/gui/src/renderer/components/Support.tsx @@ -1,456 +1,159 @@ -import * as React from 'react'; +import { useCallback } from 'react'; +import styled from 'styled-components'; -import { links } from '../../config.json'; -import { AccountToken } from '../../shared/daemon-rpc-types'; +import { colors, links } from '../../config.json'; import { messages } from '../../shared/gettext'; -import { ISupportReportForm } from '../redux/support/actions'; -import * as AppButton from './AppButton'; -import { AriaDescribed, AriaDescription, AriaDescriptionGroup } from './AriaGroup'; -import ImageView from './ImageView'; +import { useAppContext } from '../context'; +import { useHistory } from '../lib/history'; +import { RoutePath } from '../lib/routes'; +import { useSelector } from '../redux/store'; +import { + AriaDescribed, + AriaDescription, + AriaDescriptionGroup, + AriaInput, + AriaInputGroup, + AriaLabel, +} from './AriaGroup'; +import * as Cell from './cell'; import { BackAction } from './KeyboardNavigation'; -import { Layout } from './Layout'; -import { ModalAlert, ModalAlertType } from './Modal'; -import { NavigationBar, NavigationItems, TitleBarItem } from './NavigationBar'; -import SettingsHeader, { HeaderSubTitle, HeaderTitle } from './SettingsHeader'; +import { Container, Layout } from './Layout'; import { - StyledBlueButton, - StyledContainer, - StyledContent, - StyledContentContainer, - StyledEmail, - StyledEmailInput, - StyledFooter, - StyledForm, - StyledFormEmailRow, - StyledFormMessageRow, - StyledMessageInput, - StyledSendStatus, - StyledSentMessage, - StyledStatusIcon, - StyledThanks, -} from './SupportStyles'; - -enum SendState { - initial, - confirm, - sending, - success, - failed, -} - -interface ISupportState { - email: string; - message: string; - savedReportId?: string; - sendState: SendState; - disableActions: boolean; - showOutdatedVersionWarning: boolean; -} - -interface ISupportProps { - defaultEmail: string; - defaultMessage: string; - accountHistory?: AccountToken; - isOffline: boolean; - onClose: () => void; - viewLog: (path: string) => void; - saveReportForm: (form: ISupportReportForm) => void; - clearReportForm: () => void; - collectProblemReport: (accountToRedact?: string) => Promise<string>; - sendProblemReport: (email: string, message: string, savedReportId: string) => Promise<void>; - outdatedVersion: boolean; - suggestedIsBeta: boolean; - onExternalLink: (url: string) => void; -} - -export default class Support extends React.Component<ISupportProps, ISupportState> { - public state = { - email: '', - message: '', - savedReportId: undefined, - sendState: SendState.initial, - disableActions: false, - showOutdatedVersionWarning: false, - }; - - private collectLogPromise?: Promise<string>; - - constructor(props: ISupportProps) { - super(props); - - // seed initial data from props - this.state.email = props.defaultEmail; - this.state.message = props.defaultMessage; - this.state.showOutdatedVersionWarning = props.outdatedVersion; - } - - public validate() { - return this.state.message.trim().length > 0; - } - - public onChangeEmail = (event: React.ChangeEvent<HTMLInputElement>) => { - this.setState({ email: event.target.value }, () => { - this.saveFormData(); - }); - }; - - public onChangeDescription = (event: React.ChangeEvent<HTMLTextAreaElement>) => { - this.setState({ message: event.target.value }, () => { - this.saveFormData(); - }); - }; - - public onViewLog = () => { - this.performWithActionsDisabled(async () => { - try { - const reportId = await this.collectLog(); - this.props.viewLog(reportId); - } catch (error) { - // TODO: handle error - } - }); - }; + NavigationBar, + NavigationContainer, + NavigationItems, + NavigationScrollbars, + TitleBarItem, +} from './NavigationBar'; +import SettingsHeader, { HeaderTitle } from './SettingsHeader'; - public onSend = async (): Promise<void> => { - const sendState = this.state.sendState; - if (sendState === SendState.initial && this.state.email.length === 0) { - this.setState({ sendState: SendState.confirm }); - } else if ( - sendState === SendState.initial || - sendState === SendState.confirm || - sendState === SendState.failed - ) { - try { - await this.sendReport(); - } catch (error) { - // No-op - } - } - }; +const StyledContainer = styled(Container)({ + backgroundColor: colors.darkBlue, +}); - public onCancelNoEmailDialog = () => { - this.setState({ sendState: SendState.initial }); - }; +const StyledContent = styled.div({ + display: 'flex', + flexDirection: 'column', + flex: 1, + marginBottom: '2px', +}); - public render() { - const { sendState } = this.state; - const header = ( - <SettingsHeader> - <HeaderTitle>{messages.pgettext('support-view', 'Report a problem')}</HeaderTitle> - {(sendState === SendState.initial || sendState === SendState.confirm) && ( - <HeaderSubTitle> - {messages.pgettext( - 'support-view', - "To help you more effectively, your app's log file will be attached to this message. Your data will remain secure and private, as it is anonymised before being sent over an encrypted channel.", - )} - </HeaderSubTitle> - )} - </SettingsHeader> - ); +export default function Support() { + const { pop } = useHistory(); - const content = this.renderContent(); - - return ( - <BackAction action={this.props.onClose}> - <Layout> - <StyledContainer> + return ( + <BackAction action={pop}> + <Layout> + <StyledContainer> + <NavigationContainer> <NavigationBar> <NavigationItems> <TitleBarItem> { // TRANSLATORS: Title label in navigation bar - messages.pgettext('support-view', 'Report a problem') + messages.pgettext('support-view', 'Support') } </TitleBarItem> </NavigationItems> </NavigationBar> - <StyledContentContainer> - {header} - {content} - </StyledContentContainer> - - {this.renderNoEmailDialog()} - {this.renderOutdateVersionWarningDialog()} - </StyledContainer> - </Layout> - </BackAction> - ); - } - - private saveFormData() { - this.props.saveReportForm({ - email: this.state.email, - message: this.state.message, - }); - } - - private async collectLog(): Promise<string> { - if (this.collectLogPromise) { - return this.collectLogPromise; - } else { - const collectPromise = this.props.collectProblemReport(this.props.accountHistory); - // save promise to prevent subsequent requests - this.collectLogPromise = collectPromise; + <NavigationScrollbars> + <SettingsHeader> + <HeaderTitle>{messages.pgettext('support-view', 'Support')}</HeaderTitle> + </SettingsHeader> - try { - const reportId = await collectPromise; - return new Promise((resolve) => { - this.setState({ savedReportId: reportId }, () => resolve(reportId)); - }); - } catch (error) { - this.collectLogPromise = undefined; + <StyledContent> + <Cell.Group> + <ProblemReportButton /> + <FaqButton /> + </Cell.Group> - throw error; - } - } - } - - private sendReport(): Promise<void> { - return new Promise((resolve, reject) => { - this.setState({ sendState: SendState.sending }, async () => { - try { - const { email, message } = this.state; - const reportId = await this.collectLog(); - await this.props.sendProblemReport(email, message, reportId); - this.props.clearReportForm(); - this.setState({ sendState: SendState.success }, () => { - resolve(); - }); - } catch (error) { - this.setState({ sendState: SendState.failed }, () => { - reject(error); - }); - } - }); - }); - } - - private renderContent() { - switch (this.state.sendState) { - case SendState.initial: - case SendState.confirm: - return this.renderForm(); - case SendState.sending: - return this.renderSending(); - case SendState.success: - return this.renderSent(); - case SendState.failed: - return this.renderFailed(); - default: - return null; - } - } + <Cell.Group> + <BetaProgramSetting /> + </Cell.Group> + </StyledContent> + </NavigationScrollbars> + </NavigationContainer> + </StyledContainer> + </Layout> + </BackAction> + ); +} - private renderNoEmailDialog() { - const message = messages.pgettext( - 'support-view', - 'You are about to send the problem report without a way for us to get back to you. If you want an answer to your report you will have to enter an email address.', - ); - return ( - <ModalAlert - isOpen={this.state.sendState === SendState.confirm} - type={ModalAlertType.warning} - message={message} - buttons={[ - <AppButton.RedButton key="proceed" onClick={this.onSend}> - {messages.pgettext('support-view', 'Send anyway')} - </AppButton.RedButton>, - <AppButton.BlueButton key="cancel" onClick={this.onCancelNoEmailDialog}> - {messages.gettext('Back')} - </AppButton.BlueButton>, - ]} - close={this.onCancelNoEmailDialog} - /> - ); - } +function ProblemReportButton() { + const history = useHistory(); + const clickHandler = useCallback(() => history.push(RoutePath.problemReport), [history]); - private acknowledgeOutdateVersion = () => { - this.setState({ showOutdatedVersionWarning: false }); - }; + // TRANSLATORS: Navigation button to the 'Report a problem' help view + const label = messages.pgettext('support-view', 'Report a problem'); - private openDownloadLink = () => - this.props.onExternalLink(this.props.suggestedIsBeta ? links.betaDownload : links.download); + return ( + <Cell.CellNavigationButton onClick={clickHandler}> + <Cell.Label>{label}</Cell.Label> + </Cell.CellNavigationButton> + ); +} - private renderOutdateVersionWarningDialog() { - const message = messages.pgettext( - 'support-view', - 'You are using an old version of the app. Please upgrade and see if the problem still exists before sending a report.', - ); - return ( - <ModalAlert - isOpen={this.state.showOutdatedVersionWarning} - type={ModalAlertType.warning} - message={message} - buttons={[ - <AriaDescriptionGroup key="upgrade"> - <AriaDescribed> - <AppButton.GreenButton - disabled={this.props.isOffline} - onClick={this.openDownloadLink}> - <AppButton.Label> - {messages.pgettext('support-view', 'Upgrade app')} - </AppButton.Label> - <AriaDescription> - <AppButton.Icon - height={16} - width={16} - source="icon-extLink" - aria-label={messages.pgettext('accessibility', 'Opens externally')} - /> - </AriaDescription> - </AppButton.GreenButton> - </AriaDescribed> - </AriaDescriptionGroup>, - <AppButton.RedButton key="proceed" onClick={this.acknowledgeOutdateVersion}> - {messages.pgettext('support-view', 'Continue anyway')} - </AppButton.RedButton>, - <AppButton.BlueButton key="cancel" onClick={this.outdatedVersionCancel}> - {messages.gettext('Cancel')} - </AppButton.BlueButton>, - ]} - close={this.props.onClose} - /> - ); - } +function FaqButton() { + const isOffline = useSelector((state) => state.connection.isBlocked); + const { openUrl } = useAppContext(); - private outdatedVersionCancel = () => { - this.acknowledgeOutdateVersion(); - this.props.onClose(); - }; + const openFaq = useCallback(() => openUrl(links.faq), [openUrl]); - private renderForm() { - return ( - <StyledContent> - <StyledForm> - <StyledFormEmailRow> - <StyledEmailInput - placeholder={messages.pgettext('support-view', 'Your email (optional)')} - defaultValue={this.state.email} - onChange={this.onChangeEmail} + return ( + <AriaDescriptionGroup> + <AriaDescribed> + <Cell.CellButton disabled={isOffline} onClick={openFaq}> + <Cell.Label> + { + // TRANSLATORS: Link to the webpage + messages.pgettext('support-view', 'FAQs & Guides') + } + </Cell.Label> + <AriaDescription> + <Cell.Icon + height={16} + width={16} + source="icon-extLink" + aria-label={messages.pgettext('accessibility', 'Opens externally')} /> - </StyledFormEmailRow> - <StyledFormMessageRow> - <StyledMessageInput - placeholder={messages.pgettext( - 'support-view', - 'Please describe your problem in English or Swedish.', - )} - defaultValue={this.state.message} - onChange={this.onChangeDescription} - /> - </StyledFormMessageRow> - </StyledForm> - <StyledFooter> - <AriaDescriptionGroup> - <AriaDescribed> - <StyledBlueButton onClick={this.onViewLog} disabled={this.state.disableActions}> - <AppButton.Label> - {messages.pgettext('support-view', 'View app logs')} - </AppButton.Label> - <AriaDescription> - <AppButton.Icon - source="icon-extLink" - height={16} - width={16} - aria-label={messages.pgettext('accessibility', 'Opens externally')} - /> - </AriaDescription> - </StyledBlueButton> - </AriaDescribed> - </AriaDescriptionGroup> - <AppButton.GreenButton - disabled={!this.validate() || this.state.disableActions} - onClick={this.onSend}> - {messages.pgettext('support-view', 'Send')} - </AppButton.GreenButton> - </StyledFooter> - </StyledContent> - ); - } - - private renderSending() { - return ( - <StyledContent> - <StyledForm> - <StyledStatusIcon> - <ImageView source="icon-spinner" height={60} width={60} /> - </StyledStatusIcon> - <StyledSendStatus>{messages.pgettext('support-view', 'Sending...')}</StyledSendStatus> - </StyledForm> - </StyledContent> - ); - } - - private renderSent() { - const reachBackMessage: React.ReactNodeArray = - // TRANSLATORS: The message displayed to the user after submitting the problem report, given that the user left his or her email for us to reach back. - // TRANSLATORS: Available placeholders: - // TRANSLATORS: %(email)s - messages - .pgettext('support-view', 'If needed we will contact you at %(email)s') - .split('%(email)s', 2); - reachBackMessage.splice(1, 0, <StyledEmail key="email">{this.state.email}</StyledEmail>); - - return ( - <StyledContent> - <StyledForm> - <StyledStatusIcon> - <ImageView source="icon-success" height={60} width={60} /> - </StyledStatusIcon> - <StyledSendStatus>{messages.pgettext('support-view', 'Sent')}</StyledSendStatus> - - <StyledSentMessage> - <StyledThanks>{messages.pgettext('support-view', 'Thanks!')} </StyledThanks> - {messages.pgettext('support-view', 'We will look into this.')} - </StyledSentMessage> - {this.state.email.trim().length > 0 ? ( - <StyledSentMessage>{reachBackMessage}</StyledSentMessage> - ) : null} - </StyledForm> - </StyledContent> - ); - } - - private renderFailed() { - return ( - <StyledContent> - <StyledForm> - <StyledStatusIcon> - <ImageView source="icon-fail" height={60} width={60} /> - </StyledStatusIcon> - <StyledSendStatus>{messages.pgettext('support-view', 'Failed to send')}</StyledSendStatus> - <StyledSentMessage> - {messages.pgettext( - 'support-view', - 'If you exit the form and try again later, the information you already entered will still be here.', - )} - </StyledSentMessage> - </StyledForm> - <StyledFooter> - <StyledBlueButton onClick={this.handleEditMessage}> - {messages.pgettext('support-view', 'Edit message')} - </StyledBlueButton> - <AppButton.GreenButton onClick={this.onSend}> - {messages.pgettext('support-view', 'Try again')} - </AppButton.GreenButton> - </StyledFooter> - </StyledContent> - ); - } + </AriaDescription> + </Cell.CellButton> + </AriaDescribed> + </AriaDescriptionGroup> + ); +} - private handleEditMessage = () => { - this.setState({ sendState: SendState.initial }); - }; +function BetaProgramSetting() { + const isBeta = useSelector((state) => state.version.isBeta); + const showBetaReleases = useSelector((state) => state.settings.showBetaReleases); + const { setShowBetaReleases } = useAppContext(); - private performWithActionsDisabled(work: () => Promise<void>) { - this.setState({ disableActions: true }, async () => { - try { - await work(); - } catch { - // TODO: handle error - } - this.setState({ disableActions: false }); - }); - } + return ( + <AriaInputGroup> + <Cell.Container disabled={isBeta}> + <AriaLabel> + <Cell.InputLabel>{messages.pgettext('support-view', 'Beta program')}</Cell.InputLabel> + </AriaLabel> + <AriaInput> + <Cell.Switch isOn={showBetaReleases} onChange={setShowBetaReleases} /> + </AriaInput> + </Cell.Container> + <Cell.Footer> + <AriaDescription> + <Cell.FooterText> + {isBeta + ? messages.pgettext( + 'support-view', + 'This option is unavailable while using a beta version.', + ) + : messages.pgettext( + 'support-view', + 'Enable to get notified when new beta versions of the app are released.', + )} + </Cell.FooterText> + </AriaDescription> + </Cell.Footer> + </AriaInputGroup> + ); } diff --git a/gui/src/renderer/components/TooManyDevices.tsx b/gui/src/renderer/components/TooManyDevices.tsx index 5df8da0df3..aeed50afd2 100644 --- a/gui/src/renderer/components/TooManyDevices.tsx +++ b/gui/src/renderer/components/TooManyDevices.tsx @@ -67,19 +67,10 @@ const StyledLabel = styled.span({ margin: '0 22px 18px', }); -const StyledDeviceList = styled(Cell.CellButtonGroup)({ - marginBottom: 0, - flex: '0 0', -}); - const StyledSpacer = styled.div({ flex: '1', }); -const StyledCellContainer = styled(Cell.Container)({ - marginBottom: '1px', -}); - const StyledDeviceName = styled(Cell.Label)({ textTransform: 'capitalize', }); @@ -175,11 +166,9 @@ interface IDeviceListProps { function DeviceList(props: IDeviceListProps) { return ( <StyledSpacer> - <StyledDeviceList> - <List items={props.devices} getKey={getDeviceKey}> - {(device) => <Device device={device} onRemove={props.onRemoveDevice} />} - </List> - </StyledDeviceList> + <List items={props.devices} getKey={getDeviceKey}> + {(device) => <Device device={device} onRemove={props.onRemoveDevice} />} + </List> </StyledSpacer> ); } @@ -205,7 +194,7 @@ function Device(props: IDeviceProps) { return ( <> - <StyledCellContainer> + <Cell.Container> <StyledDeviceName aria-hidden>{props.device.name}</StyledDeviceName> <StyledRemoveDeviceButton onClick={showConfirmation} @@ -223,7 +212,7 @@ function Device(props: IDeviceProps) { tintHoverColor={colors.white60} /> </StyledRemoveDeviceButton> - </StyledCellContainer> + </Cell.Container> <ModalAlert isOpen={confirmationVisible} type={ModalAlertType.warning} diff --git a/gui/src/renderer/components/VpnSettings.tsx b/gui/src/renderer/components/VpnSettings.tsx new file mode 100644 index 0000000000..03274c0b2a --- /dev/null +++ b/gui/src/renderer/components/VpnSettings.tsx @@ -0,0 +1,722 @@ +import { useCallback, useMemo } from 'react'; +import { sprintf } from 'sprintf-js'; +import styled from 'styled-components'; + +import { colors, strings } from '../../config.json'; +import { IDnsOptions, TunnelProtocol } from '../../shared/daemon-rpc-types'; +import { messages } from '../../shared/gettext'; +import log from '../../shared/logging'; +import RelaySettingsBuilder from '../../shared/relay-settings-builder'; +import { useAppContext } from '../context'; +import { useHistory } from '../lib/history'; +import { RoutePath } from '../lib/routes'; +import { useBoolean } from '../lib/utilityHooks'; +import { formatMarkdown } from '../markdown-formatter'; +import { RelaySettingsRedux } from '../redux/settings/reducers'; +import { useSelector } from '../redux/store'; +import * as AppButton from './AppButton'; +import { AriaDescription, AriaDetails, AriaInput, AriaInputGroup, AriaLabel } from './AriaGroup'; +import * as Cell from './cell'; +import Selector, { ISelectorItem } from './cell/Selector'; +import CustomDnsSettings from './CustomDnsSettings'; +import InfoButton, { InfoIcon } from './InfoButton'; +import { BackAction } from './KeyboardNavigation'; +import { Container, Layout } from './Layout'; +import { ModalAlert, ModalAlertType, ModalMessage } from './Modal'; +import { + NavigationBar, + NavigationContainer, + NavigationItems, + NavigationScrollbars, + TitleBarItem, +} from './NavigationBar'; +import SettingsHeader, { HeaderTitle } from './SettingsHeader'; + +const StyledContainer = styled(Container)({ + backgroundColor: colors.darkBlue, +}); + +const StyledContent = styled.div({ + display: 'flex', + flexDirection: 'column', + flex: 1, + marginBottom: '2px', +}); + +const StyledInfoIcon = styled(InfoIcon)({ + marginRight: '16px', +}); + +const StyledSelectorContainer = styled.div({ + flex: 0, +}); + +export default function VpnSettings() { + const { pop } = useHistory(); + + return ( + <BackAction action={pop}> + <Layout> + <StyledContainer> + <NavigationContainer> + <NavigationBar> + <NavigationItems> + <TitleBarItem> + { + // TRANSLATORS: Title label in navigation bar + messages.pgettext('vpn-settings-view', 'VPN settings') + } + </TitleBarItem> + </NavigationItems> + </NavigationBar> + + <NavigationScrollbars> + <SettingsHeader> + <HeaderTitle>{messages.pgettext('vpn-settings-view', 'VPN settings')}</HeaderTitle> + </SettingsHeader> + + <StyledContent> + <Cell.Group> + <AutoStart /> + <AutoConnect /> + </Cell.Group> + + <Cell.Group> + <AllowLan /> + </Cell.Group> + + <Cell.Group> + <BlockAds /> + <BlockTrackers /> + <BlockMalware /> + <BlockGambling /> + <BlockAdultContent /> + </Cell.Group> + + <Cell.Group> + <EnableIpv6 /> + </Cell.Group> + + <Cell.Group> + <KillSwitchInfo /> + <LockdownMode /> + </Cell.Group> + + <Cell.Group> + <TunnelProtocolSetting /> + </Cell.Group> + + <Cell.Group> + <WireguardSettingsButton /> + <OpenVpnSettingsButton /> + </Cell.Group> + + <Cell.Group> + <CustomDnsSettings /> + </Cell.Group> + </StyledContent> + </NavigationScrollbars> + </NavigationContainer> + </StyledContainer> + </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.Footer> + <AriaDescription> + <Cell.FooterText> + {messages.pgettext( + 'vpn-settings-view', + 'Automatically connect to a server when the app launches.', + )} + </Cell.FooterText> + </AriaDescription> + </Cell.Footer> + </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> + <AriaInput> + <Cell.Switch isOn={allowLan} onChange={setAllowLan} /> + </AriaInput> + </Cell.Container> + <Cell.Footer> + <AriaDescription> + <Cell.FooterText> + {messages.pgettext( + 'vpn-settings-view', + 'Allows access to other devices on the same network for sharing, printing etc.', + )} + </Cell.FooterText> + </AriaDescription> + </Cell.Footer> + </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, + }, + }), + [dns, setDnsOptions], + ); + + return [dns, updateBlockSetting] as const; +} + +function BlockAds() { + const [dns, setBlockAds] = useDns('blockAds'); + + return ( + <AriaInputGroup> + <Cell.Container disabled={dns.state === 'custom'}> + <AriaLabel> + <Cell.InputLabel>{messages.pgettext('vpn-settings-view', 'Block ads')}</Cell.InputLabel> + </AriaLabel> + <AriaDetails> + <InfoButton> + <ModalMessage> + {messages.pgettext( + 'vpn-settings-view', + 'When enabled, this feature stops the device from contacting certain known ad domains.', + )} + </ModalMessage> + <ModalMessage> + {messages.pgettext( + 'vpn-settings-view', + 'Warning: This might cause issues on certain websites, services, and programs.', + )} + </ModalMessage> + </InfoButton> + </AriaDetails> + <AriaInput> + <Cell.Switch + isOn={dns.state === 'default' && dns.defaultOptions.blockAds} + onChange={setBlockAds} + /> + </AriaInput> + </Cell.Container> + </AriaInputGroup> + ); +} + +function BlockTrackers() { + const [dns, setBlockTrackers] = useDns('blockTrackers'); + + return ( + <AriaInputGroup> + <Cell.Container disabled={dns.state === 'custom'}> + <AriaLabel> + <Cell.InputLabel> + {messages.pgettext('vpn-settings-view', 'Block trackers')} + </Cell.InputLabel> + </AriaLabel> + <AriaDetails> + <InfoButton> + <ModalMessage> + {messages.pgettext( + 'vpn-settings-view', + 'When enabled, this feature stops the device from contacting certain domains known to track users.', + )} + </ModalMessage> + <ModalMessage> + {messages.pgettext( + 'vpn-settings-view', + 'Warning: This might cause issues on certain websites, services, and programs.', + )} + </ModalMessage> + </InfoButton> + </AriaDetails> + <AriaInput> + <Cell.Switch + isOn={dns.state === 'default' && dns.defaultOptions.blockTrackers} + onChange={setBlockTrackers} + /> + </AriaInput> + </Cell.Container> + </AriaInputGroup> + ); +} + +function BlockMalware() { + const [dns, setBlockMalware] = useDns('blockMalware'); + + return ( + <AriaInputGroup> + <Cell.Container disabled={dns.state === 'custom'}> + <AriaLabel> + <Cell.InputLabel> + {messages.pgettext('vpn-settings-view', 'Block malware')} + </Cell.InputLabel> + </AriaLabel> + <AriaDetails> + <InfoButton> + <ModalMessage> + {messages.pgettext( + 'vpn-settings-view', + 'When enabled, this feature stops the device from contacting certain domains known to host malware.', + )} + </ModalMessage> + <ModalMessage> + {messages.pgettext( + 'vpn-settings-view', + 'Warning: This is not an anti-virus and should not be treated as such, this is just an extra layer of protection.', + )} + </ModalMessage> + </InfoButton> + </AriaDetails> + <AriaInput> + <Cell.Switch + isOn={dns.state === 'default' && dns.defaultOptions.blockMalware} + onChange={setBlockMalware} + /> + </AriaInput> + </Cell.Container> + </AriaInputGroup> + ); +} + +function BlockGambling() { + const [dns, setBlockGambling] = useDns('blockGambling'); + + return ( + <AriaInputGroup> + <Cell.Container disabled={dns.state === 'custom'}> + <AriaLabel> + <Cell.InputLabel> + {messages.pgettext('vpn-settings-view', 'Block gambling')} + </Cell.InputLabel> + </AriaLabel> + <AriaDetails> + <InfoButton + message={messages.pgettext( + 'vpn-settings-view', + 'When enabled, this feature stops the device from contacting certain websites and services known to host gambling content.', + )} + /> + </AriaDetails> + <AriaInput> + <Cell.Switch + isOn={dns.state === 'default' && dns.defaultOptions.blockGambling} + onChange={setBlockGambling} + /> + </AriaInput> + </Cell.Container> + </AriaInputGroup> + ); +} + +function BlockAdultContent() { + const [dns, setBlockAdultContent] = useDns('blockAdultContent'); + + return ( + <AriaInputGroup> + <Cell.Container disabled={dns.state === 'custom'}> + <AriaLabel> + <Cell.InputLabel> + {messages.pgettext('vpn-settings-view', 'Block adult content')} + </Cell.InputLabel> + </AriaLabel> + <AriaDetails> + <InfoButton + message={messages.pgettext( + 'vpn-settings-view', + 'When enabled, this feature stops the device from contacting certain websites and services known to host adult content.', + )} + /> + </AriaDetails> + <AriaInput> + <Cell.Switch + isOn={dns.state === 'default' && dns.defaultOptions.blockAdultContent} + onChange={setBlockAdultContent} + /> + </AriaInput> + </Cell.Container> + {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 "**" will appear bold. + // TRANSLATORS: Advanced settings refer to the name of the page with the title "Advanced". + // 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** (under Advanced settings) to activate these settings.', + ); + + return ( + <Cell.Footer> + <AriaDescription> + <Cell.FooterText> + {formatMarkdown(sprintf(blockingDisabledText, { customDnsFeatureName }))} + </Cell.FooterText> + </AriaDescription> + </Cell.Footer> + ); +} + +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> + <AriaInput> + <Cell.Switch isOn={enableIpv6} onChange={setEnableIpv6} /> + </AriaInput> + </Cell.Container> + <Cell.Footer> + <AriaDescription> + <Cell.FooterText> + {messages.pgettext( + 'vpn-settings-view', + 'Enable IPv6 communication through the tunnel.', + )} + </Cell.FooterText> + </AriaDescription> + </Cell.Footer> + </AriaInputGroup> + ); +} + +function KillSwitchInfo() { + const [killSwitchInfoVisible, showKillSwitchInfo, hideKillSwitchInfo] = useBoolean(false); + + return ( + <> + <Cell.CellButton onClick={showKillSwitchInfo}> + <AriaInputGroup> + <AriaLabel> + <Cell.InputLabel> + {messages.pgettext('vpn-settings-view', 'Kill switch')} + </Cell.InputLabel> + </AriaLabel> + <StyledInfoIcon /> + <AriaInput> + <Cell.Switch isOn disabled /> + </AriaInput> + </AriaInputGroup> + </Cell.CellButton> + <ModalAlert + isOpen={killSwitchInfoVisible} + type={ModalAlertType.info} + buttons={[ + <AppButton.BlueButton key="back" onClick={hideKillSwitchInfo}> + {messages.gettext('Got it!')} + </AppButton.BlueButton>, + ]} + 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> + <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> + </AriaDetails> + <AriaInput> + <Cell.Switch isOn={blockWhenDisconnected} onChange={setLockDownMode} /> + </AriaInput> + </Cell.Container> + </AriaInputGroup> + <ModalAlert + isOpen={confirmationDialogVisible} + type={ModalAlertType.caution} + buttons={[ + <AppButton.RedButton key="confirm" onClick={confirmLockdownMode}> + {messages.gettext('Enable anyway')} + </AppButton.RedButton>, + <AppButton.BlueButton key="back" onClick={hideConfirmationDialog}> + {messages.gettext('Back')} + </AppButton.BlueButton>, + ]} + 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 = useSelector((state) => + mapRelaySettingsToProtocol(state.settings.relaySettings), + ); + const { updateRelaySettings } = useAppContext(); + + const setTunnelProtocol = useCallback(async (tunnelProtocol: TunnelProtocol | undefined) => { + const relayUpdate = RelaySettingsBuilder.normal() + .tunnel.tunnelProtocol((config) => { + if (tunnelProtocol) { + config.tunnelProtocol.exact(tunnelProtocol); + } else { + config.tunnelProtocol.any(); + } + }) + .build(); + try { + await updateRelaySettings(relayUpdate); + } catch (e) { + const error = e as Error; + log.error('Failed to update tunnel protocol constraints', error.message); + } + }, []); + + const tunnelProtocolItems: Array<ISelectorItem<TunnelProtocol | undefined>> = useMemo( + () => [ + { + label: messages.gettext('Automatic'), + value: undefined, + }, + { + label: strings.wireguard, + value: 'wireguard', + }, + { + label: strings.openvpn, + value: 'openvpn', + }, + ], + [], + ); + + return ( + <AriaInputGroup> + <StyledSelectorContainer> + <Selector + title={messages.pgettext('vpn-settings-view', 'Tunnel protocol')} + values={tunnelProtocolItems} + value={tunnelProtocol} + onSelect={setTunnelProtocol} + /> + </StyledSelectorContainer> + </AriaInputGroup> + ); +} + +function mapRelaySettingsToProtocol(relaySettings: RelaySettingsRedux) { + if ('normal' in relaySettings) { + const { tunnelProtocol } = relaySettings.normal; + return tunnelProtocol === 'any' ? undefined : 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 history = useHistory(); + const tunnelProtocol = useSelector((state) => + mapRelaySettingsToProtocol(state.settings.relaySettings), + ); + + const navigate = useCallback(() => history.push(RoutePath.wireguardSettings), [history]); + + return ( + <Cell.CellNavigationButton onClick={navigate} disabled={tunnelProtocol === 'openvpn'}> + <Cell.Label> + {sprintf( + // TRANSLATORS: %(wireguard)s will be replaced with the string "WireGuard" + messages.pgettext('vpn-settings-view', '%(wireguard)s settings'), + { wireguard: strings.wireguard }, + )} + </Cell.Label> + </Cell.CellNavigationButton> + ); +} + +function OpenVpnSettingsButton() { + const history = useHistory(); + const tunnelProtocol = useSelector((state) => + mapRelaySettingsToProtocol(state.settings.relaySettings), + ); + + const navigate = useCallback(() => history.push(RoutePath.openVpnSettings), [history]); + + return ( + <Cell.CellNavigationButton onClick={navigate} disabled={tunnelProtocol === 'wireguard'}> + <Cell.Label> + {sprintf( + // TRANSLATORS: %(openvpn)s will be replaced with the string "OpenVPN" + messages.pgettext('vpn-settings-view', '%(openvpn)s settings'), + { openvpn: strings.openvpn }, + )} + </Cell.Label> + </Cell.CellNavigationButton> + ); +} diff --git a/gui/src/renderer/components/WireguardSettings.tsx b/gui/src/renderer/components/WireguardSettings.tsx index 20c3009574..d0a22c7e87 100644 --- a/gui/src/renderer/components/WireguardSettings.tsx +++ b/gui/src/renderer/components/WireguardSettings.tsx @@ -128,138 +128,146 @@ export default class WireguardSettings extends React.Component<IProps, IState> { </HeaderTitle> </SettingsHeader> - <AriaInputGroup> - <StyledSelectorContainer> - <StyledSelectorForFooter - // TRANSLATORS: The title for the WireGuard port selector. - title={messages.pgettext('wireguard-settings-view', 'Port')} - values={this.wireguardPortItems} - value={this.props.wireguard.port} - onSelect={this.props.setWireguardPort} - /> - </StyledSelectorContainer> - <Cell.Footer> - <AriaDescription> - <Cell.FooterText> - { - // TRANSLATORS: The hint displayed below the WireGuard port selector. - messages.pgettext( - 'wireguard-settings-view', - 'The automatic setting will randomly choose from a wide range of ports.', - ) - } - </Cell.FooterText> - </AriaDescription> - </Cell.Footer> - </AriaInputGroup> - - <AriaInputGroup> - <Cell.Container> - <AriaLabel> - <Cell.InputLabel> - { - // TRANSLATORS: The label next to the multihop settings toggle. - messages.pgettext('advanced-settings-view', 'Enable multihop') - } - </Cell.InputLabel> - </AriaLabel> - <AriaInput> - <Cell.Switch - ref={this.multihopRef} - isOn={this.props.wireguardMultihop} - onChange={this.setWireguardMultihop} + <Cell.Group> + <AriaInputGroup> + <StyledSelectorContainer> + <StyledSelectorForFooter + // TRANSLATORS: The title for the WireGuard port selector. + title={messages.pgettext('wireguard-settings-view', 'Port')} + values={this.wireguardPortItems} + value={this.props.wireguard.port} + onSelect={this.props.setWireguardPort} /> - </AriaInput> - </Cell.Container> - <Cell.Footer> - <AriaDescription> - <Cell.FooterText> - {sprintf( - // TRANSLATORS: Description for multihop settings toggle. - // TRANSLATORS: Available placeholders: - // TRANSLATORS: %(wireguard)s - Will be replaced with the string "WireGuard" - messages.pgettext( - 'advanced-settings-view', - 'Increases anonymity by routing your traffic into one %(wireguard)s server and out another, making it harder to trace.', - ), - { wireguard: strings.wireguard }, - )} - </Cell.FooterText> - </AriaDescription> - </Cell.Footer> - </AriaInputGroup> + </StyledSelectorContainer> + <Cell.Footer> + <AriaDescription> + <Cell.FooterText> + { + // TRANSLATORS: The hint displayed below the WireGuard port selector. + messages.pgettext( + 'wireguard-settings-view', + 'The automatic setting will randomly choose from a wide range of ports.', + ) + } + </Cell.FooterText> + </AriaDescription> + </Cell.Footer> + </AriaInputGroup> + </Cell.Group> - <AriaInputGroup> - <StyledSelectorContainer> - <StyledSelectorForFooter - // TRANSLATORS: The title for the WireGuard IP version selector. - title={messages.pgettext('wireguard-settings-view', 'IP version')} - values={this.wireguardIpVersionItems} - value={this.props.wireguard.ipVersion} - onSelect={this.props.setWireguardIpVersion} - /> - </StyledSelectorContainer> - <Cell.Footer> - <AriaDescription> - <Cell.FooterText> - {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.FooterText> - </AriaDescription> - </Cell.Footer> - </AriaInputGroup> + <Cell.Group> + <AriaInputGroup> + <Cell.Container> + <AriaLabel> + <Cell.InputLabel> + { + // TRANSLATORS: The label next to the multihop settings toggle. + messages.pgettext('vpn-settings-view', 'Enable multihop') + } + </Cell.InputLabel> + </AriaLabel> + <AriaInput> + <Cell.Switch + ref={this.multihopRef} + isOn={this.props.wireguardMultihop} + onChange={this.setWireguardMultihop} + /> + </AriaInput> + </Cell.Container> + <Cell.Footer> + <AriaDescription> + <Cell.FooterText> + {sprintf( + // TRANSLATORS: Description for multihop settings toggle. + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(wireguard)s - Will be replaced with the string "WireGuard" + messages.pgettext( + 'vpn-settings-view', + 'Increases anonymity by routing your traffic into one %(wireguard)s server and out another, making it harder to trace.', + ), + { wireguard: strings.wireguard }, + )} + </Cell.FooterText> + </AriaDescription> + </Cell.Footer> + </AriaInputGroup> + </Cell.Group> - <AriaInputGroup> - <Cell.Container> - <AriaLabel> - <Cell.InputLabel> - {messages.pgettext('wireguard-settings-view', 'MTU')} - </Cell.InputLabel> - </AriaLabel> - <AriaInput> - <Cell.AutoSizingTextInput - value={this.props.wireguardMtu ? this.props.wireguardMtu.toString() : ''} - inputMode={'numeric'} - maxLength={4} - placeholder={messages.gettext('Default')} - onSubmitValue={this.onWireguardMtuSubmit} - validateValue={WireguardSettings.wireguarMtuIsValid} - submitOnBlur={true} - modifyValue={WireguardSettings.removeNonNumericCharacters} + <Cell.Group> + <AriaInputGroup> + <StyledSelectorContainer> + <StyledSelectorForFooter + // TRANSLATORS: The title for the WireGuard IP version selector. + title={messages.pgettext('wireguard-settings-view', 'IP version')} + values={this.wireguardIpVersionItems} + value={this.props.wireguard.ipVersion} + onSelect={this.props.setWireguardIpVersion} /> - </AriaInput> - </Cell.Container> - <Cell.Footer> - <AriaDescription> - <Cell.FooterText> - {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.FooterText> - </AriaDescription> - </Cell.Footer> - </AriaInputGroup> + </StyledSelectorContainer> + <Cell.Footer> + <AriaDescription> + <Cell.FooterText> + {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.FooterText> + </AriaDescription> + </Cell.Footer> + </AriaInputGroup> + </Cell.Group> + + <Cell.Group> + <AriaInputGroup> + <Cell.Container> + <AriaLabel> + <Cell.InputLabel> + {messages.pgettext('wireguard-settings-view', 'MTU')} + </Cell.InputLabel> + </AriaLabel> + <AriaInput> + <Cell.AutoSizingTextInput + value={this.props.wireguardMtu ? this.props.wireguardMtu.toString() : ''} + inputMode={'numeric'} + maxLength={4} + placeholder={messages.gettext('Default')} + onSubmitValue={this.onWireguardMtuSubmit} + validateValue={WireguardSettings.wireguarMtuIsValid} + submitOnBlur={true} + modifyValue={WireguardSettings.removeNonNumericCharacters} + /> + </AriaInput> + </Cell.Container> + <Cell.Footer> + <AriaDescription> + <Cell.FooterText> + {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.FooterText> + </AriaDescription> + </Cell.Footer> + </AriaInputGroup> + </Cell.Group> </StyledNavigationScrollbars> </NavigationContainer> </SettingsContainer> diff --git a/gui/src/renderer/components/cell/CellButton.tsx b/gui/src/renderer/components/cell/CellButton.tsx index 5a24cfe901..e8d1c1c649 100644 --- a/gui/src/renderer/components/cell/CellButton.tsx +++ b/gui/src/renderer/components/cell/CellButton.tsx @@ -3,6 +3,7 @@ import styled from 'styled-components'; import { colors } from '../../../config.json'; import { CellDisabledContext } from './Container'; +import { Icon } from './Label'; import { CellSectionContext } from './Section'; interface IStyledCellButtonProps extends React.HTMLAttributes<HTMLButtonElement> { @@ -22,7 +23,6 @@ const StyledCellButton = styled.button({}, (props: IStyledCellButtonProps) => { display: 'flex', minHeight: '44px', padding: '0 16px 0 22px', - marginBottom: '1px', flex: 1, alignItems: 'center', alignContent: 'center', @@ -50,9 +50,13 @@ export const CellButton = styled( }), )({}); -export const CellButtonGroup = styled.div({ - display: 'flex', - flexDirection: 'column', - flex: 1, - marginBottom: '20px', -}); +export function CellNavigationButton(props: ICellButtonProps) { + const { children, ...otherProps } = props; + + return ( + <CellButton {...otherProps}> + {children} + <Icon height={12} width={7} source="icon-chevron" /> + </CellButton> + ); +} diff --git a/gui/src/renderer/components/cell/Footer.tsx b/gui/src/renderer/components/cell/Footer.tsx index 2cdf8aa8fa..466ef4acca 100644 --- a/gui/src/renderer/components/cell/Footer.tsx +++ b/gui/src/renderer/components/cell/Footer.tsx @@ -4,7 +4,7 @@ import { colors } from '../../../config.json'; import { tinyText } from '../common-styles'; export const Footer = styled.div({ - padding: '6px 22px 20px', + padding: '6px 22px 0px', }); export const FooterText = styled.span(tinyText, { diff --git a/gui/src/renderer/components/cell/Group.tsx b/gui/src/renderer/components/cell/Group.tsx new file mode 100644 index 0000000000..c167b2d525 --- /dev/null +++ b/gui/src/renderer/components/cell/Group.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import styled from 'styled-components'; + +interface IStyledGroupProps { + noMarginBottom?: boolean; +} + +const StyledGroup = styled.div({}, (props: IStyledGroupProps) => ({ + display: 'flex', + flexDirection: 'column', + flex: 1, + marginBottom: props.noMarginBottom ? '0px' : '20px', +})); + +const StyledCellWrapper = styled.div({ + display: 'flex', + flexDirection: 'column', + flex: 1, + marginBottom: '1px', +}); + +interface IGroupProps extends IStyledGroupProps { + children: React.ReactNode | React.ReactNode[]; +} + +export function Group(props: IGroupProps) { + const children = React.Children.toArray(props.children); + return ( + <StyledGroup noMarginBottom={props.noMarginBottom}> + {children.map((child, index) => + index === children.length - 1 ? ( + child + ) : ( + <StyledCellWrapper key={index}>{child}</StyledCellWrapper> + ), + )} + </StyledGroup> + ); +} diff --git a/gui/src/renderer/components/cell/Section.tsx b/gui/src/renderer/components/cell/Section.tsx index 41f57ee52c..1c1779248f 100644 --- a/gui/src/renderer/components/cell/Section.tsx +++ b/gui/src/renderer/components/cell/Section.tsx @@ -15,7 +15,6 @@ export const SectionTitle = styled.span(buttonText, { alignItems: 'center', backgroundColor: colors.blue, padding: '0 16px 0 22px', - marginBottom: '1px', }); export const CellSectionContext = React.createContext<boolean>(false); diff --git a/gui/src/renderer/components/cell/Selector.tsx b/gui/src/renderer/components/cell/Selector.tsx index 5ea1687a1e..ea2abb90ef 100644 --- a/gui/src/renderer/components/cell/Selector.tsx +++ b/gui/src/renderer/components/cell/Selector.tsx @@ -19,13 +19,8 @@ interface ISelectorProps<T> { onSelect: (value: T) => void; selectedCellRef?: React.Ref<HTMLButtonElement>; className?: string; - hasFooter?: boolean; } -const Section = styled(Cell.Section)((props: { hasFooter: boolean }) => ({ - marginBottom: props.hasFooter ? 0 : '20px', -})); - export default class Selector<T> extends React.Component<ISelectorProps<T>> { public render() { const items = this.props.values.map((item, i) => { @@ -52,13 +47,10 @@ export default class Selector<T> extends React.Component<ISelectorProps<T>> { return ( <AriaInput> - <Section - role="listbox" - className={this.props.className} - hasFooter={this.props.hasFooter ?? false}> + <Cell.Section role="listbox" className={this.props.className}> {title} {items} - </Section> + </Cell.Section> </AriaInput> ); } diff --git a/gui/src/renderer/components/cell/index.ts b/gui/src/renderer/components/cell/index.ts index a16cb93097..cc510e5b22 100644 --- a/gui/src/renderer/components/cell/index.ts +++ b/gui/src/renderer/components/cell/index.ts @@ -4,3 +4,4 @@ export * from './Footer'; export * from './Input'; export * from './Label'; export * from './Section'; +export * from './Group'; diff --git a/gui/src/renderer/containers/AdvancedSettingsPage.tsx b/gui/src/renderer/containers/AdvancedSettingsPage.tsx deleted file mode 100644 index 203067f1af..0000000000 --- a/gui/src/renderer/containers/AdvancedSettingsPage.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { connect } from 'react-redux'; - -import { TunnelProtocol } from '../../shared/daemon-rpc-types'; -import log from '../../shared/logging'; -import RelaySettingsBuilder from '../../shared/relay-settings-builder'; -import AdvancedSettings from '../components/AdvancedSettings'; -import withAppContext, { IAppContext } from '../context'; -import { IHistoryProps, withHistory } from '../lib/history'; -import { RoutePath } from '../lib/routes'; -import { RelaySettingsRedux } from '../redux/settings/reducers'; -import { IReduxState, ReduxDispatch } from '../redux/store'; - -const mapStateToProps = (state: IReduxState) => { - const tunnelProtocol = mapRelaySettingsToProtocol(state.settings.relaySettings); - - return { - enableIpv6: state.settings.enableIpv6, - blockWhenDisconnected: state.settings.blockWhenDisconnected, - tunnelProtocol, - }; -}; - -const mapRelaySettingsToProtocol = (relaySettings: RelaySettingsRedux) => { - if ('normal' in relaySettings) { - const { tunnelProtocol } = relaySettings.normal; - return tunnelProtocol === 'any' ? undefined : 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.'); - } -}; - -const mapDispatchToProps = (_dispatch: ReduxDispatch, props: IHistoryProps & IAppContext) => { - return { - onClose: () => { - props.history.pop(); - }, - - setTunnelProtocol: async (tunnelProtocol: TunnelProtocol | undefined) => { - const relayUpdate = RelaySettingsBuilder.normal() - .tunnel.tunnelProtocol((config) => { - if (tunnelProtocol) { - config.tunnelProtocol.exact(tunnelProtocol); - } else { - config.tunnelProtocol.any(); - } - }) - .build(); - try { - await props.app.updateRelaySettings(relayUpdate); - } catch (e) { - const error = e as Error; - log.error('Failed to update tunnel protocol constraints', error.message); - } - }, - - setEnableIpv6: async (enableIpv6: boolean) => { - try { - await props.app.setEnableIpv6(enableIpv6); - } catch (e) { - const error = e as Error; - log.error('Failed to update enable IPv6', error.message); - } - }, - - setBlockWhenDisconnected: async (blockWhenDisconnected: boolean) => { - try { - await props.app.setBlockWhenDisconnected(blockWhenDisconnected); - } catch (e) { - const error = e as Error; - log.error('Failed to update block when disconnected', error.message); - } - }, - - onViewWireguardSettings: () => props.history.push(RoutePath.wireguardSettings), - onViewOpenVpnSettings: () => props.history.push(RoutePath.openVpnSettings), - onViewSplitTunneling: () => props.history.push(RoutePath.splitTunneling), - }; -}; - -export default withAppContext( - withHistory(connect(mapStateToProps, mapDispatchToProps)(AdvancedSettings)), -); diff --git a/gui/src/renderer/containers/PreferencesPage.tsx b/gui/src/renderer/containers/PreferencesPage.tsx deleted file mode 100644 index be2dce9373..0000000000 --- a/gui/src/renderer/containers/PreferencesPage.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { connect } from 'react-redux'; - -import { IDnsOptions } from '../../shared/daemon-rpc-types'; -import log from '../../shared/logging'; -import Preferences from '../components/Preferences'; -import withAppContext, { IAppContext } from '../context'; -import { IHistoryProps, withHistory } from '../lib/history'; -import { IReduxState, ReduxDispatch } from '../redux/store'; - -const mapStateToProps = (state: IReduxState) => ({ - autoStart: state.settings.autoStart, - allowLan: state.settings.allowLan, - showBetaReleases: state.settings.showBetaReleases, - isBeta: state.version.isBeta, - autoConnect: state.settings.guiSettings.autoConnect, - enableSystemNotifications: state.settings.guiSettings.enableSystemNotifications, - monochromaticIcon: state.settings.guiSettings.monochromaticIcon, - startMinimized: state.settings.guiSettings.startMinimized, - unpinnedWindow: state.settings.guiSettings.unpinnedWindow, - dns: state.settings.dns, -}); - -const mapDispatchToProps = (_dispatch: ReduxDispatch, props: IHistoryProps & IAppContext) => { - return { - onClose: () => { - props.history.pop(); - }, - setEnableSystemNotifications: (flag: boolean) => { - props.app.setEnableSystemNotifications(flag); - }, - setAutoStart: async (autoStart: boolean) => { - try { - await props.app.setAutoStart(autoStart); - } catch (e) { - const error = e as Error; - log.error(`Cannot set auto-start: ${error.message}`); - } - }, - setAutoConnect: (autoConnect: boolean) => { - props.app.setAutoConnect(autoConnect); - }, - setAllowLan: (allowLan: boolean) => { - void props.app.setAllowLan(allowLan); - }, - setShowBetaReleases: (showBetaReleases: boolean) => { - void props.app.setShowBetaReleases(showBetaReleases); - }, - setStartMinimized: (startMinimized: boolean) => { - props.app.setStartMinimized(startMinimized); - }, - setMonochromaticIcon: (monochromaticIcon: boolean) => { - props.app.setMonochromaticIcon(monochromaticIcon); - }, - setUnpinnedWindow: (unpinnedWindow: boolean) => { - props.app.setUnpinnedWindow(unpinnedWindow); - }, - setDnsOptions: (dns: IDnsOptions) => { - return props.app.setDnsOptions(dns); - }, - }; -}; - -export default withAppContext( - withHistory(connect(mapStateToProps, mapDispatchToProps)(Preferences)), -); diff --git a/gui/src/renderer/containers/SupportPage.tsx b/gui/src/renderer/containers/ProblemReportPage.tsx index 37e6b86031..1c405134c8 100644 --- a/gui/src/renderer/containers/SupportPage.tsx +++ b/gui/src/renderer/containers/ProblemReportPage.tsx @@ -1,7 +1,7 @@ import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; -import Support from '../components/Support'; +import ProblemReport from '../components/ProblemReport'; import withAppContext, { IAppContext } from '../context'; import { IHistoryProps, withHistory } from '../lib/history'; import { IReduxState, ReduxDispatch } from '../redux/store'; @@ -34,4 +34,6 @@ const mapDispatchToProps = (dispatch: ReduxDispatch, props: IAppContext & IHisto }; }; -export default withAppContext(withHistory(connect(mapStateToProps, mapDispatchToProps)(Support))); +export default withAppContext( + withHistory(connect(mapStateToProps, mapDispatchToProps)(ProblemReport)), +); diff --git a/gui/src/renderer/containers/SettingsPage.tsx b/gui/src/renderer/containers/SettingsPage.tsx deleted file mode 100644 index 58689e31d9..0000000000 --- a/gui/src/renderer/containers/SettingsPage.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { connect } from 'react-redux'; - -import Settings from '../components/Settings'; -import withAppContext, { IAppContext } from '../context'; -import { IHistoryProps, withHistory } from '../lib/history'; -import { RoutePath } from '../lib/routes'; -import { IReduxState, ReduxDispatch } from '../redux/store'; - -const mapStateToProps = (state: IReduxState, props: IAppContext) => ({ - preferredLocaleDisplayName: props.app.getPreferredLocaleDisplayName( - state.settings.guiSettings.preferredLocale, - ), - loginState: state.account.status, - connectedToDaemon: state.userInterface.connectedToDaemon, - accountExpiry: state.account.expiry, - appVersion: state.version.current, - consistentVersion: state.version.consistent, - upToDateVersion: state.version.suggestedUpgrade ? false : true, - suggestedIsBeta: state.version.suggestedIsBeta ?? false, - isOffline: state.connection.isBlocked, -}); -const mapDispatchToProps = (_dispatch: ReduxDispatch, props: IHistoryProps & IAppContext) => { - return { - onQuit: () => props.app.quit(), - onClose: () => props.history.dismiss(), - onViewSelectLanguage: () => props.history.push(RoutePath.selectLanguage), - onViewAccount: () => props.history.push(RoutePath.accountSettings), - onViewSupport: () => props.history.push(RoutePath.support), - onViewPreferences: () => props.history.push(RoutePath.preferences), - onViewAdvancedSettings: () => props.history.push(RoutePath.advancedSettings), - onExternalLink: (url: string) => props.app.openUrl(url), - updateAccountData: () => props.app.updateAccountData(), - }; -}; - -export default withAppContext(withHistory(connect(mapStateToProps, mapDispatchToProps)(Settings))); diff --git a/gui/src/renderer/lib/routes.ts b/gui/src/renderer/lib/routes.ts index 641b48781d..9c8eb4c493 100644 --- a/gui/src/renderer/lib/routes.ts +++ b/gui/src/renderer/lib/routes.ts @@ -15,12 +15,13 @@ export enum RoutePath { settings = '/settings', selectLanguage = '/settings/language', accountSettings = '/settings/account', - preferences = '/settings/preferences', - advancedSettings = '/settings/advanced', + interfaceSettings = '/settings/interface', + vpnSettings = '/settings/vpn', wireguardSettings = '/settings/advanced/wireguard', openVpnSettings = '/settings/advanced/openvpn', - splitTunneling = '/settings/advanced/split-tunneling', + splitTunneling = '/settings/split-tunneling', support = '/settings/support', + problemReport = '/settings/support/problem-report', selectLocation = '/select-location', filter = '/select-location/filter', } diff --git a/gui/src/renderer/redux/support/actions.ts b/gui/src/renderer/redux/support/actions.ts index 23cb57daf3..de26a17908 100644 --- a/gui/src/renderer/redux/support/actions.ts +++ b/gui/src/renderer/redux/support/actions.ts @@ -1,11 +1,11 @@ -export interface ISupportReportForm { +export interface IProblemReportForm { email: string; message: string; } export interface IKeepReportFormAction { type: 'SAVE_REPORT_FORM'; - form: ISupportReportForm; + form: IProblemReportForm; } export interface IClearReportFormAction { @@ -14,7 +14,7 @@ export interface IClearReportFormAction { export type SupportAction = IKeepReportFormAction | IClearReportFormAction; -function saveReportForm(form: ISupportReportForm): IKeepReportFormAction { +function saveReportForm(form: IProblemReportForm): IKeepReportFormAction { return { type: 'SAVE_REPORT_FORM', form, diff --git a/gui/src/shared/localization-contexts.ts b/gui/src/shared/localization-contexts.ts index e03c679b61..980e79917a 100644 --- a/gui/src/shared/localization-contexts.ts +++ b/gui/src/shared/localization-contexts.ts @@ -22,10 +22,8 @@ export type LocalizationContexts = | 'account-view' | 'redeem-voucher-view' | 'redeem-voucher-alert' - | 'preferences-view' - | 'preferences-nav' - | 'advanced-settings-view' - | 'advanced-settings-nav' + | 'interface-settings-view' + | 'vpn-settings-view' | 'wireguard-settings-view' | 'wireguard-settings-nav' | 'openvpn-settings-view' |
