diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2021-08-18 13:37:46 +0200 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2021-08-18 13:37:46 +0200 |
| commit | 3f4430ea27c56efdc524276a6c4e02e8080b52d4 (patch) | |
| tree | d5cca3468c47365f4b6dcff93c36703533489e4a | |
| parent | 6bcea0fd559988f94962bb23c159ba1b211b17bd (diff) | |
| parent | b43b45007fb69430b502a443c18bcd4c96aa0f91 (diff) | |
| download | mullvadvpn-3f4430ea27c56efdc524276a6c4e02e8080b52d4.tar.xz mullvadvpn-3f4430ea27c56efdc524276a6c4e02e8080b52d4.zip | |
Merge branch 'filter-by-provider'
| -rw-r--r-- | CHANGELOG.md | 3 | ||||
| -rw-r--r-- | gui/assets/images/icon-filter-round.svg | 3 | ||||
| -rw-r--r-- | gui/locales/messages.pot | 36 | ||||
| -rw-r--r-- | gui/src/main/daemon-rpc.ts | 11 | ||||
| -rw-r--r-- | gui/src/main/index.ts | 2 | ||||
| -rw-r--r-- | gui/src/renderer/app.tsx | 2 | ||||
| -rw-r--r-- | gui/src/renderer/components/AppRouter.tsx | 2 | ||||
| -rw-r--r-- | gui/src/renderer/components/FilterByProvider.tsx | 204 | ||||
| -rw-r--r-- | gui/src/renderer/components/LocationRow.tsx | 10 | ||||
| -rw-r--r-- | gui/src/renderer/components/SelectLocation.tsx | 116 | ||||
| -rw-r--r-- | gui/src/renderer/components/SelectLocationStyles.tsx | 70 | ||||
| -rw-r--r-- | gui/src/renderer/components/SettingsHeader.tsx | 3 | ||||
| -rw-r--r-- | gui/src/renderer/containers/SelectLocationPage.tsx | 33 | ||||
| -rw-r--r-- | gui/src/renderer/lib/routes.ts | 1 | ||||
| -rw-r--r-- | gui/src/renderer/redux/settings/reducers.ts | 3 | ||||
| -rw-r--r-- | gui/src/renderer/redux/store.ts | 5 | ||||
| -rw-r--r-- | gui/src/shared/bridge-settings-builder.ts | 1 | ||||
| -rw-r--r-- | gui/src/shared/daemon-rpc-types.ts | 4 | ||||
| -rw-r--r-- | gui/src/shared/localization-contexts.ts | 2 |
19 files changed, 479 insertions, 32 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index bf7dc652bb..dcbb34ae9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,9 @@ Line wrap the file at 100 chars. Th ## [Unreleased] +### Added +- Added possibility to filter locations by provider in the desktop app. + ### Changed - Only use the account history file to store the last used account. - Update the out of time-view and new account-view to make it more user friendly. diff --git a/gui/assets/images/icon-filter-round.svg b/gui/assets/images/icon-filter-round.svg new file mode 100644 index 0000000000..9df0dd5ccc --- /dev/null +++ b/gui/assets/images/icon-filter-round.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> + <path fill="#FFFFFF" d="M12 24A12 12 0 0 1 3.515 3.515a12 12 0 1 1 16.97 16.97A11.922 11.922 0 0 1 12 24zm-1.555-7.956V17.6h3.111v-1.556zm-3.112-3.889v1.556h9.333v-1.556zM5 8.267v1.556h14V8.267z" transform="translate(0 0)"/> +</svg> diff --git a/gui/locales/messages.pot b/gui/locales/messages.pot index 9b685faf49..09e121d68b 100644 --- a/gui/locales/messages.pot +++ b/gui/locales/messages.pot @@ -60,6 +60,9 @@ msgid_plural "%d hours ago" msgstr[0] "" msgstr[1] "" +msgid "Apply" +msgstr "" + msgid "Back" msgstr "" @@ -78,6 +81,9 @@ msgstr "" msgid "Cancel" msgstr "" +msgid "Clear" +msgstr "" + msgid "Close" msgstr "" @@ -99,6 +105,9 @@ msgstr "" msgid "FAILED TO SECURE CONNECTION" msgstr "" +msgid "Filter" +msgstr "" + msgid "Got it!" msgstr "" @@ -491,6 +500,20 @@ msgctxt "error-boundary-view" msgid "Something went wrong. Please contact us at %(email)s" msgstr "" +#. Title label in navigation bar +msgctxt "filter-by-provider-nav" +msgid "Filter by provider" +msgstr "" + +#. Back button in navigation bar +msgctxt "filter-by-provider-nav" +msgid "Select location" +msgstr "" + +msgctxt "filter-by-provider-view" +msgid "All providers" +msgstr "" + msgctxt "generic" msgid "Mullvad VPN" msgstr "" @@ -911,7 +934,7 @@ msgctxt "select-location-nav" msgid "Exit" msgstr "" -#. Title label in navigation bar +#. Heading in select location view msgctxt "select-location-nav" msgid "Select location" msgstr "" @@ -921,11 +944,15 @@ msgid "Closest to exit server" msgstr "" msgctxt "select-location-view" -msgid "While connected, your real location is masked with a private and secure location in the selected region." +msgid "Filter by provider" msgstr "" msgctxt "select-location-view" -msgid "While connected, your traffic will be routed through two secure locations, the entry point (a bridge server) and the exit point (a VPN server)." +msgid "Filtered:" +msgstr "" + +msgctxt "select-location-view" +msgid "Providers: %(numberOfProviders)d" msgstr "" #. Navigation button to the 'Account' view @@ -1336,6 +1363,9 @@ msgstr "" msgid "Virtual adapter error" msgstr "" +msgid "While connected, your real location is masked with a private and secure location in the selected region." +msgstr "" + msgid "WireGuard error" msgstr "" diff --git a/gui/src/main/daemon-rpc.ts b/gui/src/main/daemon-rpc.ts index 28b8e651dc..1c3e9a67ba 100644 --- a/gui/src/main/daemon-rpc.ts +++ b/gui/src/main/daemon-rpc.ts @@ -293,6 +293,12 @@ export class DaemonRpc { ); } + if (settingsUpdate.providers) { + const providerUpdate = new grpcTypes.ProviderUpdate(); + providerUpdate.setProvidersList(settingsUpdate.providers); + normalUpdate.setProviders(providerUpdate); + } + grpcRelaySettings.setNormal(normalUpdate); await this.call<grpcTypes.RelaySettingsUpdate, Empty>( this.client.updateRelaySettings, @@ -924,6 +930,7 @@ function convertFromRelaySettings( ? { only: convertFromLocation(grpcLocation.toObject()) } : 'any'; const tunnelProtocol = convertFromTunnelTypeConstraint(normal.getTunnelType()!); + const providers = normal.getProvidersList(); const openvpnConstraints = convertFromOpenVpnConstraints(normal.getOpenvpnConstraints()!); const wireguardConstraints = convertFromWireguardConstraints( normal.getWireguardConstraints()!, @@ -933,6 +940,7 @@ function convertFromRelaySettings( normal: { location, tunnelProtocol, + providers, wireguardConstraints, openvpnConstraints, }, @@ -951,9 +959,11 @@ function convertFromBridgeSettings( if (normalSettings) { const grpcLocation = normalSettings.location; const location = grpcLocation ? { only: convertFromLocation(grpcLocation) } : 'any'; + const providers = normalSettings.providersList; return { normal: { location, + providers, }, }; } @@ -1177,6 +1187,7 @@ function convertToNormalBridgeSettings( ): grpcTypes.BridgeSettings.BridgeConstraints { const normalBridgeSettings = new grpcTypes.BridgeSettings.BridgeConstraints(); normalBridgeSettings.setLocation(convertToLocation(liftConstraint(constraints.location))); + normalBridgeSettings.setProvidersList(constraints.providers); return normalBridgeSettings; } diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts index a81324fd27..95771c5878 100644 --- a/gui/src/main/index.ts +++ b/gui/src/main/index.ts @@ -136,6 +136,7 @@ class ApplicationMain { normal: { location: 'any', tunnelProtocol: 'any', + providers: [], openvpnConstraints: { port: 'any', protocol: 'any', @@ -148,6 +149,7 @@ class ApplicationMain { bridgeSettings: { normal: { location: 'any', + providers: [], }, }, bridgeState: 'auto', diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx index 5dfdb7bf29..49c4129112 100644 --- a/gui/src/renderer/app.tsx +++ b/gui/src/renderer/app.tsx @@ -595,11 +595,13 @@ export default class AppRenderer { openvpnConstraints, wireguardConstraints, tunnelProtocol, + providers, } = relaySettings.normal; actions.settings.updateRelay({ normal: { location: liftConstraint(location), + providers, openvpn: { port: liftConstraint(openvpnConstraints.port), protocol: liftConstraint(openvpnConstraints.protocol), diff --git a/gui/src/renderer/components/AppRouter.tsx b/gui/src/renderer/components/AppRouter.tsx index 1816541b49..9f049fc791 100644 --- a/gui/src/renderer/components/AppRouter.tsx +++ b/gui/src/renderer/components/AppRouter.tsx @@ -25,6 +25,7 @@ import { VoucherVerificationSuccess, } from './ExpiredAccountAddTime'; import { RoutePath } from '../lib/routes'; +import FilterByProvider from './FilterByProvider'; interface IAppRoutesState { currentLocation: IHistoryProps['history']['location']; @@ -94,6 +95,7 @@ class AppRouter extends React.Component<IHistoryProps, IAppRoutesState> { <Route exact path={RoutePath.splitTunneling} component={SplitTunnelingSettings} /> <Route exact path={RoutePath.support} component={SupportPage} /> <Route exact path={RoutePath.selectLocation} component={SelectLocationPage} /> + <Route exact path={RoutePath.filterByProvider} component={FilterByProvider} /> </Switch> </TransitionView> </TransitionContainer> diff --git a/gui/src/renderer/components/FilterByProvider.tsx b/gui/src/renderer/components/FilterByProvider.tsx new file mode 100644 index 0000000000..f90baf89b5 --- /dev/null +++ b/gui/src/renderer/components/FilterByProvider.tsx @@ -0,0 +1,204 @@ +import React, { useCallback, useMemo, useState } 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 { useSelector } from '../redux/store'; +import * as AppButton from './AppButton'; +import { mediumText } from './common-styles'; +import ImageView from './ImageView'; +import { Container, Layout } from './Layout'; +import { + BackBarItem, + NavigationBar, + NavigationContainer, + NavigationItems, + NavigationScrollbars, + TitleBarItem, +} from './NavigationBar'; + +const StyledContainer = styled(Container)({ + backgroundColor: colors.darkBlue, +}); + +const StyledNavigationScrollbars = styled(NavigationScrollbars)({ + backgroundColor: colors.blue, + flex: 1, +}); + +const StyledFooter = styled.div({ + display: 'flex', + flexDirection: 'column', + padding: '18px 22px 22px', +}); + +enum Selection { + all, + some, + none, +} + +export default function FilterByProvider() { + const history = useHistory(); + const { updateRelaySettings } = useAppContext(); + + const serverList = useSelector((state) => + state.settings.relayLocations.concat( + state.settings.bridgeState === 'on' ? state.settings.bridgeLocations : [], + ), + ); + const providerConstraint = useSelector((state) => { + if ('normal' in state.settings.relaySettings) { + return state.settings.relaySettings.normal.providers; + } else { + return []; + } + }); + + const [providers, setProviders] = useState(() => { + const providers = serverList.flatMap((country) => + country.cities.flatMap((city) => city.relays.map((relay) => relay.provider)), + ); + const uniqueProviders = removeDuplicates(providers).sort((a, b) => a.localeCompare(b)); + + return Object.fromEntries( + uniqueProviders.map((provider) => [ + provider, + providerConstraint.length === 0 || providerConstraint.includes(provider), + ]), + ); + }); + + const selectionStatus = useMemo(() => { + if (Object.values(providers).every((value) => value)) { + return Selection.all; + } else if (Object.values(providers).every((value) => !value)) { + return Selection.none; + } else { + return Selection.some; + } + }, [providers]); + + const onCheck = useCallback((provider: string) => { + setProviders((providers) => ({ ...providers, [provider]: !providers[provider] })); + }, []); + + const toggleAll = useCallback(() => { + setProviders((providers) => + Object.fromEntries( + Object.keys(providers).map((provider) => [provider, selectionStatus !== Selection.all]), + ), + ); + }, [selectionStatus]); + + const onApply = useCallback(async () => { + const selectedProviders = + selectionStatus === Selection.all + ? [] + : Object.entries(providers) + .filter(([, selected]) => selected) + .map(([provider]) => provider); + + await updateRelaySettings({ normal: { providers: selectedProviders } }); + + history.pop(); + }, [providers, history, updateRelaySettings, selectionStatus]); + + return ( + <Layout> + <StyledContainer> + <NavigationContainer> + <NavigationBar alwaysDisplayBarTitle={true}> + <NavigationItems> + <BackBarItem action={history.pop}> + { + // TRANSLATORS: Back button in navigation bar + messages.pgettext('filter-by-provider-nav', 'Select location') + } + </BackBarItem> + <TitleBarItem> + { + // TRANSLATORS: Title label in navigation bar + messages.pgettext('filter-by-provider-nav', 'Filter by provider') + } + </TitleBarItem> + </NavigationItems> + </NavigationBar> + <StyledNavigationScrollbars> + <ProviderRow + provider={messages.pgettext('filter-by-provider-view', 'All providers')} + bold + checked={selectionStatus === Selection.all} + onCheck={toggleAll} + /> + {Object.entries(providers).map(([provider, checked]) => ( + <ProviderRow key={provider} provider={provider} checked={checked} onCheck={onCheck} /> + ))} + </StyledNavigationScrollbars> + <StyledFooter> + <AppButton.GreenButton disabled={selectionStatus === Selection.none} onClick={onApply}> + {messages.gettext('Apply')} + </AppButton.GreenButton> + </StyledFooter> + </NavigationContainer> + </StyledContainer> + </Layout> + ); +} + +interface IStyledRowTitleProps { + bold?: boolean; +} + +const StyledRow = styled.div({ + display: 'flex', + height: '42px', + padding: '9px 22px', +}); + +const StyledCheckbox = styled.div({ + width: '24px', + height: '24px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.white, + borderRadius: '4px', +}); + +const StyledRowTitle = styled.label((props: IStyledRowTitleProps) => ({ + ...mediumText, + fontSize: '16px', + fontWeight: props.bold ? 800 : 600, + color: colors.white, + marginLeft: '22px', +})); + +interface IProviderRowProps extends IStyledRowTitleProps { + provider: string; + checked: boolean; + onCheck: (provider: string) => void; +} + +function ProviderRow(props: IProviderRowProps) { + const onCheck = useCallback(() => props.onCheck(props.provider), [props.onCheck, props.provider]); + + return ( + <StyledRow onClick={onCheck}> + <StyledCheckbox role="checkbox" aria-label={props.provider} aria-checked={props.checked}> + {props.checked && <ImageView source="icon-tick" width={16} tintColor={colors.green} />} + </StyledCheckbox> + <StyledRowTitle aria-hidden bold={props.bold}> + {props.provider} + </StyledRowTitle> + </StyledRow> + ); +} + +function removeDuplicates(list: string[]): string[] { + return list.reduce( + (result, current) => (result.includes(current) ? result : [...result, current]), + [] as string[], + ); +} diff --git a/gui/src/renderer/components/LocationRow.tsx b/gui/src/renderer/components/LocationRow.tsx index 0ffc4299cc..cf7bc5942c 100644 --- a/gui/src/renderer/components/LocationRow.tsx +++ b/gui/src/renderer/components/LocationRow.tsx @@ -81,13 +81,9 @@ function LocationRow(props: IProps, ref: React.Ref<HTMLDivElement>) { const hasChildren = props.children !== undefined; const buttonRef = useRef<HTMLButtonElement>() as React.RefObject<HTMLButtonElement>; - const toggleCollapse = useCallback( - (event: React.MouseEvent) => { - props.onExpand?.(props.location, !props.expanded); - event.stopPropagation(); - }, - [props.onExpand, props.expanded, props.location], - ); + const toggleCollapse = useCallback(() => { + props.onExpand?.(props.location, !props.expanded); + }, [props.onExpand, props.expanded, props.location]); const handleClick = useCallback(() => props.onSelect?.(props.location), [ props.onSelect, diff --git a/gui/src/renderer/components/SelectLocation.tsx b/gui/src/renderer/components/SelectLocation.tsx index a8375a178c..f7a694917f 100644 --- a/gui/src/renderer/components/SelectLocation.tsx +++ b/gui/src/renderer/components/SelectLocation.tsx @@ -1,4 +1,6 @@ import React from 'react'; +import { sprintf } from 'sprintf-js'; +import { colors } from '../../config.json'; import { LiftedConstraint, RelayLocation } from '../../shared/daemon-rpc-types'; import { messages } from '../../shared/gettext'; import { IRelayLocationRedux } from '../redux/settings/reducers'; @@ -6,6 +8,7 @@ import { LocationScope } from '../redux/userinterface/reducers'; import BridgeLocations, { SpecialBridgeLocationType } from './BridgeLocations'; import CustomScrollbars from './CustomScrollbars'; import ExitLocations from './ExitLocations'; +import ImageView from './ImageView'; import { Layout } from './Layout'; import LocationList, { LocationSelection, LocationSelectionType } from './LocationList'; import { @@ -20,10 +23,18 @@ import { ScopeBarItem } from './ScopeBar'; import { StyledContainer, StyledContent, + StyledFilterIconButton, + StyledFilterContainer, + StyledFilterMenu, StyledNavigationBarAttachment, StyledScopeBar, + StyledFilterByProviderButton, + StyledProvidersCount, + StyledProviderCountRow, + StyledClearProvidersButton, + StyledSettingsHeader, } from './SelectLocationStyles'; -import { HeaderSubTitle } from './SettingsHeader'; +import { HeaderTitle } from './SettingsHeader'; interface IProps { locationScope: LocationScope; @@ -32,11 +43,18 @@ interface IProps { relayLocations: IRelayLocationRedux[]; bridgeLocations: IRelayLocationRedux[]; allowBridgeSelection: boolean; + providers: string[]; onClose: () => void; + onViewFilterByProvider: () => void; onChangeLocationScope: (location: LocationScope) => void; onSelectExitLocation: (location: RelayLocation) => void; onSelectBridgeLocation: (location: RelayLocation) => void; onSelectClosestToExit: () => void; + onClearProviders: () => void; +} + +interface IState { + showFilterMenu: boolean; } interface ISelectLocationSnapshot { @@ -44,7 +62,9 @@ interface ISelectLocationSnapshot { expandedLocations: RelayLocation[]; } -export default class SelectLocation extends React.Component<IProps> { +export default class SelectLocation extends React.Component<IProps, IState> { + public state = { showFilterMenu: false }; + private scrollView = React.createRef<CustomScrollbars>(); private spacePreAllocationViewRef = React.createRef<SpacePreAllocationView>(); private selectedExitLocationRef = React.createRef<React.ReactInstance>(); @@ -55,6 +75,8 @@ export default class SelectLocation extends React.Component<IProps> { private snapshotByScope: { [index: number]: ISelectLocationSnapshot } = {}; + private filterButtonRef = React.createRef<HTMLDivElement>(); + public componentDidMount() { this.scrollToSelectedCell(); } @@ -92,31 +114,72 @@ export default class SelectLocation extends React.Component<IProps> { public render() { return ( - <Layout> + <Layout onClick={this.onClickAnywhere}> <StyledContainer> <NavigationContainer> <NavigationBar alwaysDisplayBarTitle={true}> <NavigationItems> <CloseBarItem action={this.props.onClose} /> - <TitleBarItem> - { - // TRANSLATORS: Title label in navigation bar - messages.pgettext('select-location-nav', 'Select location') - } - </TitleBarItem> + <TitleBarItem /> + + <StyledFilterContainer ref={this.filterButtonRef}> + <StyledFilterIconButton + onClick={this.toggleFilterMenu} + aria-label={messages.gettext('Filter')}> + <ImageView + source="icon-filter-round" + tintColor={colors.white60} + tintHoverColor={colors.white80} + height={24} + width={24} + /> + </StyledFilterIconButton> + {this.state.showFilterMenu && ( + <StyledFilterMenu> + <StyledFilterByProviderButton onClick={this.props.onViewFilterByProvider}> + {messages.pgettext('select-location-view', 'Filter by provider')} + </StyledFilterByProviderButton> + </StyledFilterMenu> + )} + </StyledFilterContainer> </NavigationItems> <StyledNavigationBarAttachment> - <HeaderSubTitle> - {this.props.allowBridgeSelection - ? messages.pgettext( - 'select-location-view', - 'While connected, your traffic will be routed through two secure locations, the entry point (a bridge server) and the exit point (a VPN server).', - ) - : messages.pgettext( - 'select-location-view', - 'While connected, your real location is masked with a private and secure location in the selected region.', + <StyledSettingsHeader> + <HeaderTitle> + { + // TRANSLATORS: Heading in select location view + messages.pgettext('select-location-nav', 'Select location') + } + </HeaderTitle> + </StyledSettingsHeader> + + {this.props.providers.length > 0 && ( + <StyledProviderCountRow> + {messages.pgettext('select-location-view', 'Filtered:')} + <StyledProvidersCount> + {sprintf( + messages.pgettext( + 'select-location-view', + 'Providers: %(numberOfProviders)d', + ), + { + numberOfProviders: this.props.providers.length, + }, )} - </HeaderSubTitle> + <StyledClearProvidersButton + aria-label={messages.gettext('Clear')} + onClick={this.props.onClearProviders}> + <ImageView + height={16} + width={16} + source="icon-close" + tintColor={colors.white60} + tintHoverColor={colors.white80} + /> + </StyledClearProvidersButton> + </StyledProvidersCount> + </StyledProviderCountRow> + )} {this.props.allowBridgeSelection && ( <StyledScopeBar defaultSelectedIndex={this.props.locationScope} @@ -232,6 +295,21 @@ export default class SelectLocation extends React.Component<IProps> { this.spacePreAllocationViewRef.current?.allocate(expandedContentHeight); this.scrollView.current?.scrollIntoView(locationRect); }; + + private toggleFilterMenu = () => { + this.setState((state) => ({ + showFilterMenu: !state.showFilterMenu, + })); + }; + + private onClickAnywhere = (event: React.MouseEvent<HTMLDivElement>) => { + if ( + this.state.showFilterMenu && + !this.filterButtonRef.current?.contains(event.target as HTMLElement) + ) { + this.setState({ showFilterMenu: false }); + } + }; } interface ISpacePreAllocationView { diff --git a/gui/src/renderer/components/SelectLocationStyles.tsx b/gui/src/renderer/components/SelectLocationStyles.tsx index 6a3685ffb4..bf0dc038e8 100644 --- a/gui/src/renderer/components/SelectLocationStyles.tsx +++ b/gui/src/renderer/components/SelectLocationStyles.tsx @@ -1,7 +1,9 @@ import styled from 'styled-components'; import { colors } from '../../config.json'; +import { smallText } from './common-styles'; import { Container } from './Layout'; import { ScopeBar } from './ScopeBar'; +import SettingsHeader from './SettingsHeader'; export const StyledContainer = styled(Container)({ backgroundColor: colors.darkBlue, @@ -22,3 +24,71 @@ export const StyledNavigationBarAttachment = styled.div({ marginTop: '8px', paddingHorizontal: '4px', }); + +export const StyledFilterContainer = styled.div({ + position: 'relative', +}); + +export const StyledFilterIconButton = styled.button({ + borderWidth: 0, + padding: 0, + margin: 0, + cursor: 'default', + backgroundColor: 'transparent', +}); + +export const StyledFilterMenu = styled.div({ + position: 'absolute', + top: 'calc(100% + 4px)', + right: '0', + borderRadius: '4px', + backgroundColor: colors.darkBlue, + overflow: 'hidden', +}); + +export const StyledFilterByProviderButton = styled.button({ + ...smallText, + borderWidth: 0, + margin: 0, + cursor: 'default', + color: colors.white, + padding: '7px 15px', + whiteSpace: 'nowrap', + borderRadius: 0, + backgroundColor: colors.blue, + ':hover': { + backgroundColor: colors.blue80, + }, +}); + +export const StyledSettingsHeader = styled(SettingsHeader)({ + paddingLeft: '6px', + paddingBottom: '11px', +}); + +export const StyledProviderCountRow = styled.div({ + ...smallText, + color: colors.white, + marginLeft: '6px', + marginBottom: '8px', +}); + +export const StyledProvidersCount = styled.div({ + ...smallText, + display: 'inline-flex', + alignItems: 'center', + backgroundColor: colors.blue, + borderRadius: '4px', + padding: '3px 8px', + marginLeft: '6px', + color: colors.white, +}); + +export const StyledClearProvidersButton = styled.div({ + display: 'inline-block', + borderWidth: 0, + padding: 0, + margin: '0 0 0 6px', + cursor: 'default', + backgroundColor: 'transparent', +}); diff --git a/gui/src/renderer/components/SettingsHeader.tsx b/gui/src/renderer/components/SettingsHeader.tsx index 808b6f80c7..ea1d2ab634 100644 --- a/gui/src/renderer/components/SettingsHeader.tsx +++ b/gui/src/renderer/components/SettingsHeader.tsx @@ -18,11 +18,12 @@ export const HeaderSubTitle = styled.span(smallText); interface ISettingsHeaderProps { children?: React.ReactNode; + className?: string; } export default function SettingsHeader(props: ISettingsHeaderProps) { return ( - <Container> + <Container className={props.className}> {React.Children.map(props.children, (child) => { return React.isValidElement(child) ? <ContentWrapper>{child}</ContentWrapper> : undefined; })} diff --git a/gui/src/renderer/containers/SelectLocationPage.tsx b/gui/src/renderer/containers/SelectLocationPage.tsx index b3aab7eb69..ea931c8ea9 100644 --- a/gui/src/renderer/containers/SelectLocationPage.tsx +++ b/gui/src/renderer/containers/SelectLocationPage.tsx @@ -7,6 +7,8 @@ import RelaySettingsBuilder from '../../shared/relay-settings-builder'; import SelectLocation from '../components/SelectLocation'; import withAppContext, { IAppContext } from '../context'; import { IHistoryProps, withHistory } from '../lib/history'; +import { RoutePath } from '../lib/routes'; +import { IRelayLocationRedux } from '../redux/settings/reducers'; import { IReduxState, ReduxDispatch } from '../redux/store'; import userInterfaceActions from '../redux/userinterface/actions'; import { LocationScope } from '../redux/userinterface/reducers'; @@ -31,13 +33,17 @@ const mapStateToProps = (state: IReduxState) => { ? state.userInterface.locationScope : LocationScope.relay; + const relaySettings = state.settings.relaySettings; + const providers = 'normal' in relaySettings ? relaySettings.normal.providers : []; + return { selectedExitLocation, selectedBridgeLocation, - relayLocations: state.settings.relayLocations, - bridgeLocations: state.settings.bridgeLocations, + relayLocations: filterLocationsByProvider(state.settings.relayLocations, providers), + bridgeLocations: filterLocationsByProvider(state.settings.bridgeLocations, providers), locationScope, allowBridgeSelection, + providers, }; }; const mapDispatchToProps = (dispatch: ReduxDispatch, props: IHistoryProps & IAppContext) => { @@ -45,6 +51,7 @@ const mapDispatchToProps = (dispatch: ReduxDispatch, props: IHistoryProps & IApp return { onClose: () => props.history.dismiss(), + onViewFilterByProvider: () => props.history.push(RoutePath.filterByProvider), onChangeLocationScope: (scope: LocationScope) => { userInterface.setLocationScope(scope); }, @@ -83,9 +90,31 @@ const mapDispatchToProps = (dispatch: ReduxDispatch, props: IHistoryProps & IApp log.error(`Failed to set the bridge location to closest to exit: ${e.message}`); } }, + onClearProviders: async () => { + await props.app.updateRelaySettings({ normal: { providers: [] } }); + }, }; }; +function filterLocationsByProvider( + locations: IRelayLocationRedux[], + providers: string[], +): IRelayLocationRedux[] { + return providers.length === 0 + ? locations + : locations + .map((country) => ({ + ...country, + cities: country.cities + .map((city) => ({ + ...city, + relays: city.relays.filter((relay) => providers.includes(relay.provider)), + })) + .filter((city) => city.relays.length > 0), + })) + .filter((country) => country.cities.length > 0); +} + export default withAppContext( withHistory(connect(mapStateToProps, mapDispatchToProps)(SelectLocation)), ); diff --git a/gui/src/renderer/lib/routes.ts b/gui/src/renderer/lib/routes.ts index bf8e6e1f60..8d8c2ce27b 100644 --- a/gui/src/renderer/lib/routes.ts +++ b/gui/src/renderer/lib/routes.ts @@ -15,4 +15,5 @@ export enum RoutePath { splitTunneling = '/settings/advanced/split-tunneling', support = '/settings/support', selectLocation = '/select-location', + filterByProvider = '/select-location/filter-by-provider', } diff --git a/gui/src/renderer/redux/settings/reducers.ts b/gui/src/renderer/redux/settings/reducers.ts index 8f61721a6e..bbab124d36 100644 --- a/gui/src/renderer/redux/settings/reducers.ts +++ b/gui/src/renderer/redux/settings/reducers.ts @@ -18,6 +18,7 @@ export type RelaySettingsRedux = normal: { tunnelProtocol: LiftedConstraint<TunnelProtocol>; location: LiftedConstraint<RelayLocation>; + providers: string[]; openvpn: { port: LiftedConstraint<number>; protocol: LiftedConstraint<RelayProtocol>; @@ -47,6 +48,7 @@ export type BridgeSettingsRedux = export interface IRelayLocationRelayRedux { hostname: string; + provider: string; ipv4AddrIn: string; includeInCountry: boolean; active: boolean; @@ -156,6 +158,7 @@ const initialState: ISettingsReduxState = { normal: { location: 'any', tunnelProtocol: 'any', + providers: [], wireguard: { port: 'any' }, openvpn: { port: 'any', diff --git a/gui/src/renderer/redux/store.ts b/gui/src/renderer/redux/store.ts index 3579978425..79700497c6 100644 --- a/gui/src/renderer/redux/store.ts +++ b/gui/src/renderer/redux/store.ts @@ -1,3 +1,4 @@ +import { useSelector as useReduxSelector } from 'react-redux'; import { combineReducers, compose, createStore, Dispatch } from 'redux'; import accountActions, { AccountAction } from './account/actions'; @@ -62,3 +63,7 @@ function composeEnhancers(): typeof compose { (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({ actionCreators })() : compose(); } + +export function useSelector<R>(fn: (state: IReduxState) => R): R { + return useReduxSelector(fn); +} diff --git a/gui/src/shared/bridge-settings-builder.ts b/gui/src/shared/bridge-settings-builder.ts index 28e701ec0a..fc4eeaa682 100644 --- a/gui/src/shared/bridge-settings-builder.ts +++ b/gui/src/shared/bridge-settings-builder.ts @@ -9,6 +9,7 @@ export default class BridgeSettingsBuilder { return { normal: { location: this.payload.location, + providers: this.payload.providers ?? [], }, }; } else { diff --git a/gui/src/shared/daemon-rpc-types.ts b/gui/src/shared/daemon-rpc-types.ts index 4c806b11b8..0ce24bf8a2 100644 --- a/gui/src/shared/daemon-rpc-types.ts +++ b/gui/src/shared/daemon-rpc-types.ts @@ -14,6 +14,7 @@ export interface ILocation { mullvadExitIp: boolean; hostname?: string; bridgeHostname?: string; + provider?: string; } export type FirewallPolicyError = @@ -136,6 +137,7 @@ export type TunnelProtocol = 'wireguard' | 'openvpn'; interface IRelaySettingsNormal<OpenVpn, Wireguard> { location: Constraint<RelayLocation>; tunnelProtocol: Constraint<TunnelProtocol>; + providers: string[]; openvpnConstraints: OpenVpn; wireguardConstraints: Wireguard; } @@ -213,6 +215,7 @@ export interface IRelayListCity { export interface IRelayListHostname { hostname: string; + provider: string; ipv4AddrIn: string; includeInCountry: boolean; active: boolean; @@ -340,6 +343,7 @@ export type SplitTunnelSettings = { export interface IBridgeConstraints { location: Constraint<RelayLocation>; + providers: string[]; } export type BridgeSettings = { normal: IBridgeConstraints } | { custom: ProxySettings }; diff --git a/gui/src/shared/localization-contexts.ts b/gui/src/shared/localization-contexts.ts index 6d86920870..7e80e68a5b 100644 --- a/gui/src/shared/localization-contexts.ts +++ b/gui/src/shared/localization-contexts.ts @@ -14,6 +14,8 @@ export type LocalizationContexts = | 'account-expiry' | 'select-location-view' | 'select-location-nav' + | 'filter-by-provider-view' + | 'filter-by-provider-nav' | 'settings-view' | 'navigation-bar' | 'account-view' |
