summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2021-08-18 13:37:46 +0200
committerOskar Nyberg <oskar@mullvad.net>2021-08-18 13:37:46 +0200
commit3f4430ea27c56efdc524276a6c4e02e8080b52d4 (patch)
treed5cca3468c47365f4b6dcff93c36703533489e4a
parent6bcea0fd559988f94962bb23c159ba1b211b17bd (diff)
parentb43b45007fb69430b502a443c18bcd4c96aa0f91 (diff)
downloadmullvadvpn-3f4430ea27c56efdc524276a6c4e02e8080b52d4.tar.xz
mullvadvpn-3f4430ea27c56efdc524276a6c4e02e8080b52d4.zip
Merge branch 'filter-by-provider'
-rw-r--r--CHANGELOG.md3
-rw-r--r--gui/assets/images/icon-filter-round.svg3
-rw-r--r--gui/locales/messages.pot36
-rw-r--r--gui/src/main/daemon-rpc.ts11
-rw-r--r--gui/src/main/index.ts2
-rw-r--r--gui/src/renderer/app.tsx2
-rw-r--r--gui/src/renderer/components/AppRouter.tsx2
-rw-r--r--gui/src/renderer/components/FilterByProvider.tsx204
-rw-r--r--gui/src/renderer/components/LocationRow.tsx10
-rw-r--r--gui/src/renderer/components/SelectLocation.tsx116
-rw-r--r--gui/src/renderer/components/SelectLocationStyles.tsx70
-rw-r--r--gui/src/renderer/components/SettingsHeader.tsx3
-rw-r--r--gui/src/renderer/containers/SelectLocationPage.tsx33
-rw-r--r--gui/src/renderer/lib/routes.ts1
-rw-r--r--gui/src/renderer/redux/settings/reducers.ts3
-rw-r--r--gui/src/renderer/redux/store.ts5
-rw-r--r--gui/src/shared/bridge-settings-builder.ts1
-rw-r--r--gui/src/shared/daemon-rpc-types.ts4
-rw-r--r--gui/src/shared/localization-contexts.ts2
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'