summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2022-11-25 14:47:33 +0100
committerOskar Nyberg <oskar@mullvad.net>2022-11-25 14:47:33 +0100
commit044b3868dfa1bd8c4573624aecd4c17b053d256e (patch)
tree2f1b8d921540a9736a96eac6ddba115624c04ac9
parent1fcdf3c0ab63dd78fc491f0537a0a09a367b804c (diff)
parent29e5fadc291946de824ea36fd94a0db197f42af7 (diff)
downloadmullvadvpn-044b3868dfa1bd8c4573624aecd4c17b053d256e.tar.xz
mullvadvpn-044b3868dfa1bd8c4573624aecd4c17b053d256e.zip
Merge branch 'add-location-list-filter'
-rw-r--r--CHANGELOG.md1
-rw-r--r--gui/assets/images/icon-search.svg17
-rw-r--r--gui/locales/messages.pot26
-rw-r--r--gui/src/main/index.ts36
-rw-r--r--gui/src/main/relay-list.ts121
-rw-r--r--gui/src/renderer/app.tsx24
-rw-r--r--gui/src/renderer/components/AppRouter.tsx4
-rw-r--r--gui/src/renderer/components/BridgeLocations.tsx70
-rw-r--r--gui/src/renderer/components/CustomScrollbars.tsx11
-rw-r--r--gui/src/renderer/components/Filter.tsx49
-rw-r--r--gui/src/renderer/components/LocationList.tsx635
-rw-r--r--gui/src/renderer/components/LocationRow.tsx207
-rw-r--r--gui/src/renderer/components/Locations.tsx48
-rw-r--r--gui/src/renderer/components/SearchBar.tsx112
-rw-r--r--gui/src/renderer/components/SelectLocation.tsx471
-rw-r--r--gui/src/renderer/components/SplitTunnelingSettings.tsx60
-rw-r--r--gui/src/renderer/components/SplitTunnelingSettingsStyles.tsx65
-rw-r--r--gui/src/renderer/components/select-location/CombinedLocationList.tsx51
-rw-r--r--gui/src/renderer/components/select-location/LocationRow.tsx258
-rw-r--r--gui/src/renderer/components/select-location/RelayListContext.tsx311
-rw-r--r--gui/src/renderer/components/select-location/RelayLocationList.tsx58
-rw-r--r--gui/src/renderer/components/select-location/ScopeBar.tsx (renamed from gui/src/renderer/components/ScopeBar.tsx)19
-rw-r--r--gui/src/renderer/components/select-location/ScrollPositionContext.tsx88
-rw-r--r--gui/src/renderer/components/select-location/SelectLocation.tsx351
-rw-r--r--gui/src/renderer/components/select-location/SelectLocationContainer.tsx40
-rw-r--r--gui/src/renderer/components/select-location/SelectLocationStyles.tsx (renamed from gui/src/renderer/components/SelectLocationStyles.tsx)54
-rw-r--r--gui/src/renderer/components/select-location/SpacePreAllocationView.tsx30
-rw-r--r--gui/src/renderer/components/select-location/SpecialLocationList.tsx89
-rw-r--r--gui/src/renderer/components/select-location/select-location-helpers.ts185
-rw-r--r--gui/src/renderer/components/select-location/select-location-hooks.ts93
-rw-r--r--gui/src/renderer/components/select-location/select-location-types.ts87
-rw-r--r--gui/src/renderer/containers/SelectLocationPage.tsx176
-rw-r--r--gui/src/renderer/lib/filter-locations.ts174
-rw-r--r--gui/src/renderer/lib/utilityHooks.ts12
-rw-r--r--gui/src/renderer/redux/settings/actions.ts16
-rw-r--r--gui/src/renderer/redux/settings/reducers.ts52
-rw-r--r--gui/src/shared/daemon-rpc-types.ts2
-rw-r--r--gui/src/shared/ipc-schema.ts13
38 files changed, 2076 insertions, 2040 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d49ea21a51..533044c4dd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,7 @@ Line wrap the file at 100 chars. Th
## [Unreleased]
### Added
- Add quit button to tray context menu on Linux and Window.
+- Add search bar to location list in desktop app.
#### Windows
- Remove all settings when the app is uninstalled silently.
diff --git a/gui/assets/images/icon-search.svg b/gui/assets/images/icon-search.svg
new file mode 100644
index 0000000000..8c54192c53
--- /dev/null
+++ b/gui/assets/images/icon-search.svg
@@ -0,0 +1,17 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
+ <defs>
+ <path id="d6xp5m7c6a" d="M7.5 0C11.642 0 15 3.358 15 7.5c0 1.77-.613 3.397-1.64 4.68l4.169 4.294c.55.566.54 1.487-.02 2.057-.56.57-1.46.574-2.01.008l-4.352-4.484c-1.08.602-2.323.945-3.647.945C3.358 15 0 11.642 0 7.5 0 3.358 3.358 0 7.5 0zm0 2C4.462 2 2 4.462 2 7.5S4.462 13 7.5 13 13 10.538 13 7.5 10.538 2 7.5 2z"/>
+ </defs>
+ <g fill="none" fill-rule="evenodd">
+ <g transform="translate(3 3)">
+ <mask id="ybpe0o1wtb" fill="#fff">
+ <use xlink:href="#d6xp5m7c6a"/>
+ </mask>
+ <use fill="#D8D8D8" xlink:href="#d6xp5m7c6a"/>
+ <g fill="#294D73" mask="url(#ybpe0o1wtb)">
+ <path d="M0 0H24V24H0z" transform="translate(-3 -3)"/>
+ </g>
+ <path fill="#FBFCFD" fill-opacity=".2" d="M8.608 12.175s-3.092.026-4.914-1.796c-.585-.585-.947-1.986-1.086-4.204.525 1.415 1.28 2.638 2.266 3.668 1.522 1.523 3.734 2.332 3.734 2.332z" mask="url(#ybpe0o1wtb)" transform="rotate(7 5.608 9.175)"/>
+ </g>
+ </g>
+</svg>
diff --git a/gui/locales/messages.pot b/gui/locales/messages.pot
index 06e1043b1e..62e195a631 100644
--- a/gui/locales/messages.pot
+++ b/gui/locales/messages.pot
@@ -172,6 +172,9 @@ msgstr ""
msgid "Next"
msgstr ""
+msgid "No result for <b>%(searchTerm)s</b>."
+msgstr ""
+
msgid "Off"
msgstr ""
@@ -190,6 +193,9 @@ msgstr ""
msgid "Reconnect"
msgstr ""
+msgid "Search for..."
+msgstr ""
+
msgid "SECURE CONNECTION"
msgstr ""
@@ -206,6 +212,9 @@ msgstr ""
msgid "This setting increases latency. Use only if needed."
msgstr ""
+msgid "Try a different search."
+msgstr ""
+
msgid "UDP"
msgstr ""
@@ -978,11 +987,6 @@ msgctxt "select-location-view"
msgid "Providers: %(numberOfProviders)d"
msgstr ""
-#. Heading in select location view
-msgctxt "select-location-view"
-msgid "Select location"
-msgstr ""
-
msgctxt "select-location-view"
msgid "The app selects a random bridge server, but servers have a higher probability the closer they are to you."
msgstr ""
@@ -1060,10 +1064,6 @@ msgid "Excluded apps"
msgstr ""
msgctxt "split-tunneling-view"
-msgid "Filter..."
-msgstr ""
-
-msgctxt "split-tunneling-view"
msgid "Find another app"
msgstr ""
@@ -1075,10 +1075,6 @@ msgctxt "split-tunneling-view"
msgid "Launch"
msgstr ""
-msgctxt "split-tunneling-view"
-msgid "No result for <b>%(searchTerm)s</b>."
-msgstr ""
-
#. This error message is shown if the user tries to launch a Linux desktop
#. entry file that doesn't contain the required 'Exec' value.
msgctxt "split-tunneling-view"
@@ -1092,10 +1088,6 @@ msgctxt "split-tunneling-view"
msgid "Please try again or contact support."
msgstr ""
-msgctxt "split-tunneling-view"
-msgid "Try a different search."
-msgstr ""
-
#. Error message showed in a dialog when an application failes to launch.
msgctxt "split-tunneling-view"
msgid "Unable to launch selection. %(detailedErrorMessage)s"
diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts
index 94607766a0..320185abd8 100644
--- a/gui/src/main/index.ts
+++ b/gui/src/main/index.ts
@@ -7,7 +7,13 @@ import util from 'util';
import config from '../config.json';
import { hasExpired } from '../shared/account-expiry';
import { IWindowsApplication } from '../shared/application-types';
-import { DaemonEvent, DeviceEvent, ISettings, TunnelState } from '../shared/daemon-rpc-types';
+import {
+ DaemonEvent,
+ DeviceEvent,
+ IRelayListWithEndpointData,
+ ISettings,
+ TunnelState,
+} from '../shared/daemon-rpc-types';
import { messages, relayLocations } from '../shared/gettext';
import { SYSTEM_PREFERRED_LOCALE_KEY } from '../shared/gui-settings-state';
import { ITranslations, MacOsScrollbarVisibility } from '../shared/ipc-schema';
@@ -40,7 +46,6 @@ import NotificationController, {
} from './notification-controller';
import * as problemReport from './problem-report';
import ReconnectionBackoff from './reconnection-backoff';
-import RelayList from './relay-list';
import Settings, { SettingsDelegate } from './settings';
import TunnelStateHandler, {
TunnelStateHandlerDelegate,
@@ -75,7 +80,6 @@ class ApplicationMain
private notificationController = new NotificationController(this);
private version = new Version(this, this.daemonRpc, UPDATE_NOTIFICATION_DISABLED);
private settings = new Settings(this, this.daemonRpc, this.version.currentVersion);
- private relayList = new RelayList();
private userInterface?: UserInterface;
private account: Account = new Account(this, this.daemonRpc);
private tunnelState = new TunnelStateHandler(this);
@@ -102,6 +106,8 @@ class ApplicationMain
private navigationHistory?: IHistoryObject;
+ private relayList?: IRelayListWithEndpointData;
+
public run() {
// Remove window animations to combat window flickering when opening window. Can be removed when
// this issue has been resolved: https://github.com/electron/electron/issues/12130
@@ -514,11 +520,7 @@ class ApplicationMain
// fetch relays
try {
- this.relayList.setRelays(
- await this.daemonRpc.getRelayLocations(),
- this.settings.relaySettings,
- this.settings.bridgeState,
- );
+ this.setRelayList(await this.daemonRpc.getRelayLocations());
} catch (e) {
const error = e as Error;
log.error(`Failed to fetch relay locations: ${error.message}`);
@@ -610,11 +612,7 @@ class ApplicationMain
} else if ('settings' in daemonEvent) {
this.setSettings(daemonEvent.settings);
} else if ('relayList' in daemonEvent) {
- this.relayList.setRelays(
- daemonEvent.relayList,
- this.settings.relaySettings,
- this.settings.bridgeState,
- );
+ IpcMainEventChannel.relays.notify?.(daemonEvent.relayList);
} else if ('appVersionInfo' in daemonEvent) {
this.version.setLatestVersion(daemonEvent.appVersionInfo);
} else if ('device' in daemonEvent) {
@@ -652,10 +650,11 @@ class ApplicationMain
if (windowsSplitTunneling) {
void this.updateSplitTunnelingApplications(newSettings.splitTunnel.appsList);
}
+ }
- // since settings can have the relay constraints changed, the relay
- // list should also be updated
- this.relayList.updateSettings(newSettings.relaySettings, newSettings.bridgeState);
+ private setRelayList(relayList: IRelayListWithEndpointData) {
+ this.relayList = relayList;
+ IpcMainEventChannel.relays.notify?.(relayList);
}
private async updateSplitTunnelingApplications(appList: string[]): Promise<void> {
@@ -677,10 +676,7 @@ class ApplicationMain
settings: this.settings.all,
isPerformingPostUpgrade: this.isPerformingPostUpgrade,
deviceState: this.account.deviceState,
- relayListPair: this.relayList.getProcessedRelays(
- this.settings.relaySettings,
- this.settings.bridgeState,
- ),
+ relayList: this.relayList,
currentVersion: this.version.currentVersion,
upgradeVersion: this.version.upgradeVersion,
guiSettings: this.settings.gui.state,
diff --git a/gui/src/main/relay-list.ts b/gui/src/main/relay-list.ts
deleted file mode 100644
index c7b4a1fff1..0000000000
--- a/gui/src/main/relay-list.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-import {
- BridgeState,
- IRelayList,
- IRelayListWithEndpointData,
- liftConstraint,
- RelaySettings,
-} from '../shared/daemon-rpc-types';
-import { IRelayListPair } from '../shared/ipc-schema';
-import { IpcMainEventChannel } from './ipc-event-channel';
-
-export default class RelayList {
- private relays: IRelayListWithEndpointData = {
- relayList: {
- countries: [],
- },
- wireguardEndpointData: {
- portRanges: [],
- udp2tcpPorts: [],
- },
- };
-
- public setRelays(
- newRelayList: IRelayListWithEndpointData,
- relaySettings: RelaySettings,
- bridgeState: BridgeState,
- ) {
- this.relays = newRelayList;
-
- const processedRelays = this.processRelays(newRelayList, relaySettings, bridgeState);
- IpcMainEventChannel.relays.notify?.(processedRelays);
- }
-
- public updateSettings(relaySettings: RelaySettings, bridgeState: BridgeState) {
- this.setRelays(this.relays, relaySettings, bridgeState);
- }
-
- public getProcessedRelays(relaySettings: RelaySettings, bridgeState: BridgeState) {
- return this.processRelays(this.relays, relaySettings, bridgeState);
- }
-
- private processRelays(
- relayList: IRelayListWithEndpointData,
- relaySettings: RelaySettings,
- bridgeState: BridgeState,
- ): IRelayListPair {
- const filteredRelays = this.processRelaysForPresentation(relayList.relayList, relaySettings);
- const filteredBridges = this.processBridgesForPresentation(relayList.relayList, bridgeState);
-
- return {
- relays: filteredRelays,
- bridges: filteredBridges,
- wireguardEndpointData: relayList.wireguardEndpointData,
- };
- }
-
- private processRelaysForPresentation(
- relayList: IRelayList,
- relaySettings: RelaySettings,
- ): IRelayList {
- const tunnelProtocol =
- 'normal' in relaySettings ? liftConstraint(relaySettings.normal.tunnelProtocol) : undefined;
-
- const filteredCountries = relayList.countries
- .map((country) => ({
- ...country,
- cities: country.cities
- .map((city) => ({
- ...city,
- relays: city.relays.filter((relay) => {
- if (relay.endpointType != 'bridge') {
- switch (tunnelProtocol) {
- case 'openvpn':
- return relay.endpointType == 'openvpn';
-
- case 'wireguard':
- return relay.endpointType == 'wireguard';
-
- case 'any': {
- const useMultihop =
- 'normal' in relaySettings &&
- relaySettings.normal.wireguardConstraints.useMultihop;
- return !useMultihop || relay.endpointType == 'wireguard';
- }
- default:
- return false;
- }
- } else {
- return false;
- }
- }),
- }))
- .filter((city) => city.relays.length > 0),
- }))
- .filter((country) => country.cities.length > 0);
-
- return { countries: filteredCountries };
- }
-
- private processBridgesForPresentation(
- relayList: IRelayList,
- bridgeState: BridgeState,
- ): IRelayList {
- if (bridgeState === 'on') {
- const filteredCountries = relayList.countries
- .map((country) => ({
- ...country,
- cities: country.cities
- .map((city) => ({
- ...city,
- relays: city.relays.filter((relay) => relay.endpointType == 'bridge'),
- }))
- .filter((city) => city.relays.length > 0),
- }))
- .filter((country) => country.cities.length > 0);
-
- return { countries: filteredCountries };
- } else {
- return { countries: [] };
- }
- }
-}
diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx
index 80e969a920..fc6a32aa87 100644
--- a/gui/src/renderer/app.tsx
+++ b/gui/src/renderer/app.tsx
@@ -15,6 +15,7 @@ import {
IDeviceRemoval,
IDnsOptions,
ILocation,
+ IRelayListWithEndpointData,
ISettings,
liftConstraint,
ObfuscationSettings,
@@ -24,7 +25,6 @@ import {
} from '../shared/daemon-rpc-types';
import { messages, relayLocations } from '../shared/gettext';
import { IGuiSettingsState, SYSTEM_PREFERRED_LOCALE_KEY } from '../shared/gui-settings-state';
-import { IRelayListPair } from '../shared/ipc-schema';
import { IChangelog, ICurrentAppVersionInfo, IHistoryObject } from '../shared/ipc-types';
import log, { ConsoleOutput } from '../shared/logging';
import { LogLevel } from '../shared/logging-types';
@@ -92,7 +92,7 @@ export default class AppRenderer {
private location?: Partial<ILocation>;
private lastDisconnectedLocation?: Partial<ILocation>;
- private relayListPair!: IRelayListPair;
+ private relayList?: IRelayListWithEndpointData;
private tunnelState!: TunnelState;
private settings!: ISettings;
private deviceState?: DeviceState;
@@ -150,7 +150,7 @@ export default class AppRenderer {
this.updateBlockedState(this.tunnelState, newSettings.blockWhenDisconnected);
});
- IpcRendererEventChannel.relays.listen((relayListPair: IRelayListPair) => {
+ IpcRendererEventChannel.relays.listen((relayListPair: IRelayListWithEndpointData) => {
this.setRelayListPair(relayListPair);
});
@@ -217,7 +217,7 @@ export default class AppRenderer {
this.setTunnelState(initialState.tunnelState);
this.updateBlockedState(initialState.tunnelState, initialState.settings.blockWhenDisconnected);
- this.setRelayListPair(initialState.relayListPair);
+ this.setRelayListPair(initialState.relayList);
this.setCurrentVersion(initialState.currentVersion);
this.setUpgradeVersion(initialState.upgradeVersion);
this.setGuiSettings(initialState.guiSettings);
@@ -827,20 +827,16 @@ export default class AppRenderer {
}
}
- private setRelayListPair(relayListPair: IRelayListPair) {
- this.relayListPair = relayListPair;
+ private setRelayListPair(relayListPair?: IRelayListWithEndpointData) {
+ this.relayList = relayListPair;
this.propagateRelayListPairToRedux();
}
private propagateRelayListPairToRedux() {
- const relays = this.relayListPair.relays.countries;
- const bridges = this.relayListPair.bridges.countries;
-
- this.reduxActions.settings.updateRelayLocations(relays);
- this.reduxActions.settings.updateBridgeLocations(bridges);
- this.reduxActions.settings.updateWireguardEndpointData(
- this.relayListPair.wireguardEndpointData,
- );
+ if (this.relayList) {
+ this.reduxActions.settings.updateRelayLocations(this.relayList.relayList.countries);
+ this.reduxActions.settings.updateWireguardEndpointData(this.relayList.wireguardEndpointData);
+ }
}
private setCurrentVersion(versionInfo: ICurrentAppVersionInfo) {
diff --git a/gui/src/renderer/components/AppRouter.tsx b/gui/src/renderer/components/AppRouter.tsx
index cab40d9db9..bd2ae6e53f 100644
--- a/gui/src/renderer/components/AppRouter.tsx
+++ b/gui/src/renderer/components/AppRouter.tsx
@@ -1,8 +1,8 @@
import { createRef, useCallback, useEffect, useState } from 'react';
import { Route, Switch } from 'react-router';
+import SelectLocation from '../components/select-location/SelectLocationContainer';
import LoginPage from '../containers/LoginPage';
-import SelectLocationPage from '../containers/SelectLocationPage';
import { useAppContext } from '../context';
import { ITransitionSpecification, transitions, useHistory } from '../lib/history';
import { RoutePath } from '../lib/routes';
@@ -81,7 +81,7 @@ export default function AppRouter() {
<Route exact path={RoutePath.support} component={Support} />
<Route exact path={RoutePath.problemReport} component={ProblemReport} />
<Route exact path={RoutePath.debug} component={Debug} />
- <Route exact path={RoutePath.selectLocation} component={SelectLocationPage} />
+ <Route exact path={RoutePath.selectLocation} component={SelectLocation} />
<Route exact path={RoutePath.filter} component={Filter} />
</Switch>
</TransitionView>
diff --git a/gui/src/renderer/components/BridgeLocations.tsx b/gui/src/renderer/components/BridgeLocations.tsx
deleted file mode 100644
index 355b462222..0000000000
--- a/gui/src/renderer/components/BridgeLocations.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-import * as React from 'react';
-
-import { LiftedConstraint, RelayLocation } from '../../shared/daemon-rpc-types';
-import { messages } from '../../shared/gettext';
-import { IRelayLocationRedux } from '../redux/settings/reducers';
-import LocationList, {
- LocationSelection,
- LocationSelectionType,
- RelayLocations,
- SpecialLocation,
- SpecialLocationIcon,
- SpecialLocations,
-} from './LocationList';
-
-export enum SpecialBridgeLocationType {
- closestToExit = 0,
-}
-
-interface IBridgeLocationsProps {
- source: IRelayLocationRedux[];
- locale: string;
- defaultExpandedLocations?: RelayLocation[];
- selectedValue?: LiftedConstraint<RelayLocation>;
- selectedElementRef?: React.Ref<React.ReactInstance>;
- onSelect?: (value: LocationSelection<SpecialBridgeLocationType>) => void;
- onWillExpand?: (locationRect: DOMRect, expandedContentHeight: number) => void;
- onTransitionEnd?: () => void;
-}
-
-const BridgeLocations = React.forwardRef(function BridgeLocationsT(
- props: IBridgeLocationsProps,
- ref: React.Ref<LocationList<SpecialBridgeLocationType>>,
-) {
- const selectedValue:
- | LocationSelection<SpecialBridgeLocationType>
- | undefined = props.selectedValue
- ? props.selectedValue === 'any'
- ? { type: LocationSelectionType.special, value: SpecialBridgeLocationType.closestToExit }
- : { type: LocationSelectionType.relay, value: props.selectedValue }
- : undefined;
-
- return (
- <LocationList
- ref={ref}
- defaultExpandedLocations={props.defaultExpandedLocations}
- selectedValue={selectedValue}
- selectedElementRef={props.selectedElementRef}
- onSelect={props.onSelect}>
- <SpecialLocations>
- <SpecialLocation
- icon={SpecialLocationIcon.geoLocation}
- value={SpecialBridgeLocationType.closestToExit}
- info={messages.pgettext(
- 'select-location-view',
- 'The app selects a random bridge server, but servers have a higher probability the closer they are to you.',
- )}>
- {messages.gettext('Automatic')}
- </SpecialLocation>
- </SpecialLocations>
- <RelayLocations
- source={props.source}
- locale={props.locale}
- onWillExpand={props.onWillExpand}
- onTransitionEnd={props.onTransitionEnd}
- />
- </LocationList>
- );
-});
-
-export default BridgeLocations;
diff --git a/gui/src/renderer/components/CustomScrollbars.tsx b/gui/src/renderer/components/CustomScrollbars.tsx
index a760647cbe..ab95d66511 100644
--- a/gui/src/renderer/components/CustomScrollbars.tsx
+++ b/gui/src/renderer/components/CustomScrollbars.tsx
@@ -137,17 +137,12 @@ class CustomScrollbars extends React.Component<IProps, IState> {
public scrollToTop(smooth = false) {
const scrollable = this.scrollableRef.current;
- if (scrollable) {
- scrollable.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' });
- }
+ scrollable?.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' });
}
- public scrollTo(x: number, y: number) {
+ public scrollTo(x: number, y: number, smooth = false) {
const scrollable = this.scrollableRef.current;
- if (scrollable) {
- scrollable.scrollLeft = x;
- scrollable.scrollTop = y;
- }
+ scrollable?.scrollTo({ top: y, left: x, behavior: smooth ? 'smooth' : 'auto' });
}
public scrollToElement(child: HTMLElement, scrollPosition: ScrollPosition) {
diff --git a/gui/src/renderer/components/Filter.tsx b/gui/src/renderer/components/Filter.tsx
index 1bb208cd7f..26ed781396 100644
--- a/gui/src/renderer/components/Filter.tsx
+++ b/gui/src/renderer/components/Filter.tsx
@@ -5,9 +5,13 @@ import { colors } from '../../config.json';
import { Ownership } from '../../shared/daemon-rpc-types';
import { messages } from '../../shared/gettext';
import { useAppContext } from '../context';
-import filterLocations from '../lib/filter-locations';
+import {
+ EndpointType,
+ filterLocations,
+ filterLocationsByEndPointType,
+} from '../lib/filter-locations';
import { useHistory } from '../lib/history';
-import { useBoolean } from '../lib/utilityHooks';
+import { useBoolean, useNormalRelaySettings } from '../lib/utilityHooks';
import { IRelayLocationRedux } from '../redux/settings/reducers';
import { IReduxState, useSelector } from '../redux/store';
import Accordion from './Accordion';
@@ -111,20 +115,31 @@ export default function Filter() {
// Returns only the options for each filter that are compatible with current filter selection.
function useFilteredFilters(providers: string[], ownership: Ownership) {
- const locations = useSelector((state) =>
- state.settings.relayLocations.concat(
- state.settings.bridgeState === 'on' ? state.settings.bridgeLocations : [],
- ),
- );
+ const relaySettings = useNormalRelaySettings();
+ const bridgeState = useSelector((state) => state.settings.bridgeState);
+ const locations = useSelector((state) => state.settings.relayLocations);
+
+ const endpointType = bridgeState === 'on' ? EndpointType.any : EndpointType.exit;
const availableProviders = useMemo(() => {
- const filteredRelays = filterLocations(locations, [], ownership);
- return providersFromRelays(filteredRelays);
+ const relayListForEndpointType = filterLocationsByEndPointType(
+ locations,
+ endpointType,
+ relaySettings,
+ );
+ const relaylistForFilters = filterLocations(relayListForEndpointType, ownership, []);
+ return providersFromRelays(relaylistForFilters);
}, [locations, ownership]);
const availableOwnershipOptions = useMemo(() => {
- const filteredRelays = filterLocations(locations, providers, Ownership.any);
- const filteredRelayOwnership = filteredRelays.flatMap((country) =>
+ const relayListForEndpointType = filterLocationsByEndPointType(
+ locations,
+ endpointType,
+ relaySettings,
+ );
+ const relaylistForFilters = filterLocations(relayListForEndpointType, Ownership.any, providers);
+
+ const filteredRelayOwnership = relaylistForFilters.flatMap((country) =>
country.cities.flatMap((city) => city.relays.map((relay) => relay.owned)),
);
@@ -151,11 +166,15 @@ function providersFromRelays(relays: IRelayLocationRedux[]) {
}
function providersSelector(state: IReduxState): Record<string, boolean> {
- const providerConstraint =
- 'normal' in state.settings.relaySettings ? state.settings.relaySettings.normal.providers : [];
+ const relaySettings =
+ 'normal' in state.settings.relaySettings ? state.settings.relaySettings.normal : undefined;
+ const providerConstraint = relaySettings?.providers ?? [];
- const relays = state.settings.relayLocations.concat(
- state.settings.bridgeState === 'on' ? state.settings.bridgeLocations : [],
+ const endpointType = state.settings.bridgeState === 'on' ? EndpointType.any : EndpointType.exit;
+ const relays = filterLocationsByEndPointType(
+ state.settings.relayLocations,
+ endpointType,
+ relaySettings,
);
const providers = providersFromRelays(relays);
diff --git a/gui/src/renderer/components/LocationList.tsx b/gui/src/renderer/components/LocationList.tsx
deleted file mode 100644
index 19c3eca0a5..0000000000
--- a/gui/src/renderer/components/LocationList.tsx
+++ /dev/null
@@ -1,635 +0,0 @@
-import * as React from 'react';
-import { sprintf } from 'sprintf-js';
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import {
- compareRelayLocation,
- compareRelayLocationLoose,
- RelayLocation,
- relayLocationComponents,
-} from '../../shared/daemon-rpc-types';
-import { messages, relayLocations } from '../../shared/gettext';
-import {
- IRelayLocationCityRedux,
- IRelayLocationRedux,
- IRelayLocationRelayRedux,
-} from '../redux/settings/reducers';
-import * as Cell from './cell';
-import InfoButton from './InfoButton';
-import LocationRow, {
- StyledLocationRowButton,
- StyledLocationRowContainer,
- StyledLocationRowIcon,
- StyledLocationRowLabel,
-} from './LocationRow';
-
-export enum LocationSelectionType {
- relay = 'relay',
- special = 'special',
-}
-
-export type LocationSelection<SpecialValueType> =
- | { type: LocationSelectionType.special; value: SpecialValueType }
- | { type: LocationSelectionType.relay; value: RelayLocation };
-
-interface ILocationListState<SpecialValueType> {
- selectedValue?: LocationSelection<SpecialValueType>;
- expandedLocations: RelayLocation[];
-}
-
-interface ILocationListProps<SpecialValueType> {
- defaultExpandedLocations?: RelayLocation[];
- selectedValue?: LocationSelection<SpecialValueType>;
- selectedElementRef?: React.Ref<React.ReactInstance>;
- onSelect?: (value: LocationSelection<SpecialValueType>) => void;
- children?: React.ReactNode;
-}
-
-export default class LocationList<SpecialValueType> extends React.Component<
- ILocationListProps<SpecialValueType>,
- ILocationListState<SpecialValueType>
-> {
- public state: ILocationListState<SpecialValueType> = {
- expandedLocations: [],
- };
-
- public selectedRelayLocationRef: React.ReactInstance | null = null;
- public selectedSpecialLocationRef: React.ReactInstance | null = null;
-
- constructor(props: ILocationListProps<SpecialValueType>) {
- super(props);
-
- if (props.selectedValue) {
- const expandedLocations =
- props.defaultExpandedLocations ||
- (props.selectedValue.type === LocationSelectionType.relay
- ? expandRelayLocation(props.selectedValue.value)
- : []);
-
- this.state = {
- selectedValue: props.selectedValue,
- expandedLocations,
- };
- }
- }
-
- public getExpandedLocations(): RelayLocation[] {
- return this.state.expandedLocations;
- }
-
- public componentDidUpdate(prevProps: ILocationListProps<SpecialValueType>) {
- if (!compareLocationSelectionLoose(prevProps.selectedValue, this.props.selectedValue)) {
- this.setState({ selectedValue: this.props.selectedValue });
- }
- }
-
- public render() {
- const selection = this.state.selectedValue;
- const specialSelection =
- selection && selection.type === LocationSelectionType.special ? selection.value : undefined;
- const relaySelection =
- selection && selection.type === LocationSelectionType.relay ? selection.value : undefined;
-
- return (
- <>
- {React.Children.map(this.props.children, (child) => {
- if (React.isValidElement(child)) {
- if (child.type === SpecialLocations) {
- return React.cloneElement(child, {
- ...child.props,
- selectedElementRef: this.onSpecialLocationRef,
- selectedValue: specialSelection,
- onSelect: this.onSelectSpecialLocation,
- });
- } else if (child.type === RelayLocations) {
- return React.cloneElement(child, {
- ...child.props,
- selectedLocation: relaySelection,
- selectedElementRef: this.onRelayLocationRef,
- expandedItems: this.state.expandedLocations,
- onSelect: this.onSelectRelayLocation,
- onExpand: this.onExpandRelayLocation,
- });
- }
- }
- return child;
- })}
- </>
- );
- }
-
- private onSpecialLocationRef = (ref: React.ReactInstance | null) => {
- this.selectedSpecialLocationRef = ref;
-
- this.updateExternalRef();
- };
-
- private onRelayLocationRef = (ref: React.ReactInstance | null) => {
- this.selectedRelayLocationRef = ref;
-
- this.updateExternalRef();
- };
-
- private updateExternalRef() {
- if (this.props.selectedElementRef) {
- const value = this.selectedRelayLocationRef || this.selectedSpecialLocationRef;
-
- if (typeof this.props.selectedElementRef === 'function') {
- this.props.selectedElementRef(value);
- } else {
- const ref = this.props
- .selectedElementRef as React.MutableRefObject<React.ReactInstance | null>;
- ref.current = value;
- }
- }
- }
-
- private onSelectRelayLocation = (value: RelayLocation) => {
- const selectedValue: LocationSelection<SpecialValueType> = {
- type: LocationSelectionType.relay,
- value,
- };
-
- this.setState({ selectedValue }, () => {
- this.notifySelection(selectedValue);
- });
- };
-
- private onSelectSpecialLocation = (value: SpecialValueType) => {
- const selectedValue: LocationSelection<SpecialValueType> = {
- type: LocationSelectionType.special,
- value,
- };
-
- this.setState({ selectedValue }, () => {
- this.notifySelection(selectedValue);
- });
- };
-
- private notifySelection(value: LocationSelection<SpecialValueType>) {
- if (this.props.onSelect) {
- this.props.onSelect(value);
- }
- }
-
- private onExpandRelayLocation = (location: RelayLocation, expand: boolean) => {
- this.setState((state) => {
- const expandedLocations = state.expandedLocations.filter(
- (item) => !compareRelayLocation(item, location),
- );
-
- if (expand) {
- expandedLocations.push(location);
- }
-
- return {
- ...state,
- expandedLocations,
- };
- });
- };
-}
-
-export enum SpecialLocationIcon {
- geoLocation = 'icon-nearest',
-}
-
-interface ISpecialLocationsProps<T> {
- children: React.ReactNode;
- selectedValue?: T;
- selectedElementRef?: React.Ref<SpecialLocation<T>>;
- onSelect?: (value: T) => void;
-}
-
-export function SpecialLocations<T>(props: ISpecialLocationsProps<T>) {
- return (
- <>
- {React.Children.map(props.children, (child) => {
- if (React.isValidElement(child) && child.type === SpecialLocation) {
- const isSelected = props.selectedValue === child.props.value;
-
- return React.cloneElement(child, {
- ...child.props,
- forwardedRef: isSelected ? props.selectedElementRef : undefined,
- onSelect: props.onSelect,
- isSelected,
- });
- } else {
- return undefined;
- }
- })}
- </>
- );
-}
-
-const StyledLocationRowContainerWithMargin = styled(StyledLocationRowContainer)({
- marginBottom: 1,
-});
-
-const StyledSpecialLocationIcon = styled(Cell.Icon)({
- flex: 0,
- marginLeft: '2px',
- marginRight: '8px',
-});
-
-const StyledSpecialLocationInfoButton = styled(InfoButton)({
- margin: 0,
- padding: '0 25px',
-});
-
-interface ISpecialLocationProps<T> {
- icon: SpecialLocationIcon;
- value: T;
- isSelected?: boolean;
- onSelect?: (value: T) => void;
- info?: string;
- forwardedRef?: React.Ref<HTMLButtonElement>;
- children?: React.ReactNode;
-}
-
-export class SpecialLocation<T> extends React.Component<ISpecialLocationProps<T>> {
- public render() {
- return (
- <StyledLocationRowContainerWithMargin>
- <StyledLocationRowButton onClick={this.onSelect} selected={this.props.isSelected ?? false}>
- <StyledSpecialLocationIcon
- source={this.props.isSelected ? 'icon-tick' : this.props.icon}
- tintColor={colors.white}
- height={22}
- width={22}
- />
- <StyledLocationRowLabel>{this.props.children}</StyledLocationRowLabel>
- </StyledLocationRowButton>
- <StyledLocationRowIcon
- as={StyledSpecialLocationInfoButton}
- message={this.props.info}
- selected={this.props.isSelected ?? false}
- aria-label={messages.pgettext('accessibility', 'info')}
- />
- </StyledLocationRowContainerWithMargin>
- );
- }
-
- private onSelect = () => {
- if (!this.props.isSelected && this.props.onSelect) {
- this.props.onSelect(this.props.value);
- }
- };
-}
-
-export enum DisabledReason {
- entry,
- exit,
- inactive,
-}
-
-interface IRelayLocationsProps {
- source: IRelayLocationRedux[];
- locale: string;
- selectedLocation?: RelayLocation;
- selectedElementRef?: React.Ref<React.ReactInstance>;
- expandedItems?: RelayLocation[];
- disabledLocation?: { location: RelayLocation; reason: DisabledReason };
- onSelect?: (location: RelayLocation) => void;
- onExpand?: (location: RelayLocation, expand: boolean) => void;
- onWillExpand?: (locationRect: DOMRect, expandedContentHeight: number) => void;
- onTransitionEnd?: () => void;
-}
-
-interface Relay extends IRelayLocationRelayRedux {
- label: string;
- disabled: boolean;
-}
-
-interface City extends Omit<IRelayLocationCityRedux, 'relays'> {
- label: string;
- active: boolean;
- disabled: boolean;
- relays: Array<Relay>;
-}
-
-interface Country extends Omit<IRelayLocationRedux, 'cities'> {
- label: string;
- active: boolean;
- disabled: boolean;
- cities: Array<City>;
-}
-
-type CountryList = Array<Country>;
-
-interface IRelayLocationsState {
- countries: CountryList;
-}
-
-interface ICommonCellProps {
- location: RelayLocation;
- selected: boolean;
- ref?: React.Ref<HTMLDivElement>;
-}
-
-export class RelayLocations extends React.PureComponent<
- IRelayLocationsProps,
- IRelayLocationsState
-> {
- public state = {
- countries: this.prepareRelaysForPresentation(this.props.source),
- };
-
- public componentDidUpdate(prevProps: IRelayLocationsProps) {
- if (this.props.source !== prevProps.source) {
- this.setState({ countries: this.prepareRelaysForPresentation(this.props.source) });
- }
- }
-
- public render() {
- return (
- <Cell.Group noMarginBottom>
- {this.state.countries.map((relayCountry) => {
- const countryLocation: RelayLocation = { country: relayCountry.code };
-
- return (
- <LocationRow
- key={getLocationKey(countryLocation)}
- name={relayCountry.label}
- active={relayCountry.active}
- disabled={relayCountry.disabled}
- expanded={this.isExpanded(countryLocation)}
- onSelect={this.handleSelection}
- onExpand={this.handleExpand}
- onWillExpand={this.props.onWillExpand}
- onTransitionEnd={this.props.onTransitionEnd}
- {...this.getCommonCellProps(countryLocation)}>
- {relayCountry.cities.map((relayCity) => {
- const cityLocation: RelayLocation = {
- city: [relayCountry.code, relayCity.code],
- };
-
- return (
- <LocationRow
- key={getLocationKey(cityLocation)}
- name={relayCity.label}
- active={relayCity.active}
- disabled={relayCity.disabled}
- expanded={this.isExpanded(cityLocation)}
- onSelect={this.handleSelection}
- onExpand={this.handleExpand}
- onWillExpand={this.props.onWillExpand}
- onTransitionEnd={this.props.onTransitionEnd}
- {...this.getCommonCellProps(cityLocation)}>
- {relayCity.relays.map((relay) => {
- const relayLocation: RelayLocation = {
- hostname: [relayCountry.code, relayCity.code, relay.hostname],
- };
-
- return (
- <LocationRow
- key={getLocationKey(relayLocation)}
- name={relay.label}
- active={relay.active}
- disabled={relay.disabled}
- onSelect={this.handleSelection}
- {...this.getCommonCellProps(relayLocation)}
- />
- );
- })}
- </LocationRow>
- );
- })}
- </LocationRow>
- );
- })}
- </Cell.Group>
- );
- }
-
- private prepareRelaysForPresentation(relayList: IRelayLocationRedux[]): CountryList {
- return relayList
- .map((country) => {
- const countryDisabled = this.isCountryDisabled(country, country.code);
- const countryLocation = { country: country.code };
-
- return {
- ...country,
- label: this.formatRowName(country.name, countryLocation, countryDisabled),
- active: countryDisabled !== DisabledReason.inactive,
- disabled: countryDisabled !== undefined,
- cities: country.cities
- .map((city) => {
- const cityDisabled =
- countryDisabled ?? this.isCityDisabled(city, [country.code, city.code]);
- const cityLocation: RelayLocation = { city: [country.code, city.code] };
-
- return {
- ...city,
- label: this.formatRowName(city.name, cityLocation, cityDisabled),
- active: cityDisabled !== DisabledReason.inactive,
- disabled: cityDisabled !== undefined,
- relays: city.relays
- .map((relay) => {
- const relayDisabled =
- countryDisabled ??
- cityDisabled ??
- this.isRelayDisabled(relay, [country.code, city.code, relay.hostname]);
- const relayLocation: RelayLocation = {
- hostname: [country.code, city.code, relay.hostname],
- };
-
- return {
- ...relay,
- label: this.formatRowName(relay.hostname, relayLocation, relayDisabled),
- disabled: relayDisabled !== undefined,
- };
- })
- .sort((a, b) =>
- a.hostname.localeCompare(b.hostname, this.props.locale, { numeric: true }),
- ),
- };
- })
- .sort((a, b) => a.label.localeCompare(b.label, this.props.locale)),
- };
- })
- .sort((a, b) => a.label.localeCompare(b.label, this.props.locale));
- }
-
- private formatRowName(
- name: string,
- location: RelayLocation,
- disabledReason?: DisabledReason,
- ): string {
- const translatedName = 'hostname' in location ? name : relayLocations.gettext(name);
- const disabledLocation = this.props.disabledLocation;
- const matchDisabledLocation = compareRelayLocationLoose(location, disabledLocation?.location);
-
- let info: string | undefined;
- if (
- disabledReason === DisabledReason.entry ||
- (matchDisabledLocation && disabledLocation?.reason === DisabledReason.entry)
- ) {
- info = messages.pgettext('select-location-view', 'Entry');
- } else if (
- disabledReason === DisabledReason.exit ||
- (matchDisabledLocation && disabledLocation?.reason === DisabledReason.exit)
- ) {
- info = messages.pgettext('select-location-view', 'Exit');
- }
-
- return info !== undefined
- ? sprintf(
- // TRANSLATORS: This is used for appending information about a location.
- // TRANSLATORS: E.g. "Gothenburg (Entry)" if Gothenburg has been selected as the entrypoint.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(location)s - Translated location name
- // TRANSLATORS: %(info)s - Information about the location
- messages.pgettext('select-location-view', '%(location)s (%(info)s)'),
- {
- location: translatedName,
- info,
- },
- )
- : translatedName;
- }
-
- private isRelayDisabled(
- relay: IRelayLocationRelayRedux,
- location: [string, string, string],
- ): DisabledReason | undefined {
- if (!relay.active) {
- return DisabledReason.inactive;
- } else if (
- this.props.disabledLocation &&
- compareRelayLocation({ hostname: location }, this.props.disabledLocation.location)
- ) {
- return this.props.disabledLocation.reason;
- } else {
- return undefined;
- }
- }
-
- private isCityDisabled(
- city: IRelayLocationCityRedux,
- location: [string, string],
- ): DisabledReason | undefined {
- const relaysDisabled = city.relays.map((relay) =>
- this.isRelayDisabled(relay, [...location, relay.hostname]),
- );
- if (relaysDisabled.every((status) => status === DisabledReason.inactive)) {
- return DisabledReason.inactive;
- }
-
- const disabledDueToSelection = relaysDisabled.find(
- (status) => status === DisabledReason.entry || status === DisabledReason.exit,
- );
-
- if (
- relaysDisabled.every((status) => status !== undefined) &&
- disabledDueToSelection !== undefined
- ) {
- return disabledDueToSelection;
- }
-
- if (
- this.props.disabledLocation &&
- compareRelayLocation({ city: location }, this.props.disabledLocation.location) &&
- city.relays.filter((relay) => relay.active).length <= 1
- ) {
- return this.props.disabledLocation.reason;
- }
-
- return undefined;
- }
-
- private isCountryDisabled(
- country: IRelayLocationRedux,
- location: string,
- ): DisabledReason | undefined {
- const citiesDisabled = country.cities.map((city) =>
- this.isCityDisabled(city, [location, city.code]),
- );
- if (citiesDisabled.every((status) => status === DisabledReason.inactive)) {
- return DisabledReason.inactive;
- }
-
- const disabledDueToSelection = citiesDisabled.find(
- (status) => status === DisabledReason.entry || status === DisabledReason.exit,
- );
- if (
- citiesDisabled.every((status) => status !== undefined) &&
- disabledDueToSelection !== undefined
- ) {
- return disabledDueToSelection;
- }
-
- if (
- this.props.disabledLocation &&
- compareRelayLocation({ country: location }, this.props.disabledLocation.location) &&
- country.cities.flatMap((city) => city.relays).filter((relay) => relay.active).length <= 1
- ) {
- return this.props.disabledLocation.reason;
- }
-
- return undefined;
- }
-
- private isExpanded(relayLocation: RelayLocation) {
- return (this.props.expandedItems || []).some((location) =>
- compareRelayLocation(location, relayLocation),
- );
- }
-
- private isSelected(relayLocation: RelayLocation) {
- return compareRelayLocationLoose(this.props.selectedLocation, relayLocation);
- }
-
- private handleSelection = (location: RelayLocation) => {
- if (!compareRelayLocationLoose(this.props.selectedLocation, location)) {
- if (this.props.onSelect) {
- this.props.onSelect(location);
- }
- }
- };
-
- private handleExpand = (location: RelayLocation, expand: boolean) => {
- if (this.props.onExpand) {
- this.props.onExpand(location, expand);
- }
- };
-
- private getCommonCellProps(location: RelayLocation): ICommonCellProps {
- const selected = this.isSelected(location);
- const ref =
- selected && this.props.selectedElementRef ? this.props.selectedElementRef : undefined;
-
- return { ref: ref as React.Ref<HTMLDivElement>, selected, location };
- }
-}
-
-function expandRelayLocation(location: RelayLocation): RelayLocation[] {
- const expandedItems: RelayLocation[] = [];
-
- if ('city' in location) {
- expandedItems.push({ country: location.city[0] });
- } else if ('hostname' in location) {
- expandedItems.push({ country: location.hostname[0] });
- expandedItems.push({ city: [location.hostname[0], location.hostname[1]] });
- }
-
- return expandedItems;
-}
-
-function getLocationKey(location: RelayLocation): string {
- return relayLocationComponents(location).join('-');
-}
-
-function compareLocationSelectionLoose<SpecialValueType>(
- lhs?: LocationSelection<SpecialValueType>,
- rhs?: LocationSelection<SpecialValueType>,
-) {
- if (!lhs || !rhs) {
- return lhs === rhs;
- } else if (lhs.type === LocationSelectionType.relay && rhs.type === LocationSelectionType.relay) {
- return compareRelayLocation(lhs.value, rhs.value);
- } else {
- return lhs.value === rhs.value;
- }
-}
diff --git a/gui/src/renderer/components/LocationRow.tsx b/gui/src/renderer/components/LocationRow.tsx
deleted file mode 100644
index 9172f8bcd3..0000000000
--- a/gui/src/renderer/components/LocationRow.tsx
+++ /dev/null
@@ -1,207 +0,0 @@
-import React, { useCallback, useRef } from 'react';
-import { sprintf } from 'sprintf-js';
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import { compareRelayLocation, RelayLocation } from '../../shared/daemon-rpc-types';
-import { messages } from '../../shared/gettext';
-import Accordion from './Accordion';
-import * as Cell from './cell';
-import ChevronButton from './ChevronButton';
-import { measurements, normalText } from './common-styles';
-import RelayStatusIndicator from './RelayStatusIndicator';
-
-interface IButtonColorProps {
- selected: boolean;
- disabled?: boolean;
- location?: RelayLocation;
-}
-
-const buttonColor = (props: IButtonColorProps) => {
- let background = colors.blue;
- if (props.selected) {
- background = colors.green;
- } else if (props.location) {
- if ('hostname' in props.location) {
- background = colors.blue20;
- } else if ('city' in props.location) {
- background = colors.blue40;
- }
- }
-
- let backgroundHover = colors.blue80;
- if (props.selected || props.disabled) {
- backgroundHover = background;
- } else if (props.location) {
- backgroundHover = colors.blue80;
- }
-
- return {
- backgroundColor: background,
- ':not(:disabled):hover': {
- backgroundColor: backgroundHover,
- },
- };
-};
-
-export const StyledLocationRowContainer = styled(Cell.Container)({
- display: 'flex',
- padding: 0,
- background: 'none',
-});
-
-export const StyledLocationRowButton = styled(Cell.Row)(
- buttonColor,
- (props: { location?: RelayLocation }) => {
- const paddingLeft =
- props.location && 'hostname' in props.location
- ? 50
- : props.location && 'city' in props.location
- ? 34
- : 18;
-
- return {
- flex: 1,
- border: 'none',
- padding: `0 10px 0 ${paddingLeft}px`,
- margin: 0,
- };
- },
-);
-
-export const StyledLocationRowIcon = styled.button(buttonColor, {
- position: 'relative',
- alignSelf: 'stretch',
- paddingLeft: '22px',
- paddingRight: measurements.viewMargin,
-
- '&::before': {
- content: '""',
- position: 'absolute',
- margin: 'auto',
- top: 0,
- left: 0,
- bottom: 0,
- height: '50%',
- width: '1px',
- backgroundColor: colors.darkBlue,
- },
-});
-
-export const StyledLocationRowLabel = styled(Cell.Label)(normalText, {
- fontWeight: 400,
-});
-
-interface IProps {
- name: string;
- active: boolean;
- disabled: boolean;
- location: RelayLocation;
- selected: boolean;
- expanded?: boolean;
- onSelect?: (location: RelayLocation) => void;
- onExpand?: (location: RelayLocation, value: boolean) => void;
- onWillExpand?: (locationRect: DOMRect, expandedContentHeight: number) => void;
- onTransitionEnd?: () => void;
- children?: React.ReactElement<IProps>[];
-}
-
-function LocationRow(props: IProps, ref: React.Ref<HTMLDivElement>) {
- const hasChildren = props.children !== undefined;
- const buttonRef = useRef<HTMLButtonElement>() as React.RefObject<HTMLButtonElement>;
-
- const toggleCollapse = useCallback(() => {
- props.onExpand?.(props.location, !props.expanded);
- }, [props.onExpand, props.expanded, props.location]);
-
- const handleClick = useCallback(() => props.onSelect?.(props.location), [
- props.onSelect,
- props.location,
- ]);
-
- const onWillExpand = useCallback(
- (nextHeight: number) => {
- const buttonRect = buttonRef.current?.getBoundingClientRect();
- if (buttonRect) {
- props.onWillExpand?.(buttonRect, nextHeight);
- }
- },
- [props.onWillExpand],
- );
-
- return (
- <>
- <StyledLocationRowContainer ref={ref} disabled={props.disabled}>
- <StyledLocationRowButton
- as="button"
- ref={buttonRef}
- onClick={handleClick}
- selected={props.selected}
- location={props.location}
- disabled={props.disabled}>
- <RelayStatusIndicator active={props.active} selected={props.selected} />
- <StyledLocationRowLabel>{props.name}</StyledLocationRowLabel>
- </StyledLocationRowButton>
- {hasChildren ? (
- <StyledLocationRowIcon
- as={ChevronButton}
- onClick={toggleCollapse}
- up={props.expanded ?? false}
- selected={props.selected}
- disabled={props.disabled}
- location={props.location}
- aria-label={sprintf(
- props.expanded
- ? messages.pgettext('accessibility', 'Collapse %(location)s')
- : messages.pgettext('accessibility', 'Expand %(location)s'),
- { location: props.name },
- )}
- />
- ) : null}
- </StyledLocationRowContainer>
-
- {hasChildren && (
- <Accordion
- expanded={props.expanded}
- onWillExpand={onWillExpand}
- onTransitionEnd={props.onTransitionEnd}
- animationDuration={150}>
- <Cell.Group noMarginBottom>{props.children}</Cell.Group>
- </Accordion>
- )}
- </>
- );
-}
-
-export default React.memo(React.forwardRef(LocationRow), compareProps);
-
-function compareProps(oldProps: IProps, nextProps: IProps): boolean {
- return (
- React.Children.count(oldProps.children) === React.Children.count(nextProps.children) &&
- oldProps.name === nextProps.name &&
- oldProps.active === nextProps.active &&
- oldProps.disabled === nextProps.disabled &&
- oldProps.selected === nextProps.selected &&
- oldProps.expanded === nextProps.expanded &&
- oldProps.onSelect === nextProps.onSelect &&
- oldProps.onExpand === nextProps.onExpand &&
- oldProps.onWillExpand === nextProps.onWillExpand &&
- oldProps.onTransitionEnd === nextProps.onTransitionEnd &&
- compareRelayLocation(oldProps.location, nextProps.location) &&
- compareChildren(oldProps.children, nextProps.children)
- );
-}
-
-function compareChildren(
- oldChildren?: React.ReactElement<IProps>[],
- nextChildren?: React.ReactElement<IProps>[],
-) {
- if (oldChildren === undefined || nextChildren === undefined) {
- return oldChildren === nextChildren;
- }
-
- return (
- oldChildren.length === nextChildren.length &&
- oldChildren.every((oldChild, i) => compareProps(oldChild.props, nextChildren[i].props))
- );
-}
diff --git a/gui/src/renderer/components/Locations.tsx b/gui/src/renderer/components/Locations.tsx
deleted file mode 100644
index 4f7fadfb5d..0000000000
--- a/gui/src/renderer/components/Locations.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import * as React from 'react';
-
-import { RelayLocation } from '../../shared/daemon-rpc-types';
-import { IRelayLocationRedux } from '../redux/settings/reducers';
-import LocationList, {
- DisabledReason,
- LocationSelection,
- LocationSelectionType,
- RelayLocations,
-} from './LocationList';
-
-interface ILocationsProps {
- source: IRelayLocationRedux[];
- locale: string;
- defaultExpandedLocations?: RelayLocation[];
- selectedValue?: RelayLocation;
- disabledLocation?: { location: RelayLocation; reason: DisabledReason };
- selectedElementRef?: React.Ref<React.ReactInstance>;
- onSelect?: (value: LocationSelection<never>) => void;
- onWillExpand?: (locationRect: DOMRect, expandedContentHeight: number) => void;
- onTransitionEnd?: () => void;
-}
-
-function Locations(props: ILocationsProps, ref: React.Ref<LocationList<never>>) {
- const selectedValue: LocationSelection<never> | undefined = props.selectedValue
- ? { type: LocationSelectionType.relay, value: props.selectedValue }
- : undefined;
-
- return (
- <LocationList
- ref={ref}
- defaultExpandedLocations={props.defaultExpandedLocations}
- selectedValue={selectedValue}
- selectedElementRef={props.selectedElementRef}
- onSelect={props.onSelect}>
- <RelayLocations
- source={props.source}
- locale={props.locale}
- disabledLocation={props.disabledLocation}
- onWillExpand={props.onWillExpand}
- onTransitionEnd={props.onTransitionEnd}
- />
- </LocationList>
- );
-}
-
-export const ExitLocations = React.forwardRef(Locations);
-export const EntryLocations = React.forwardRef(Locations);
diff --git a/gui/src/renderer/components/SearchBar.tsx b/gui/src/renderer/components/SearchBar.tsx
new file mode 100644
index 0000000000..c70e5c3de1
--- /dev/null
+++ b/gui/src/renderer/components/SearchBar.tsx
@@ -0,0 +1,112 @@
+import { useCallback, useEffect, useRef } from 'react';
+import styled from 'styled-components';
+
+import { colors } from '../../config.json';
+import { messages } from '../../shared/gettext';
+import { normalText } from './common-styles';
+import ImageView from './ImageView';
+
+export const StyledSearchContainer = styled.div({
+ position: 'relative',
+ display: 'flex',
+});
+
+export const StyledSearchInput = styled.input.attrs({ type: 'text' })({
+ ...normalText,
+ flex: 1,
+ border: 'none',
+ borderRadius: '4px',
+ padding: '9px 38px',
+ margin: 0,
+ color: colors.white60,
+ backgroundColor: colors.white10,
+ '::placeholder': {
+ color: colors.white60,
+ },
+ ':focus': {
+ color: colors.blue,
+ backgroundColor: colors.white,
+ '::placeholder': {
+ color: colors.blue40,
+ },
+ },
+});
+
+export const StyledClearButton = styled.button({
+ position: 'absolute',
+ top: '50%',
+ transform: 'translateY(-50%)',
+ right: '9px',
+ border: 'none',
+ background: 'none',
+ padding: 0,
+});
+
+export const StyledSearchIcon = styled(ImageView)({
+ position: 'absolute',
+ top: '50%',
+ transform: 'translateY(-50%)',
+ left: '9px',
+ [`${StyledSearchInput}:focus ~ &`]: {
+ backgroundColor: colors.blue,
+ },
+});
+
+export const StyledClearIcon = styled(ImageView)({
+ ':hover': {
+ backgroundColor: colors.white60,
+ },
+ [`${StyledSearchInput}:focus ~ ${StyledClearButton} &`]: {
+ backgroundColor: colors.blue40,
+ ':hover': {
+ backgroundColor: colors.blue,
+ },
+ },
+});
+
+interface ISearchBarProps {
+ searchTerm: string;
+ onSearch: (searchTerm: string) => void;
+ className?: string;
+ disableAutoFocus?: boolean;
+}
+
+export default function SearchBar(props: ISearchBarProps) {
+ const inputRef = useRef() as React.RefObject<HTMLInputElement>;
+
+ const onInput = useCallback(
+ (event: React.FormEvent) => {
+ const element = event.target as HTMLInputElement;
+ props.onSearch(element.value);
+ },
+ [props.onSearch],
+ );
+
+ const onClear = useCallback(() => {
+ props.onSearch('');
+ inputRef.current?.blur();
+ }, [props.onSearch]);
+
+ useEffect(() => {
+ if (!props.disableAutoFocus) {
+ inputRef.current?.focus();
+ }
+ }, []);
+
+ return (
+ <StyledSearchContainer className={props.className}>
+ <StyledSearchInput
+ ref={inputRef}
+ value={props.searchTerm}
+ onInput={onInput}
+ placeholder={messages.gettext('Search for...')}
+ />
+ <StyledSearchIcon source="icon-search" width={24} tintColor={colors.white60} />
+ {props.searchTerm.length > 0 && (
+ <StyledClearButton onClick={onClear}>
+ <StyledClearIcon source="icon-close" width={18} tintColor={colors.white40} />
+ </StyledClearButton>
+ )}
+ </StyledSearchContainer>
+ );
+}
diff --git a/gui/src/renderer/components/SelectLocation.tsx b/gui/src/renderer/components/SelectLocation.tsx
deleted file mode 100644
index 4c228c9a53..0000000000
--- a/gui/src/renderer/components/SelectLocation.tsx
+++ /dev/null
@@ -1,471 +0,0 @@
-import React from 'react';
-import { sprintf } from 'sprintf-js';
-
-import { colors } from '../../config.json';
-import {
- LiftedConstraint,
- Ownership,
- RelayLocation,
- TunnelProtocol,
-} from '../../shared/daemon-rpc-types';
-import { messages } from '../../shared/gettext';
-import { IRelayLocationRedux } from '../redux/settings/reducers';
-import BridgeLocations, { SpecialBridgeLocationType } from './BridgeLocations';
-import { CustomScrollbarsRef } from './CustomScrollbars';
-import ImageView from './ImageView';
-import { BackAction } from './KeyboardNavigation';
-import { Layout, SettingsContainer } from './Layout';
-import LocationList, {
- DisabledReason,
- LocationSelection,
- LocationSelectionType,
-} from './LocationList';
-import { EntryLocations, ExitLocations } from './Locations';
-import {
- NavigationBar,
- NavigationContainer,
- NavigationItems,
- NavigationScrollbars,
- TitleBarItem,
-} from './NavigationBar';
-import { ScopeBarItem } from './ScopeBar';
-import {
- StyledClearFilterButton,
- StyledContent,
- StyledFilter,
- StyledFilterIconButton,
- StyledFilterRow,
- StyledNavigationBarAttachment,
- StyledScopeBar,
- StyledSettingsHeader,
-} from './SelectLocationStyles';
-import { HeaderSubTitle, HeaderTitle } from './SettingsHeader';
-
-interface IProps {
- locale: string;
- selectedExitLocation?: RelayLocation;
- selectedEntryLocation?: RelayLocation;
- selectedBridgeLocation?: LiftedConstraint<RelayLocation>;
- relayLocations: IRelayLocationRedux[];
- bridgeLocations: IRelayLocationRedux[];
- allowEntrySelection: boolean;
- tunnelProtocol: LiftedConstraint<TunnelProtocol>;
- providers: string[];
- ownership: Ownership;
- onClose: () => void;
- onViewFilter: () => void;
- onSelectExitLocation: (location: RelayLocation) => void;
- onSelectEntryLocation: (location: RelayLocation) => void;
- onSelectBridgeLocation: (location: RelayLocation) => void;
- onSelectClosestToExit: () => void;
- onClearProviders: () => void;
- onClearOwnership: () => void;
-}
-
-enum LocationScope {
- entry = 0,
- exit,
-}
-
-interface IState {
- headingHeight: number;
- locationScope: LocationScope;
-}
-
-interface ISelectLocationSnapshot {
- scrollPosition: [number, number];
- expandedLocations: RelayLocation[];
-}
-
-export default class SelectLocation extends React.Component<IProps, IState> {
- public state = { headingHeight: 0, locationScope: LocationScope.exit };
-
- private scrollView = React.createRef<CustomScrollbarsRef>();
- private spacePreAllocationViewRef = React.createRef<SpacePreAllocationView>();
- private selectedExitLocationRef = React.createRef<React.ReactInstance>();
- private selectedEntryLocationRef = React.createRef<React.ReactInstance>();
- private selectedBridgeLocationRef = React.createRef<React.ReactInstance>();
-
- private exitLocationList = React.createRef<LocationList<never>>();
- private entryLocationList = React.createRef<LocationList<never>>();
- private bridgeLocationList = React.createRef<LocationList<SpecialBridgeLocationType>>();
-
- private snapshotByScope: Partial<Record<LocationScope, ISelectLocationSnapshot>> = {};
-
- private headerRef = React.createRef<HTMLHeadingElement>();
-
- public componentDidMount() {
- this.scrollToSelectedCell();
- this.setState((state) => ({
- headingHeight: this.headerRef.current?.offsetHeight ?? state.headingHeight,
- }));
- }
-
- public componentDidUpdate(
- _prevProps: IProps,
- prevState: IState,
- snapshot?: ISelectLocationSnapshot,
- ) {
- if (this.state.locationScope !== prevState.locationScope) {
- this.restoreScrollPosition(this.state.locationScope);
-
- if (snapshot) {
- this.snapshotByScope[prevState.locationScope] = snapshot;
- }
- }
- }
-
- public getSnapshotBeforeUpdate(
- prevProps: IProps,
- prevState: IState,
- ): ISelectLocationSnapshot | undefined {
- const scrollView = this.scrollView.current;
- const locationList = this.getLocationListRef(prevProps, prevState);
-
- if (scrollView && locationList) {
- return {
- scrollPosition: scrollView.getScrollPosition(),
- expandedLocations: locationList.getExpandedLocations(),
- };
- } else {
- return undefined;
- }
- }
-
- public render() {
- const showOwnershipFilter = this.props.ownership !== Ownership.any;
- const showProvidersFilter = this.props.providers.length > 0;
- const showFilters = showOwnershipFilter || showProvidersFilter;
- return (
- <BackAction icon="close" action={this.props.onClose}>
- <Layout>
- <SettingsContainer>
- <NavigationContainer>
- <NavigationBar>
- <NavigationItems>
- <TitleBarItem>
- {
- // TRANSLATORS: Title label in navigation bar
- messages.pgettext('select-location-nav', 'Select location')
- }
- </TitleBarItem>
-
- <StyledFilterIconButton
- onClick={this.props.onViewFilter}
- aria-label={messages.gettext('Filter')}>
- <ImageView
- source="icon-filter-round"
- tintColor={colors.white40}
- tintHoverColor={colors.white60}
- height={24}
- width={24}
- />
- </StyledFilterIconButton>
- </NavigationItems>
- </NavigationBar>
- <NavigationScrollbars ref={this.scrollView}>
- <SpacePreAllocationView ref={this.spacePreAllocationViewRef}>
- <StyledNavigationBarAttachment top={-this.state.headingHeight}>
- <StyledSettingsHeader ref={this.headerRef}>
- <HeaderTitle>
- {
- // TRANSLATORS: Heading in select location view
- messages.pgettext('select-location-view', 'Select location')
- }
- </HeaderTitle>
- {this.renderHeaderSubtitle()}
- </StyledSettingsHeader>
-
- {showFilters && (
- <StyledFilterRow>
- {messages.pgettext('select-location-view', 'Filtered:')}
-
- {showOwnershipFilter && (
- <StyledFilter>
- {this.ownershipFilterLabel()}
- <StyledClearFilterButton
- aria-label={messages.gettext('Clear')}
- onClick={this.props.onClearOwnership}>
- <ImageView
- height={16}
- width={16}
- source="icon-close"
- tintColor={colors.white60}
- tintHoverColor={colors.white80}
- />
- </StyledClearFilterButton>
- </StyledFilter>
- )}
-
- {showProvidersFilter && (
- <StyledFilter>
- {sprintf(
- messages.pgettext(
- 'select-location-view',
- 'Providers: %(numberOfProviders)d',
- ),
- {
- numberOfProviders: this.props.providers.length,
- },
- )}
- <StyledClearFilterButton
- aria-label={messages.gettext('Clear')}
- onClick={this.props.onClearProviders}>
- <ImageView
- height={16}
- width={16}
- source="icon-close"
- tintColor={colors.white60}
- tintHoverColor={colors.white80}
- />
- </StyledClearFilterButton>
- </StyledFilter>
- )}
- </StyledFilterRow>
- )}
- {this.props.allowEntrySelection && (
- <StyledScopeBar
- defaultSelectedIndex={this.state.locationScope}
- onChange={this.onChangeLocationScope}>
- <ScopeBarItem>
- {messages.pgettext('select-location-view', 'Entry')}
- </ScopeBarItem>
- <ScopeBarItem>
- {messages.pgettext('select-location-view', 'Exit')}
- </ScopeBarItem>
- </StyledScopeBar>
- )}
- </StyledNavigationBarAttachment>
-
- <StyledContent>{this.renderLocationList()}</StyledContent>
- </SpacePreAllocationView>
- </NavigationScrollbars>
- </NavigationContainer>
- </SettingsContainer>
- </Layout>
- </BackAction>
- );
- }
-
- public restoreScrollPosition(scope: LocationScope) {
- const snapshot = this.snapshotByScope[scope];
-
- if (snapshot) {
- this.scrollToPosition(...snapshot.scrollPosition);
- } else {
- this.scrollToSelectedCell();
- }
- }
-
- private ownershipFilterLabel(): string {
- switch (this.props.ownership) {
- case Ownership.mullvadOwned:
- return messages.pgettext('filter-view', 'Owned');
- case Ownership.rented:
- return messages.pgettext('filter-view', 'Rented');
- default:
- throw new Error('Only owned and rented should make label visible');
- }
- }
-
- private getLocationListRef(prevProps: IProps, prevState: IState) {
- if (prevState.locationScope === LocationScope.exit) {
- return this.exitLocationList.current;
- } else if (prevProps.tunnelProtocol === 'wireguard') {
- return this.entryLocationList.current;
- } else {
- return this.bridgeLocationList.current;
- }
- }
-
- private getSelectedLocationRef() {
- if (this.state.locationScope === LocationScope.exit) {
- return this.selectedExitLocationRef.current;
- } else if (this.props.tunnelProtocol === 'wireguard') {
- return this.selectedEntryLocationRef.current;
- } else {
- return this.selectedBridgeLocationRef.current;
- }
- }
-
- private renderHeaderSubtitle() {
- if (this.props.allowEntrySelection) {
- if (this.props.tunnelProtocol === 'openvpn') {
- return (
- <HeaderSubTitle>
- {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).',
- )}
- </HeaderSubTitle>
- );
- } else {
- return (
- <HeaderSubTitle>
- {messages.pgettext(
- 'select-location-view',
- 'While connected, your traffic will be routed through two secure locations, the entry point and the exit point (needs to be two different VPN servers).',
- )}
- </HeaderSubTitle>
- );
- }
- } else {
- return null;
- }
- }
-
- private renderLocationList() {
- if (this.state.locationScope === LocationScope.exit) {
- const disabledLocation = this.props.selectedEntryLocation
- ? {
- location: this.props.selectedEntryLocation,
- reason: DisabledReason.entry,
- }
- : undefined;
- return (
- <ExitLocations
- ref={this.exitLocationList}
- source={this.props.relayLocations}
- locale={this.props.locale}
- defaultExpandedLocations={this.getExpandedLocationsFromSnapshot()}
- selectedValue={this.props.selectedExitLocation}
- selectedElementRef={this.selectedExitLocationRef}
- disabledLocation={disabledLocation}
- onSelect={this.onSelectExitLocation}
- onWillExpand={this.onWillExpand}
- onTransitionEnd={this.resetHeight}
- />
- );
- } else if (this.props.tunnelProtocol === 'any' || this.props.tunnelProtocol === 'wireguard') {
- const disabledLocation = this.props.selectedExitLocation
- ? {
- location: this.props.selectedExitLocation,
- reason: DisabledReason.exit,
- }
- : undefined;
- return (
- <EntryLocations
- ref={this.entryLocationList}
- source={this.props.relayLocations}
- locale={this.props.locale}
- defaultExpandedLocations={this.getExpandedLocationsFromSnapshot()}
- selectedValue={this.props.selectedEntryLocation}
- selectedElementRef={this.selectedEntryLocationRef}
- disabledLocation={disabledLocation}
- onSelect={this.onSelectEntryLocation}
- onWillExpand={this.onWillExpand}
- onTransitionEnd={this.resetHeight}
- />
- );
- } else {
- return (
- <BridgeLocations
- ref={this.bridgeLocationList}
- source={this.props.bridgeLocations}
- locale={this.props.locale}
- defaultExpandedLocations={this.getExpandedLocationsFromSnapshot()}
- selectedValue={this.props.selectedBridgeLocation}
- selectedElementRef={this.selectedBridgeLocationRef}
- onSelect={this.onSelectBridgeLocation}
- onWillExpand={this.onWillExpand}
- onTransitionEnd={this.resetHeight}
- />
- );
- }
- }
-
- private resetHeight = () => {
- this.spacePreAllocationViewRef.current?.reset();
- };
-
- private getExpandedLocationsFromSnapshot(): RelayLocation[] | undefined {
- const snapshot = this.snapshotByScope[this.state.locationScope];
- if (snapshot) {
- return snapshot.expandedLocations;
- } else {
- return undefined;
- }
- }
-
- private scrollToPosition(x: number, y: number) {
- const scrollView = this.scrollView.current;
- if (scrollView) {
- scrollView.scrollTo(x, y);
- }
- }
-
- private scrollToSelectedCell() {
- const ref = this.getSelectedLocationRef();
- const scrollView = this.scrollView.current;
-
- if (scrollView) {
- if (ref) {
- if (ref instanceof HTMLElement) {
- scrollView.scrollToElement(ref, 'middle');
- }
- } else {
- scrollView.scrollToTop();
- }
- }
- }
-
- private onChangeLocationScope = (locationScope: LocationScope) => {
- this.setState({ locationScope });
- };
-
- private onSelectExitLocation = (location: LocationSelection<never>) => {
- if (location.type === LocationSelectionType.relay) {
- this.props.onSelectExitLocation(location.value);
- }
- };
-
- private onSelectEntryLocation = (location: LocationSelection<never>) => {
- this.props.onSelectEntryLocation(location.value);
- };
-
- private onSelectBridgeLocation = (location: LocationSelection<SpecialBridgeLocationType>) => {
- if (location.type === LocationSelectionType.relay) {
- this.props.onSelectBridgeLocation(location.value);
- } else if (
- location.type === LocationSelectionType.special &&
- location.value === SpecialBridgeLocationType.closestToExit
- ) {
- this.props.onSelectClosestToExit();
- }
- };
-
- private onWillExpand = (locationRect: DOMRect, expandedContentHeight: number) => {
- locationRect.height += expandedContentHeight;
- this.spacePreAllocationViewRef.current?.allocate(expandedContentHeight);
- this.scrollView.current?.scrollIntoView(locationRect);
- };
-}
-
-interface ISpacePreAllocationView {
- children?: React.ReactNode;
-}
-
-class SpacePreAllocationView extends React.Component<ISpacePreAllocationView> {
- private ref = React.createRef<HTMLDivElement>();
-
- public allocate(height: number) {
- if (this.ref.current) {
- this.minHeight = this.ref.current.offsetHeight + height + 'px';
- }
- }
-
- public reset = () => {
- this.minHeight = 'auto';
- };
-
- public render() {
- return <div ref={this.ref}>{this.props.children}</div>;
- }
-
- private set minHeight(value: string) {
- const element = this.ref.current;
- if (element) {
- element.style.minHeight = value;
- }
- }
-}
diff --git a/gui/src/renderer/components/SplitTunnelingSettings.tsx b/gui/src/renderer/components/SplitTunnelingSettings.tsx
index f68be19589..bf3b7f58b8 100644
--- a/gui/src/renderer/components/SplitTunnelingSettings.tsx
+++ b/gui/src/renderer/components/SplitTunnelingSettings.tsx
@@ -31,8 +31,6 @@ import {
StyledCellButton,
StyledCellLabel,
StyledCellWarningIcon,
- StyledClearButton,
- StyledClearIcon,
StyledContent,
StyledHeaderTitle,
StyledHeaderTitleContainer,
@@ -43,9 +41,7 @@ import {
StyledNoResult,
StyledNoResultText,
StyledPageCover,
- StyledSearchContainer,
- StyledSearchIcon,
- StyledSearchInput,
+ StyledSearchBar,
StyledSpinnerRow,
} from './SplitTunnelingSettingsStyles';
import Switch from './Switch';
@@ -176,7 +172,7 @@ function LinuxSplitTunnelingSettings(props: IPlatformSplitTunnelingSettingsProps
</HeaderSubTitle>
</SettingsHeader>
- <SearchBar searchTerm={searchTerm} onSearch={setSearchTerm} />
+ <StyledSearchBar searchTerm={searchTerm} onSearch={setSearchTerm} />
<ApplicationList applications={filteredApplications} rowRenderer={rowRenderer} />
<StyledBrowseButton onClick={launchWithFilePicker}>
@@ -435,7 +431,9 @@ export function WindowsSplitTunnelingSettings(props: IPlatformSplitTunnelingSett
</HeaderSubTitle>
</SettingsHeader>
- {splitTunnelingEnabled && <SearchBar searchTerm={searchTerm} onSearch={setSearchTerm} />}
+ {splitTunnelingEnabled && (
+ <StyledSearchBar searchTerm={searchTerm} onSearch={setSearchTerm} />
+ )}
<Accordion expanded={showSplitSection}>
<Cell.Section sectionTitle={excludedTitle}>
@@ -459,15 +457,10 @@ export function WindowsSplitTunnelingSettings(props: IPlatformSplitTunnelingSett
<StyledNoResult>
<StyledNoResultText>
{formatHtml(
- sprintf(
- messages.pgettext('split-tunneling-view', 'No result for <b>%(searchTerm)s</b>.'),
- { searchTerm },
- ),
+ sprintf(messages.gettext('No result for <b>%(searchTerm)s</b>.'), { searchTerm }),
)}
</StyledNoResultText>
- <StyledNoResultText>
- {messages.pgettext('split-tunneling-view', 'Try a different search.')}
- </StyledNoResultText>
+ <StyledNoResultText>{messages.gettext('Try a different search.')}</StyledNoResultText>
</StyledNoResult>
)}
@@ -566,45 +559,6 @@ function WindowsApplicationRow(props: IWindowsApplicationRowProps) {
);
}
-interface ISearchBarProps {
- searchTerm: string;
- onSearch: (searchTerm: string) => void;
-}
-
-function SearchBar(props: ISearchBarProps) {
- const inputRef = useRef() as React.RefObject<HTMLInputElement>;
-
- const onInput = useCallback(
- (event: React.FormEvent) => {
- const element = event.target as HTMLInputElement;
- props.onSearch(element.value);
- },
- [props.onSearch],
- );
-
- const onClear = useCallback(() => {
- props.onSearch('');
- inputRef.current?.blur();
- }, [props.onSearch]);
-
- return (
- <StyledSearchContainer>
- <StyledSearchInput
- ref={inputRef}
- value={props.searchTerm}
- onInput={onInput}
- placeholder={messages.pgettext('split-tunneling-view', 'Filter...')}
- />
- <StyledSearchIcon source="icon-filter" width={24} tintColor={colors.white60} />
- {props.searchTerm.length > 0 && (
- <StyledClearButton onClick={onClear}>
- <StyledClearIcon source="icon-close" width={18} tintColor={colors.white40} />
- </StyledClearButton>
- )}
- </StyledSearchContainer>
- );
-}
-
function includesSearchTerm(application: IApplication, searchTerm: string) {
return application.name.toLowerCase().includes(searchTerm.toLowerCase());
}
diff --git a/gui/src/renderer/components/SplitTunnelingSettingsStyles.tsx b/gui/src/renderer/components/SplitTunnelingSettingsStyles.tsx
index 6b135486db..028fac30a3 100644
--- a/gui/src/renderer/components/SplitTunnelingSettingsStyles.tsx
+++ b/gui/src/renderer/components/SplitTunnelingSettingsStyles.tsx
@@ -6,6 +6,7 @@ import * as Cell from './cell';
import { measurements, normalText } from './common-styles';
import ImageView from './ImageView';
import { NavigationScrollbars } from './NavigationBar';
+import SearchBar from './SearchBar';
import { HeaderTitle } from './SettingsHeader';
export const StyledPageCover = styled.div({}, (props: { show: boolean }) => ({
@@ -87,64 +88,6 @@ export const StyledCellContainer = styled(Cell.Container)({
marginBottom: measurements.rowVerticalMargin,
});
-export const StyledSearchContainer = styled.div({
- position: 'relative',
- marginBottom: measurements.buttonVerticalMargin,
-});
-
-export const StyledSearchInput = styled.input.attrs({ type: 'text' })({
- ...normalText,
- width: `calc(100% - ${measurements.viewMargin} * 2)`,
- border: 'none',
- borderRadius: '4px',
- padding: '9px 38px',
- margin: `0 ${measurements.viewMargin}`,
- color: colors.white60,
- backgroundColor: colors.white10,
- '::placeholder': {
- color: colors.white60,
- },
- ':focus': {
- color: colors.blue,
- backgroundColor: colors.white,
- '::placeholder': {
- color: colors.blue40,
- },
- },
-});
-
-export const StyledClearButton = styled.button({
- position: 'absolute',
- top: '50%',
- transform: 'translateY(-50%)',
- right: '28px',
- border: 'none',
- background: 'none',
- padding: 0,
-});
-
-export const StyledSearchIcon = styled(ImageView)({
- position: 'absolute',
- top: '50%',
- transform: 'translateY(-50%)',
- left: '28px',
- [`${StyledSearchInput}:focus ~ &`]: {
- backgroundColor: colors.blue,
- },
-});
-
-export const StyledClearIcon = styled(ImageView)({
- ':hover': {
- backgroundColor: colors.white60,
- },
- [`${StyledSearchInput}:focus ~ ${StyledClearButton} &`]: {
- backgroundColor: colors.blue40,
- ':hover': {
- backgroundColor: colors.blue,
- },
- },
-});
-
export const StyledNoResult = styled(Cell.CellFooter)({
display: 'flex',
flexDirection: 'column',
@@ -164,3 +107,9 @@ export const StyledHeaderTitleContainer = styled.div({
export const StyledHeaderTitle = styled(HeaderTitle)({
flex: 1,
});
+
+export const StyledSearchBar = styled(SearchBar)({
+ marginLeft: measurements.viewMargin,
+ marginRight: measurements.viewMargin,
+ marginBottom: measurements.buttonVerticalMargin,
+});
diff --git a/gui/src/renderer/components/select-location/CombinedLocationList.tsx b/gui/src/renderer/components/select-location/CombinedLocationList.tsx
new file mode 100644
index 0000000000..d2a13af845
--- /dev/null
+++ b/gui/src/renderer/components/select-location/CombinedLocationList.tsx
@@ -0,0 +1,51 @@
+import React from 'react';
+
+import { RelayLocation } from '../../../shared/daemon-rpc-types';
+import RelayLocationList from './RelayLocationList';
+import {
+ CountrySpecification,
+ LocationList,
+ LocationSelection,
+ LocationSelectionType,
+ SpecialLocation,
+} from './select-location-types';
+import SpecialLocationList from './SpecialLocationList';
+
+export interface CombinedLocationListProps<T> {
+ source: LocationList<T>;
+ selectedElementRef: React.Ref<HTMLDivElement>;
+ onSelect: (value: LocationSelection<T>) => void;
+ onExpand: (location: RelayLocation) => void;
+ onCollapse: (location: RelayLocation) => void;
+ onWillExpand: (
+ locationRect: DOMRect,
+ expandedContentHeight: number,
+ invokedByUser: boolean,
+ ) => void;
+ onTransitionEnd: () => void;
+}
+
+// Renders the special locations and the regular locations as separate lists
+export default function CombinedLocationList<T>(props: CombinedLocationListProps<T>) {
+ const specialLocations = props.source.filter(isSpecialLocation);
+ const relayLocations = props.source.filter(isRelayLocation);
+
+ return (
+ <>
+ <SpecialLocationList {...props} source={specialLocations} />
+ <RelayLocationList {...props} source={relayLocations} />
+ </>
+ );
+}
+
+function isSpecialLocation<T>(
+ location: CountrySpecification | SpecialLocation<T>,
+): location is SpecialLocation<T> {
+ return location.type === LocationSelectionType.special;
+}
+
+function isRelayLocation<T>(
+ location: CountrySpecification | SpecialLocation<T>,
+): location is CountrySpecification {
+ return location.type === LocationSelectionType.relay;
+}
diff --git a/gui/src/renderer/components/select-location/LocationRow.tsx b/gui/src/renderer/components/select-location/LocationRow.tsx
new file mode 100644
index 0000000000..eafae9c509
--- /dev/null
+++ b/gui/src/renderer/components/select-location/LocationRow.tsx
@@ -0,0 +1,258 @@
+import React, { useCallback, useRef } from 'react';
+import { sprintf } from 'sprintf-js';
+import styled from 'styled-components';
+
+import { colors } from '../../../config.json';
+import { compareRelayLocation, RelayLocation } from '../../../shared/daemon-rpc-types';
+import { messages } from '../../../shared/gettext';
+import Accordion from '../Accordion';
+import * as Cell from '../cell';
+import ChevronButton from '../ChevronButton';
+import { measurements, normalText } from '../common-styles';
+import RelayStatusIndicator from '../RelayStatusIndicator';
+import {
+ CitySpecification,
+ CountrySpecification,
+ getLocationChildren,
+ LocationSelection,
+ LocationSelectionType,
+ LocationSpecification,
+ RelaySpecification,
+} from './select-location-types';
+
+interface IButtonColorProps {
+ selected: boolean;
+ disabled?: boolean;
+ location?: RelayLocation;
+}
+
+const buttonColor = (props: IButtonColorProps) => {
+ let background = colors.blue;
+ if (props.selected) {
+ background = colors.green;
+ } else if (props.location) {
+ if ('hostname' in props.location) {
+ background = colors.blue20;
+ } else if ('city' in props.location) {
+ background = colors.blue40;
+ }
+ }
+
+ let backgroundHover = colors.blue80;
+ if (props.selected || props.disabled) {
+ backgroundHover = background;
+ } else if (props.location) {
+ backgroundHover = colors.blue80;
+ }
+
+ return {
+ backgroundColor: background,
+ ':not(:disabled):hover': {
+ backgroundColor: backgroundHover,
+ },
+ };
+};
+
+export const StyledLocationRowContainer = styled(Cell.Container)({
+ display: 'flex',
+ padding: 0,
+ background: 'none',
+});
+
+export const StyledLocationRowButton = styled(Cell.Row)(
+ buttonColor,
+ (props: { location?: RelayLocation }) => {
+ const paddingLeft =
+ props.location && 'hostname' in props.location
+ ? 50
+ : props.location && 'city' in props.location
+ ? 34
+ : 18;
+
+ return {
+ flex: 1,
+ border: 'none',
+ padding: `0 10px 0 ${paddingLeft}px`,
+ margin: 0,
+ };
+ },
+);
+
+export const StyledLocationRowIcon = styled.button(buttonColor, {
+ position: 'relative',
+ alignSelf: 'stretch',
+ paddingLeft: '22px',
+ paddingRight: measurements.viewMargin,
+
+ '&::before': {
+ content: '""',
+ position: 'absolute',
+ margin: 'auto',
+ top: 0,
+ left: 0,
+ bottom: 0,
+ height: '50%',
+ width: '1px',
+ backgroundColor: colors.darkBlue,
+ },
+});
+
+export const StyledLocationRowLabel = styled(Cell.Label)(normalText, {
+ fontWeight: 400,
+});
+
+interface IProps<C extends LocationSpecification> {
+ source: C;
+ selectedElementRef: React.Ref<HTMLDivElement>;
+ onSelect: (value: LocationSelection<never>) => void;
+ onExpand: (location: RelayLocation) => void;
+ onCollapse: (location: RelayLocation) => void;
+ onWillExpand: (
+ locationRect: DOMRect,
+ expandedContentHeight: number,
+ invokedByUser: boolean,
+ ) => void;
+ onTransitionEnd: () => void;
+ children?: C extends RelaySpecification
+ ? never
+ : React.ReactElement<
+ IProps<C extends CountrySpecification ? CitySpecification : RelaySpecification>
+ >[];
+}
+
+// Renders the rows and its children for countries, cities and relays
+function LocationRow<C extends LocationSpecification>(props: IProps<C>) {
+ const hasChildren = React.Children.count(props.children) > 0;
+ const buttonRef = useRef<HTMLButtonElement>() as React.RefObject<HTMLButtonElement>;
+ const userInvokedExpand = useRef(false);
+
+ // Expand/collapse should only be available if the expanded property is provided in the source
+ const expanded = 'expanded' in props.source ? props.source.expanded : undefined;
+ const toggleCollapse = useCallback(() => {
+ if (expanded !== undefined) {
+ userInvokedExpand.current = true;
+ const callback = expanded ? props.onCollapse : props.onExpand;
+ callback(props.source.location);
+ }
+ }, [props.onExpand, props.onCollapse, props.source.location, expanded]);
+
+ const handleClick = useCallback(() => {
+ if (!props.source.selected) {
+ props.onSelect({ type: LocationSelectionType.relay, value: props.source.location });
+ }
+ }, [props.onSelect, props.source.location, props.source.selected]);
+
+ const onWillExpand = useCallback(
+ (nextHeight: number) => {
+ const buttonRect = buttonRef.current?.getBoundingClientRect();
+ if (expanded !== undefined && buttonRect) {
+ props.onWillExpand(buttonRect, nextHeight, userInvokedExpand.current);
+ userInvokedExpand.current = false;
+ }
+ },
+ [props.onWillExpand, expanded],
+ );
+
+ // The selectedRef should only be used if the element is selected
+ const selectedRef = props.source.selected ? props.selectedElementRef : undefined;
+ return (
+ <>
+ <StyledLocationRowContainer ref={selectedRef} disabled={props.source.disabled}>
+ <StyledLocationRowButton
+ as="button"
+ ref={buttonRef}
+ onClick={handleClick}
+ selected={props.source.selected}
+ location={props.source.location}
+ disabled={props.source.disabled}>
+ <RelayStatusIndicator active={props.source.active} selected={props.source.selected} />
+ <StyledLocationRowLabel>{props.source.label}</StyledLocationRowLabel>
+ </StyledLocationRowButton>
+ {hasChildren ? (
+ <StyledLocationRowIcon
+ as={ChevronButton}
+ onClick={toggleCollapse}
+ up={expanded ?? false}
+ selected={props.source.selected}
+ disabled={props.source.disabled}
+ location={props.source.location}
+ aria-label={sprintf(
+ expanded === true
+ ? messages.pgettext('accessibility', 'Collapse %(location)s')
+ : messages.pgettext('accessibility', 'Expand %(location)s'),
+ { location: props.source.label },
+ )}
+ />
+ ) : null}
+ </StyledLocationRowContainer>
+
+ {hasChildren && (
+ <Accordion
+ expanded={expanded}
+ onWillExpand={onWillExpand}
+ onTransitionEnd={props.onTransitionEnd}
+ animationDuration={150}>
+ <Cell.Group noMarginBottom>{props.children}</Cell.Group>
+ </Accordion>
+ )}
+ </>
+ );
+}
+
+// This is to avoid unnecessary rerenders since most of the subtree is hidden and would result in
+// a lot more work than necessary
+export default React.memo(LocationRow, compareProps);
+
+function compareProps<C extends LocationSpecification>(
+ oldProps: IProps<C>,
+ nextProps: IProps<C>,
+): boolean {
+ return (
+ oldProps.onSelect === nextProps.onSelect &&
+ oldProps.onExpand === nextProps.onExpand &&
+ oldProps.onWillExpand === nextProps.onWillExpand &&
+ oldProps.onTransitionEnd === nextProps.onTransitionEnd &&
+ compareLocation(oldProps.source, nextProps.source)
+ );
+}
+
+function compareLocation(
+ oldLocation: LocationSpecification,
+ nextLocation: LocationSpecification,
+): boolean {
+ return (
+ oldLocation.label === nextLocation.label &&
+ oldLocation.active === nextLocation.active &&
+ oldLocation.disabled === nextLocation.disabled &&
+ oldLocation.selected === nextLocation.selected &&
+ compareRelayLocation(oldLocation.location, nextLocation.location) &&
+ compareExpanded(oldLocation, nextLocation) &&
+ compareChildren(oldLocation, nextLocation)
+ );
+}
+
+function compareChildren(
+ oldLocation: LocationSpecification,
+ nextLocation: LocationSpecification,
+): boolean {
+ const oldChildren = getLocationChildren(oldLocation);
+ const nextChildren = getLocationChildren(nextLocation);
+
+ // Children shouldn't be checked if the row is collapsed
+ const nextExpanded = 'expanded' in nextLocation && nextLocation.expanded;
+
+ return (
+ !nextExpanded ||
+ (oldChildren.length === nextChildren.length &&
+ oldChildren.every((oldChild, i) => compareLocation(oldChild, nextChildren[i])))
+ );
+}
+
+function compareExpanded(
+ oldLocation: LocationSpecification,
+ nextLocation: LocationSpecification,
+): boolean {
+ const oldExpanded = 'expanded' in oldLocation && oldLocation.expanded;
+ const nextExpanded = 'expanded' in nextLocation && nextLocation.expanded;
+ return oldExpanded === nextExpanded;
+}
diff --git a/gui/src/renderer/components/select-location/RelayListContext.tsx b/gui/src/renderer/components/select-location/RelayListContext.tsx
new file mode 100644
index 0000000000..b37cced8b5
--- /dev/null
+++ b/gui/src/renderer/components/select-location/RelayListContext.tsx
@@ -0,0 +1,311 @@
+import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
+
+import { compareRelayLocation, RelayLocation } from '../../../shared/daemon-rpc-types';
+import {
+ EndpointType,
+ filterLocations,
+ filterLocationsByEndPointType,
+ getLocationsExpandedBySearch,
+ searchForLocations,
+} from '../../lib/filter-locations';
+import { useNormalBridgeSettings, useNormalRelaySettings } from '../../lib/utilityHooks';
+import { IRelayLocationRedux } from '../../redux/settings/reducers';
+import { useSelector } from '../../redux/store';
+import { useScrollPositionContext } from './ScrollPositionContext';
+import {
+ defaultExpandedLocations,
+ formatRowName,
+ isCityDisabled,
+ isCountryDisabled,
+ isExpanded,
+ isRelayDisabled,
+ isSelected,
+} from './select-location-helpers';
+import {
+ DisabledReason,
+ LocationList,
+ LocationSelectionType,
+ LocationType,
+} from './select-location-types';
+import { useSelectLocationContext } from './SelectLocationContainer';
+
+// Context containing the relay list and related data and callbacks
+interface RelayListContext {
+ relayList: LocationList<never>;
+ expandLocation: (location: RelayLocation) => void;
+ collapseLocation: (location: RelayLocation) => void;
+ onBeforeExpand: (
+ locationRect: DOMRect,
+ expandedContentHeight: number,
+ invokedByUser: boolean,
+ ) => void;
+ expandSearchResults: (searchTerm: string) => void;
+}
+
+type ExpandedLocations = Partial<Record<LocationType, Array<RelayLocation>>>;
+
+const relayListContext = React.createContext<RelayListContext | undefined>(undefined);
+
+export function useRelayListContext() {
+ return useContext(relayListContext)!;
+}
+
+interface RelayListContextProviderProps {
+ children: React.ReactNode;
+}
+
+export function RelayListContextProvider(props: RelayListContextProviderProps) {
+ const { locationType, searchTerm } = useSelectLocationContext();
+ const fullRelayList = useSelector((state) => state.settings.relayLocations);
+ const relaySettings = useNormalRelaySettings();
+
+ // Filters the relays to only keep the ones of the desired endpoint type, e.g. "wireguard",
+ // "openvpn" or "bridge"
+ const relayListForEndpointType = useMemo(() => {
+ const endpointType =
+ locationType === LocationType.entry ? EndpointType.entry : EndpointType.exit;
+ return filterLocationsByEndPointType(fullRelayList, endpointType, relaySettings);
+ }, [fullRelayList, locationType, relaySettings?.tunnelProtocol]);
+
+ // Filters the relays to only keep the relays matching the currently selected filters, e.g.
+ // ownership and providers
+ const relayListForFilters = useMemo(() => {
+ return filterLocations(
+ relayListForEndpointType,
+ relaySettings?.ownership,
+ relaySettings?.providers,
+ );
+ }, [relaySettings?.ownership, relaySettings?.providers, relayListForEndpointType]);
+
+ // Filters the relays based on the provided search term
+ const relayListForSearch = useMemo(() => {
+ return searchForLocations(relayListForFilters, searchTerm);
+ }, [relayListForFilters, searchTerm]);
+
+ const {
+ expandedLocations,
+ expandLocation,
+ collapseLocation,
+ onBeforeExpand,
+ expandSearchResults,
+ } = useExpandedLocations(relayListForFilters);
+
+ // Prepares all relays and combines the data needed for rendering them
+ const relayList = useRelayList(relayListForSearch, expandedLocations);
+
+ const contextValue = useMemo(
+ () => ({
+ relayList,
+ expandLocation,
+ collapseLocation,
+ onBeforeExpand,
+ expandSearchResults,
+ }),
+ [relayList, expandLocation, collapseLocation, onBeforeExpand, expandSearchResults],
+ );
+
+ return (
+ <relayListContext.Provider value={contextValue}>{props.children}</relayListContext.Provider>
+ );
+}
+
+// Return the final filtered and formatted relay list. This should be the only place in the app
+// where processing of the relay list is performed.
+function useRelayList(
+ relayList: Array<IRelayLocationRedux>,
+ expandedLocations?: Array<RelayLocation>,
+): LocationList<never> {
+ const locale = useSelector((state) => state.userInterface.locale);
+ const selectedLocation = useSelectedLocation();
+ const disabledLocation = useDisabledLocation();
+
+ return useMemo(() => {
+ return relayList
+ .map((country) => {
+ const countryLocation = { country: country.code };
+ const countryDisabled = isCountryDisabled(country, country.code, disabledLocation);
+
+ return {
+ ...country,
+ type: LocationSelectionType.relay as const,
+ label: formatRowName(country.name, countryLocation, countryDisabled),
+ location: countryLocation,
+ active: countryDisabled !== DisabledReason.inactive,
+ disabled: countryDisabled !== undefined,
+ expanded: isExpanded(countryLocation, expandedLocations),
+ selected: isSelected(countryLocation, selectedLocation),
+ cities: country.cities
+ .map((city) => {
+ const cityLocation: RelayLocation = { city: [country.code, city.code] };
+ const cityDisabled =
+ countryDisabled ?? isCityDisabled(city, cityLocation.city, disabledLocation);
+
+ return {
+ ...city,
+ label: formatRowName(city.name, cityLocation, cityDisabled),
+ location: cityLocation,
+ active: cityDisabled !== DisabledReason.inactive,
+ disabled: cityDisabled !== undefined,
+ expanded: isExpanded(cityLocation, expandedLocations),
+ selected: isSelected(cityLocation, selectedLocation),
+ relays: city.relays
+ .map((relay) => {
+ const relayLocation: RelayLocation = {
+ hostname: [country.code, city.code, relay.hostname],
+ };
+ const relayDisabled =
+ countryDisabled ??
+ cityDisabled ??
+ isRelayDisabled(relay, relayLocation.hostname, disabledLocation);
+
+ return {
+ ...relay,
+ label: formatRowName(relay.hostname, relayLocation, relayDisabled),
+ location: relayLocation,
+ disabled: relayDisabled !== undefined,
+ selected: isSelected(relayLocation, selectedLocation),
+ };
+ })
+ .sort((a, b) => a.hostname.localeCompare(b.hostname, locale, { numeric: true })),
+ };
+ })
+ .sort((a, b) => a.label.localeCompare(b.label, locale)),
+ };
+ })
+ .sort((a, b) => a.label.localeCompare(b.label, locale));
+ }, [locale, expandedLocations, relayList, selectedLocation, disabledLocation]);
+}
+
+// Return all RelayLocations that should be expanded
+function useExpandedLocations(filteredLocations: Array<IRelayLocationRedux>) {
+ const { locationType, searchTerm } = useSelectLocationContext();
+ const { spacePreAllocationViewRef, scrollViewRef } = useScrollPositionContext();
+ const relaySettings = useNormalRelaySettings();
+ const bridgeSettings = useNormalBridgeSettings();
+
+ // Keeps the state of which locations are expanded for which locationType. This is used to restore
+ // the state when switching back and forth between entry and exit.
+ const [expandedLocationsMap, setExpandedLocations] = useState<ExpandedLocations>(() =>
+ defaultExpandedLocations(relaySettings, bridgeSettings),
+ );
+
+ const expandLocation = useCallback(
+ (location: RelayLocation) => {
+ setExpandedLocations((expandedLocations) => ({
+ ...expandedLocations,
+ [locationType]: [...(expandedLocations[locationType] ?? []), location],
+ }));
+ },
+ [locationType],
+ );
+
+ const collapseLocation = useCallback(
+ (location: RelayLocation) => {
+ setExpandedLocations((expandedLocations) => ({
+ ...expandedLocations,
+ [locationType]: expandedLocations[locationType]!.filter(
+ (item) => !compareRelayLocation(location, item),
+ ),
+ }));
+ },
+ [locationType],
+ );
+
+ // Called before expansion to make room for expansion and to scroll to fit the element
+ const onBeforeExpand = useCallback(
+ (locationRect: DOMRect, expandedContentHeight: number, invokedByUser: boolean) => {
+ if (invokedByUser) {
+ locationRect.height += expandedContentHeight;
+ spacePreAllocationViewRef.current?.allocate(expandedContentHeight);
+ scrollViewRef.current?.scrollIntoView(locationRect);
+ }
+ },
+ [],
+ );
+
+ // Expand search results when searching
+ const expandSearchResults = useCallback(
+ (searchTerm: string) => {
+ if (searchTerm === '') {
+ setExpandedLocations(defaultExpandedLocations(relaySettings, bridgeSettings));
+ } else {
+ setExpandedLocations((expandedLocations) => ({
+ ...expandedLocations,
+ [locationType]: getLocationsExpandedBySearch(filteredLocations, searchTerm),
+ }));
+ }
+ },
+ [relaySettings, bridgeSettings, locationType, filteredLocations],
+ );
+
+ // Expand locations when filters are changed
+ useEffect(() => {
+ if (searchTerm !== '') {
+ setExpandedLocations((expandedLocations) => ({
+ ...expandedLocations,
+ [locationType]: getLocationsExpandedBySearch(filteredLocations, searchTerm),
+ }));
+ }
+ }, [filteredLocations]);
+
+ return {
+ expandedLocations: expandedLocationsMap[locationType],
+ expandLocation,
+ collapseLocation,
+ onBeforeExpand,
+ expandSearchResults,
+ };
+}
+
+// Returns the location (if any) that should be disabled. This is currently used for disabling the
+// entry location when selecting exit location etc.
+function useDisabledLocation() {
+ const { locationType } = useSelectLocationContext();
+ const relaySettings = useNormalRelaySettings();
+
+ return useMemo(() => {
+ if (relaySettings?.tunnelProtocol !== 'openvpn' && relaySettings?.wireguard.useMultihop) {
+ if (locationType === LocationType.exit && relaySettings?.wireguard.entryLocation !== 'any') {
+ return {
+ location: relaySettings?.wireguard.entryLocation,
+ reason: DisabledReason.entry,
+ };
+ } else if (locationType === LocationType.entry && relaySettings?.location !== 'any') {
+ return { location: relaySettings?.location, reason: DisabledReason.exit };
+ }
+ }
+
+ return undefined;
+ }, [
+ locationType,
+ relaySettings?.tunnelProtocol,
+ relaySettings?.wireguard.useMultihop,
+ relaySettings?.wireguard.entryLocation,
+ relaySettings?.location,
+ ]);
+}
+
+// Returns the selected location for the current tunnel protocol and location type
+function useSelectedLocation() {
+ const { locationType } = useSelectLocationContext();
+ const relaySettings = useNormalRelaySettings();
+ const bridgeSettings = useNormalBridgeSettings();
+
+ return useMemo(() => {
+ if (locationType === LocationType.exit) {
+ return relaySettings?.location === 'any' ? undefined : relaySettings?.location;
+ } else if (relaySettings?.tunnelProtocol !== 'openvpn') {
+ return relaySettings?.wireguard.entryLocation === 'any'
+ ? undefined
+ : relaySettings?.wireguard.entryLocation;
+ } else {
+ return bridgeSettings?.location;
+ }
+ }, [
+ locationType,
+ relaySettings?.location,
+ relaySettings?.tunnelProtocol,
+ relaySettings?.wireguard.entryLocation,
+ bridgeSettings?.location,
+ ]);
+}
diff --git a/gui/src/renderer/components/select-location/RelayLocationList.tsx b/gui/src/renderer/components/select-location/RelayLocationList.tsx
new file mode 100644
index 0000000000..f22695d775
--- /dev/null
+++ b/gui/src/renderer/components/select-location/RelayLocationList.tsx
@@ -0,0 +1,58 @@
+import React from 'react';
+
+import { RelayLocation, relayLocationComponents } from '../../../shared/daemon-rpc-types';
+import * as Cell from '../cell';
+import LocationRow from './LocationRow';
+import {
+ getLocationChildren,
+ LocationSelection,
+ LocationSpecification,
+ RelayList,
+} from './select-location-types';
+
+interface CommonProps {
+ selectedElementRef: React.Ref<HTMLDivElement>;
+ onSelect: (value: LocationSelection<never>) => void;
+ onExpand: (location: RelayLocation) => void;
+ onCollapse: (location: RelayLocation) => void;
+ onWillExpand: (
+ locationRect: DOMRect,
+ expandedContentHeight: number,
+ invokedByUser: boolean,
+ ) => void;
+ onTransitionEnd: () => void;
+}
+
+interface RelayLocationsProps extends CommonProps {
+ source: RelayList;
+}
+
+export default function RelayLocationList({ source, ...props }: RelayLocationsProps) {
+ return (
+ <Cell.Group noMarginBottom>
+ {source.map((country) => (
+ <RelayLocation key={getLocationKey(country.location)} source={country} {...props} />
+ ))}
+ </Cell.Group>
+ );
+}
+
+interface RelayLocationProps extends CommonProps {
+ source: LocationSpecification;
+}
+
+function RelayLocation(props: RelayLocationProps) {
+ const children = getLocationChildren(props.source);
+
+ return (
+ <LocationRow {...props}>
+ {children.map((child) => (
+ <RelayLocation key={getLocationKey(child.location)} {...props} source={child} />
+ ))}
+ </LocationRow>
+ );
+}
+
+function getLocationKey(location: RelayLocation): string {
+ return relayLocationComponents(location).join('-');
+}
diff --git a/gui/src/renderer/components/ScopeBar.tsx b/gui/src/renderer/components/select-location/ScopeBar.tsx
index 10b177c2c3..94c80dea7c 100644
--- a/gui/src/renderer/components/ScopeBar.tsx
+++ b/gui/src/renderer/components/select-location/ScopeBar.tsx
@@ -1,8 +1,8 @@
-import React, { useCallback, useEffect, useState } from 'react';
+import React, { useCallback } from 'react';
import styled from 'styled-components';
-import { colors } from '../../config.json';
-import { smallText } from './common-styles';
+import { colors } from '../../../config.json';
+import { smallText } from '../common-styles';
const StyledScopeBar = styled.div({
display: 'flex',
@@ -13,25 +13,18 @@ const StyledScopeBar = styled.div({
});
interface IScopeBarProps {
- defaultSelectedIndex?: number;
+ selectedIndex: number;
onChange?: (selectedIndex: number) => void;
className?: string;
children: React.ReactElement<IScopeBarItemProps>[];
}
export function ScopeBar(props: IScopeBarProps) {
- const [selectedIndex, setSelectedIndex] = useState(props.defaultSelectedIndex ?? 0);
-
- const onClick = useCallback((index: number) => setSelectedIndex(index), []);
- useEffect(() => {
- props.onChange?.(selectedIndex);
- }, [selectedIndex]);
-
const children = React.Children.map(props.children, (child, index) => {
if (React.isValidElement(child)) {
return React.cloneElement(child, {
- selected: index === selectedIndex,
- onClick,
+ selected: index === props.selectedIndex,
+ onClick: props.onChange,
index,
});
} else {
diff --git a/gui/src/renderer/components/select-location/ScrollPositionContext.tsx b/gui/src/renderer/components/select-location/ScrollPositionContext.tsx
new file mode 100644
index 0000000000..cdb7aba044
--- /dev/null
+++ b/gui/src/renderer/components/select-location/ScrollPositionContext.tsx
@@ -0,0 +1,88 @@
+import React, { useCallback, useContext, useEffect, useMemo, useRef } from 'react';
+
+import { useNormalRelaySettings } from '../../lib/utilityHooks';
+import { CustomScrollbarsRef } from '../CustomScrollbars';
+import { LocationType } from './select-location-types';
+import { useSelectLocationContext } from './SelectLocationContainer';
+import { SpacePreAllocationView } from './SpacePreAllocationView';
+
+// Context containing the scroll position for each location type and methods to interact with it.
+interface ScrollPositionContext {
+ scrollPositions: React.RefObject<Partial<Record<LocationType, ScrollPosition>>>;
+ // The selected location element is used to scroll to it when opening the view
+ selectedLocationRef: React.RefObject<HTMLDivElement>;
+ // The scroll view container is used to get the current scroll position and to restore an old one
+ scrollViewRef: React.RefObject<CustomScrollbarsRef>;
+ // The space pre allocation view is used to enable smooth scrolling when opening locations
+ spacePreAllocationViewRef: React.RefObject<SpacePreAllocationView>;
+ saveScrollPosition: () => void;
+ resetScrollPositions: () => void;
+}
+
+type ScrollPosition = [number, number];
+
+const scrollPositionContext = React.createContext<ScrollPositionContext | undefined>(undefined);
+
+export function useScrollPositionContext() {
+ return useContext(scrollPositionContext)!;
+}
+
+interface ScrollPositionContextProps {
+ children: React.ReactNode;
+}
+
+export function ScrollPositionContextProvider(props: ScrollPositionContextProps) {
+ const { locationType, searchTerm } = useSelectLocationContext();
+ const relaySettings = useNormalRelaySettings();
+
+ const scrollPositions = useRef<Partial<Record<LocationType, ScrollPosition>>>({});
+ const scrollViewRef = useRef<CustomScrollbarsRef>(null);
+ const spacePreAllocationViewRef = useRef() as React.RefObject<SpacePreAllocationView>;
+ const selectedLocationRef = useRef<HTMLDivElement>(null);
+
+ const saveScrollPosition = useCallback(() => {
+ const scrollPosition = scrollViewRef.current?.getScrollPosition();
+ if (scrollPositions.current && scrollPosition) {
+ scrollPositions.current[locationType] = scrollPosition;
+ }
+ }, [locationType]);
+
+ const resetScrollPositions = useCallback(() => {
+ for (const locationTypeVariant of [LocationType.entry, LocationType.exit]) {
+ if (
+ scrollPositions.current &&
+ (scrollPositions.current[locationTypeVariant] || locationTypeVariant === locationType)
+ ) {
+ scrollPositions.current[locationTypeVariant] = [0, 0];
+ }
+ }
+ }, [locationType]);
+
+ const value = useMemo(
+ () => ({
+ scrollPositions,
+ selectedLocationRef,
+ scrollViewRef,
+ spacePreAllocationViewRef,
+ saveScrollPosition,
+ resetScrollPositions,
+ }),
+ [saveScrollPosition, resetScrollPositions],
+ );
+
+ // Restore the scroll position when parameters change
+ useEffect(() => {
+ const scrollPosition = scrollPositions.current?.[locationType];
+ if (scrollPosition) {
+ scrollViewRef.current?.scrollTo(...scrollPosition);
+ } else if (selectedLocationRef.current) {
+ scrollViewRef.current?.scrollToElement(selectedLocationRef.current, 'middle');
+ } else {
+ scrollViewRef.current?.scrollToTop();
+ }
+ }, [locationType, searchTerm, relaySettings?.ownership, relaySettings?.providers]);
+
+ return (
+ <scrollPositionContext.Provider value={value}>{props.children}</scrollPositionContext.Provider>
+ );
+}
diff --git a/gui/src/renderer/components/select-location/SelectLocation.tsx b/gui/src/renderer/components/select-location/SelectLocation.tsx
new file mode 100644
index 0000000000..c26dacdd33
--- /dev/null
+++ b/gui/src/renderer/components/select-location/SelectLocation.tsx
@@ -0,0 +1,351 @@
+import { useCallback, useState } from 'react';
+import { sprintf } from 'sprintf-js';
+
+import { colors } from '../../../config.json';
+import { Ownership } from '../../../shared/daemon-rpc-types';
+import { messages } from '../../../shared/gettext';
+import { useAppContext } from '../../context';
+import { filterSpecialLocations } from '../../lib/filter-locations';
+import { useHistory } from '../../lib/history';
+import { formatHtml } from '../../lib/html-formatter';
+import { RoutePath } from '../../lib/routes';
+import { useNormalBridgeSettings, useNormalRelaySettings } from '../../lib/utilityHooks';
+import { useSelector } from '../../redux/store';
+import ImageView from '../ImageView';
+import { BackAction } from '../KeyboardNavigation';
+import { Layout, SettingsContainer } from '../Layout';
+import {
+ NavigationBar,
+ NavigationContainer,
+ NavigationItems,
+ NavigationScrollbars,
+ TitleBarItem,
+} from '../NavigationBar';
+import CombinedLocationList, { CombinedLocationListProps } from './CombinedLocationList';
+import { useRelayListContext } from './RelayListContext';
+import { ScopeBarItem } from './ScopeBar';
+import { useScrollPositionContext } from './ScrollPositionContext';
+import {
+ useOnSelectBridgeLocation,
+ useOnSelectEntryLocation,
+ useOnSelectExitLocation,
+} from './select-location-hooks';
+import {
+ LocationSelectionType,
+ LocationType,
+ SpecialBridgeLocationType,
+ SpecialLocation,
+ SpecialLocationIcon,
+} from './select-location-types';
+import { useSelectLocationContext } from './SelectLocationContainer';
+import {
+ StyledClearFilterButton,
+ StyledContent,
+ StyledFilter,
+ StyledFilterIconButton,
+ StyledFilterRow,
+ StyledHeaderSubTitle,
+ StyledNavigationBarAttachment,
+ StyledNoResult,
+ StyledNoResultText,
+ StyledScopeBar,
+ StyledSearchBar,
+} from './SelectLocationStyles';
+import { SpacePreAllocationView } from './SpacePreAllocationView';
+
+export default function SelectLocation() {
+ const history = useHistory();
+ const { updateRelaySettings } = useAppContext();
+ const {
+ saveScrollPosition,
+ resetScrollPositions,
+ scrollViewRef,
+ spacePreAllocationViewRef,
+ } = useScrollPositionContext();
+ const { locationType, setLocationType, setSearchTerm } = useSelectLocationContext();
+ const { expandSearchResults } = useRelayListContext();
+
+ const relaySettings = useNormalRelaySettings();
+ const ownership = relaySettings?.ownership ?? Ownership.any;
+ const providers = relaySettings?.providers ?? [];
+
+ const [searchValue, setSearchValue] = useState('');
+
+ const onClose = useCallback(() => history.dismiss(), [history]);
+ const onViewFilter = useCallback(() => history.push(RoutePath.filter), [history]);
+
+ const tunnelProtocol = relaySettings?.tunnelProtocol ?? 'any';
+ const bridgeState = useSelector((state) => state.settings.bridgeState);
+ const allowEntrySelection =
+ (tunnelProtocol === 'openvpn' && bridgeState === 'on') ||
+ (tunnelProtocol !== 'openvpn' && relaySettings?.wireguard.useMultihop);
+
+ const onClearProviders = useCallback(async () => {
+ resetScrollPositions();
+ await updateRelaySettings({ normal: { providers: [] } });
+ }, [resetScrollPositions]);
+
+ const onClearOwnership = useCallback(async () => {
+ resetScrollPositions();
+ await updateRelaySettings({ normal: { ownership: Ownership.any } });
+ }, [resetScrollPositions]);
+
+ const changeLocationType = useCallback(
+ (locationType: LocationType) => {
+ saveScrollPosition();
+ setLocationType(locationType);
+ },
+ [saveScrollPosition],
+ );
+
+ const updateSearchTerm = useCallback(
+ (value: string) => {
+ setSearchValue(value);
+ if (value.length === 1) {
+ expandSearchResults('');
+ setSearchTerm('');
+ } else {
+ resetScrollPositions();
+ expandSearchResults(value);
+ setSearchTerm(value);
+ }
+ },
+ [resetScrollPositions, expandSearchResults],
+ );
+
+ const showOwnershipFilter = ownership !== Ownership.any;
+ const showProvidersFilter = providers.length > 0;
+ const showFilters = showOwnershipFilter || showProvidersFilter;
+ return (
+ <BackAction icon="close" action={onClose}>
+ <Layout>
+ <SettingsContainer>
+ <NavigationContainer>
+ <NavigationBar alwaysDisplayBarTitle>
+ <NavigationItems>
+ <TitleBarItem>
+ {
+ // TRANSLATORS: Title label in navigation bar
+ messages.pgettext('select-location-nav', 'Select location')
+ }
+ </TitleBarItem>
+
+ <StyledFilterIconButton
+ onClick={onViewFilter}
+ aria-label={messages.gettext('Filter')}>
+ <ImageView
+ source="icon-filter-round"
+ tintColor={colors.white40}
+ tintHoverColor={colors.white60}
+ height={24}
+ width={24}
+ />
+ </StyledFilterIconButton>
+ </NavigationItems>
+ </NavigationBar>
+
+ <StyledNavigationBarAttachment>
+ {allowEntrySelection && (
+ <>
+ <StyledScopeBar selectedIndex={locationType} onChange={changeLocationType}>
+ <ScopeBarItem>
+ {messages.pgettext('select-location-view', 'Entry')}
+ </ScopeBarItem>
+ <ScopeBarItem>{messages.pgettext('select-location-view', 'Exit')}</ScopeBarItem>
+ </StyledScopeBar>
+
+ {tunnelProtocol === 'openvpn' ? (
+ <StyledHeaderSubTitle>
+ {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).',
+ )}
+ </StyledHeaderSubTitle>
+ ) : (
+ <StyledHeaderSubTitle>
+ {messages.pgettext(
+ 'select-location-view',
+ 'While connected, your traffic will be routed through two secure locations, the entry point and the exit point (needs to be two different VPN servers).',
+ )}
+ </StyledHeaderSubTitle>
+ )}
+ </>
+ )}
+
+ {showFilters && (
+ <StyledFilterRow>
+ {messages.pgettext('select-location-view', 'Filtered:')}
+
+ {showOwnershipFilter && (
+ <StyledFilter>
+ {ownershipFilterLabel(ownership)}
+ <StyledClearFilterButton
+ aria-label={messages.gettext('Clear')}
+ onClick={onClearOwnership}>
+ <ImageView
+ height={16}
+ width={16}
+ source="icon-close"
+ tintColor={colors.white60}
+ tintHoverColor={colors.white80}
+ />
+ </StyledClearFilterButton>
+ </StyledFilter>
+ )}
+
+ {showProvidersFilter && (
+ <StyledFilter>
+ {sprintf(
+ messages.pgettext(
+ 'select-location-view',
+ 'Providers: %(numberOfProviders)d',
+ ),
+ { numberOfProviders: providers.length },
+ )}
+ <StyledClearFilterButton
+ aria-label={messages.gettext('Clear')}
+ onClick={onClearProviders}>
+ <ImageView
+ height={16}
+ width={16}
+ source="icon-close"
+ tintColor={colors.white60}
+ tintHoverColor={colors.white80}
+ />
+ </StyledClearFilterButton>
+ </StyledFilter>
+ )}
+ </StyledFilterRow>
+ )}
+
+ <StyledSearchBar searchTerm={searchValue} onSearch={updateSearchTerm} />
+ </StyledNavigationBarAttachment>
+
+ <NavigationScrollbars ref={scrollViewRef}>
+ <SpacePreAllocationView ref={spacePreAllocationViewRef}>
+ <StyledContent>
+ <SelectLocationContent />
+ </StyledContent>
+ </SpacePreAllocationView>
+ </NavigationScrollbars>
+ </NavigationContainer>
+ </SettingsContainer>
+ </Layout>
+ </BackAction>
+ );
+}
+
+function ownershipFilterLabel(ownership: Ownership): string {
+ switch (ownership) {
+ case Ownership.mullvadOwned:
+ return messages.pgettext('filter-view', 'Owned');
+ case Ownership.rented:
+ return messages.pgettext('filter-view', 'Rented');
+ default:
+ throw new Error('Only owned and rented should make label visible');
+ }
+}
+
+function SelectLocationContent() {
+ const { locationType, searchTerm } = useSelectLocationContext();
+ const { selectedLocationRef, spacePreAllocationViewRef } = useScrollPositionContext();
+ const { relayList, expandLocation, collapseLocation, onBeforeExpand } = useRelayListContext();
+ const onSelectExitLocation = useOnSelectExitLocation();
+ const onSelectEntryLocation = useOnSelectEntryLocation();
+ const onSelectBridgeLocation = useOnSelectBridgeLocation();
+
+ const relaySettings = useNormalRelaySettings();
+ const bridgeSettings = useNormalBridgeSettings();
+
+ const resetHeight = useCallback(() => spacePreAllocationViewRef.current?.reset(), []);
+
+ if (locationType === LocationType.exit) {
+ // Add "Custom" item if a custom relay is selected
+ const specialList: Array<SpecialLocation<undefined>> =
+ relaySettings === undefined
+ ? [
+ {
+ type: LocationSelectionType.special,
+ label: messages.gettext('Custom'),
+ value: undefined,
+ selected: true,
+ },
+ ]
+ : [];
+
+ const relayListWithSpecial = [...filterSpecialLocations(searchTerm, specialList), ...relayList];
+ return (
+ <LocationList
+ key={locationType}
+ source={relayListWithSpecial}
+ selectedElementRef={selectedLocationRef}
+ onSelect={onSelectExitLocation}
+ onExpand={expandLocation}
+ onCollapse={collapseLocation}
+ onWillExpand={onBeforeExpand}
+ onTransitionEnd={resetHeight}
+ />
+ );
+ } else if (relaySettings?.tunnelProtocol !== 'openvpn') {
+ return (
+ <LocationList
+ key={locationType}
+ source={relayList}
+ selectedElementRef={selectedLocationRef}
+ onSelect={onSelectEntryLocation}
+ onExpand={expandLocation}
+ onCollapse={collapseLocation}
+ onWillExpand={onBeforeExpand}
+ onTransitionEnd={resetHeight}
+ />
+ );
+ } else {
+ // Add the "Automatic" item
+ const specialList: Array<SpecialLocation<SpecialBridgeLocationType>> = [
+ {
+ type: LocationSelectionType.special,
+ label: messages.gettext('Automatic'),
+ icon: SpecialLocationIcon.geoLocation,
+ info: messages.pgettext(
+ 'select-location-view',
+ 'The app selects a random bridge server, but servers have a higher probability the closer they are to you.',
+ ),
+ value: SpecialBridgeLocationType.closestToExit,
+ selected: bridgeSettings?.location === 'any',
+ },
+ ];
+
+ const relayListWithSpecial = [...filterSpecialLocations(searchTerm, specialList), ...relayList];
+ return (
+ <LocationList
+ key={locationType}
+ source={relayListWithSpecial}
+ selectedElementRef={selectedLocationRef}
+ onSelect={onSelectBridgeLocation}
+ onExpand={expandLocation}
+ onCollapse={collapseLocation}
+ onWillExpand={onBeforeExpand}
+ onTransitionEnd={resetHeight}
+ />
+ );
+ }
+}
+
+function LocationList<T>(props: CombinedLocationListProps<T>) {
+ const { searchTerm } = useSelectLocationContext();
+
+ if (searchTerm !== '' && props.source.length === 0) {
+ return (
+ <StyledNoResult>
+ <StyledNoResultText>
+ {formatHtml(
+ sprintf(messages.gettext('No result for <b>%(searchTerm)s</b>.'), { searchTerm }),
+ )}
+ </StyledNoResultText>
+ <StyledNoResultText>{messages.gettext('Try a different search.')}</StyledNoResultText>
+ </StyledNoResult>
+ );
+ } else {
+ return <CombinedLocationList {...props} />;
+ }
+}
diff --git a/gui/src/renderer/components/select-location/SelectLocationContainer.tsx b/gui/src/renderer/components/select-location/SelectLocationContainer.tsx
new file mode 100644
index 0000000000..5843d5e6f4
--- /dev/null
+++ b/gui/src/renderer/components/select-location/SelectLocationContainer.tsx
@@ -0,0 +1,40 @@
+import React, { useContext, useMemo, useState } from 'react';
+
+import { RelayListContextProvider } from './RelayListContext';
+import { ScrollPositionContextProvider } from './ScrollPositionContext';
+import { LocationType } from './select-location-types';
+import SelectLocation from './SelectLocation';
+
+// Context containing data required by different components in the sub tree
+interface SelectLocationContext {
+ locationType: LocationType;
+ setLocationType: (locationType: LocationType) => void;
+ searchTerm: string;
+ setSearchTerm: (value: string) => void;
+}
+
+const selectLocationContext = React.createContext<SelectLocationContext | undefined>(undefined);
+
+export function useSelectLocationContext() {
+ return useContext(selectLocationContext)!;
+}
+
+export default function SelectLocationContainer() {
+ const [locationType, setLocationType] = useState(LocationType.exit);
+ const [searchTerm, setSearchTerm] = useState('');
+
+ const value = useMemo(() => ({ locationType, setLocationType, searchTerm, setSearchTerm }), [
+ locationType,
+ searchTerm,
+ ]);
+
+ return (
+ <selectLocationContext.Provider value={value}>
+ <ScrollPositionContextProvider>
+ <RelayListContextProvider>
+ <SelectLocation />
+ </RelayListContextProvider>
+ </ScrollPositionContextProvider>
+ </selectLocationContext.Provider>
+ );
+}
diff --git a/gui/src/renderer/components/SelectLocationStyles.tsx b/gui/src/renderer/components/select-location/SelectLocationStyles.tsx
index d4a0450c7c..4ed9623ee1 100644
--- a/gui/src/renderer/components/SelectLocationStyles.tsx
+++ b/gui/src/renderer/components/select-location/SelectLocationStyles.tsx
@@ -1,13 +1,11 @@
import styled from 'styled-components';
-import { colors } from '../../config.json';
-import { tinyText } from './common-styles';
+import { colors } from '../../../config.json';
+import * as Cell from '../cell';
+import { tinyText } from '../common-styles';
+import SearchBar from '../SearchBar';
+import { HeaderSubTitle } from '../SettingsHeader';
import { ScopeBar } from './ScopeBar';
-import SettingsHeader from './SettingsHeader';
-
-export const StyledScopeBar = styled(ScopeBar)({
- marginTop: '8px',
-});
export const StyledContent = styled.div({
display: 'flex',
@@ -16,13 +14,13 @@ export const StyledContent = styled.div({
overflow: 'visible',
});
-export const StyledNavigationBarAttachment = styled.div({}, (props: { top: number }) => ({
- position: 'sticky',
- top: `${props.top}px`,
- padding: '8px 18px 8px 16px',
- backgroundColor: colors.darkBlue,
- zIndex: 1,
-}));
+export const StyledScopeBar = styled(ScopeBar)({
+ marginBottom: '14px',
+});
+
+export const StyledNavigationBarAttachment = styled.div({
+ padding: '0 16px 14px',
+});
export const StyledFilterIconButton = styled.button({
justifySelf: 'end',
@@ -33,16 +31,10 @@ export const StyledFilterIconButton = styled.button({
backgroundColor: 'transparent',
});
-export const StyledSettingsHeader = styled(SettingsHeader)({
- paddingLeft: '6px',
- paddingBottom: '11px',
-});
-
export const StyledFilterRow = styled.div({
...tinyText,
color: colors.white,
- marginLeft: '6px',
- marginBottom: '8px',
+ margin: '0 6px 14px',
});
export const StyledFilter = styled.div({
@@ -64,3 +56,23 @@ export const StyledClearFilterButton = styled.div({
cursor: 'default',
backgroundColor: 'transparent',
});
+
+export const StyledHeaderSubTitle = styled(HeaderSubTitle)({
+ display: 'block',
+ margin: '0 6px 14px',
+});
+
+export const StyledSearchBar = styled(SearchBar)({
+ margin: '0 6px',
+});
+
+export const StyledNoResult = styled(Cell.CellFooter)({
+ display: 'flex',
+ flexDirection: 'column',
+ paddingTop: 0,
+ marginTop: 0,
+});
+
+export const StyledNoResultText = styled(Cell.CellFooterText)({
+ textAlign: 'center',
+});
diff --git a/gui/src/renderer/components/select-location/SpacePreAllocationView.tsx b/gui/src/renderer/components/select-location/SpacePreAllocationView.tsx
new file mode 100644
index 0000000000..4b493aeed1
--- /dev/null
+++ b/gui/src/renderer/components/select-location/SpacePreAllocationView.tsx
@@ -0,0 +1,30 @@
+import React from 'react';
+
+interface ISpacePreAllocationView {
+ children?: React.ReactNode;
+}
+
+export class SpacePreAllocationView extends React.Component<ISpacePreAllocationView> {
+ private ref = React.createRef<HTMLDivElement>();
+
+ public allocate(height: number) {
+ if (this.ref.current) {
+ this.minHeight = this.ref.current.offsetHeight + height + 'px';
+ }
+ }
+
+ public reset = () => {
+ this.minHeight = 'auto';
+ };
+
+ public render() {
+ return <div ref={this.ref}>{this.props.children}</div>;
+ }
+
+ private set minHeight(value: string) {
+ const element = this.ref.current;
+ if (element) {
+ element.style.minHeight = value;
+ }
+ }
+}
diff --git a/gui/src/renderer/components/select-location/SpecialLocationList.tsx b/gui/src/renderer/components/select-location/SpecialLocationList.tsx
new file mode 100644
index 0000000000..cbed2cf358
--- /dev/null
+++ b/gui/src/renderer/components/select-location/SpecialLocationList.tsx
@@ -0,0 +1,89 @@
+import React, { useCallback } from 'react';
+import styled from 'styled-components';
+
+import { colors } from '../../../config.json';
+import { messages } from '../../../shared/gettext';
+import * as Cell from '../cell';
+import InfoButton from '../InfoButton';
+import {
+ StyledLocationRowButton,
+ StyledLocationRowContainer,
+ StyledLocationRowIcon,
+ StyledLocationRowLabel,
+} from './LocationRow';
+import { LocationSelection, LocationSelectionType, SpecialLocation } from './select-location-types';
+
+interface SpecialLocationsProps<T> {
+ source: Array<SpecialLocation<T>>;
+ selectedElementRef: React.Ref<HTMLDivElement>;
+ onSelect: (value: LocationSelection<T>) => void;
+}
+
+export default function SpecialLocationList<T>({ source, ...props }: SpecialLocationsProps<T>) {
+ return (
+ <>
+ {source.map((location) => (
+ <SpecialLocationRow key={location.label} source={location} {...props} />
+ ))}
+ </>
+ );
+}
+
+const StyledLocationRowContainerWithMargin = styled(StyledLocationRowContainer)({
+ marginBottom: 1,
+});
+
+const StyledSpecialLocationIcon = styled(Cell.Icon)({
+ flex: 0,
+ marginLeft: '2px',
+ marginRight: '8px',
+});
+
+const StyledSpecialLocationInfoButton = styled(InfoButton)({
+ margin: 0,
+ padding: '0 25px',
+ backgroundColor: colors.blue,
+});
+
+interface SpecialLocationRowProps<T> {
+ source: SpecialLocation<T>;
+ selectedElementRef: React.Ref<HTMLDivElement>;
+ onSelect: (value: LocationSelection<T>) => void;
+}
+
+function SpecialLocationRow<T>(props: SpecialLocationRowProps<T>) {
+ const onSelect = useCallback(() => {
+ if (!props.source.selected) {
+ props.onSelect({
+ type: LocationSelectionType.special,
+ value: props.source.value,
+ });
+ }
+ }, [props.source.selected, props.onSelect, props.source.value]);
+
+ const icon = props.source.selected ? 'icon-tick' : props.source.icon ?? undefined;
+ const selectedRef = props.source.selected ? props.selectedElementRef : undefined;
+ return (
+ <StyledLocationRowContainerWithMargin ref={selectedRef}>
+ <StyledLocationRowButton onClick={onSelect} selected={props.source.selected}>
+ {icon && (
+ <StyledSpecialLocationIcon
+ source={icon}
+ tintColor={colors.white}
+ height={22}
+ width={22}
+ />
+ )}
+ <StyledLocationRowLabel>{props.source.label}</StyledLocationRowLabel>
+ </StyledLocationRowButton>
+ {props.source.info && (
+ <StyledLocationRowIcon
+ as={StyledSpecialLocationInfoButton}
+ message={props.source.info}
+ selected={props.source.selected}
+ aria-label={messages.pgettext('accessibility', 'info')}
+ />
+ )}
+ </StyledLocationRowContainerWithMargin>
+ );
+}
diff --git a/gui/src/renderer/components/select-location/select-location-helpers.ts b/gui/src/renderer/components/select-location/select-location-helpers.ts
new file mode 100644
index 0000000000..46225645cc
--- /dev/null
+++ b/gui/src/renderer/components/select-location/select-location-helpers.ts
@@ -0,0 +1,185 @@
+import { sprintf } from 'sprintf-js';
+
+import {
+ compareRelayLocation,
+ compareRelayLocationLoose,
+ LiftedConstraint,
+ RelayLocation,
+} from '../../../shared/daemon-rpc-types';
+import { messages, relayLocations } from '../../../shared/gettext';
+import {
+ IRelayLocationCityRedux,
+ IRelayLocationRedux,
+ IRelayLocationRelayRedux,
+ NormalBridgeSettingsRedux,
+ NormalRelaySettingsRedux,
+} from '../../redux/settings/reducers';
+import { DisabledReason, LocationType } from './select-location-types';
+
+export function isSelected(
+ relayLocation: RelayLocation,
+ selected?: LiftedConstraint<RelayLocation>,
+) {
+ return selected !== 'any' && compareRelayLocationLoose(selected, relayLocation);
+}
+
+export function isExpanded(relayLocation: RelayLocation, expandedLocations?: Array<RelayLocation>) {
+ return (
+ expandedLocations?.some((location) => compareRelayLocation(location, relayLocation)) ?? false
+ );
+}
+
+// Calculates which locations should be expanded based on selected location
+export function defaultExpandedLocations(
+ relaySettings?: NormalRelaySettingsRedux,
+ bridgeSettings?: NormalBridgeSettingsRedux,
+) {
+ const expandedLocations: Partial<Record<LocationType, Array<RelayLocation>>> = {};
+
+ const exitLocation = relaySettings?.location;
+ if (exitLocation && exitLocation !== 'any') {
+ expandedLocations[LocationType.exit] = expandRelayLocation(exitLocation);
+ }
+
+ if (relaySettings?.tunnelProtocol === 'openvpn') {
+ const bridgeLocation = bridgeSettings?.location;
+ if (bridgeLocation && bridgeLocation !== 'any') {
+ expandedLocations[LocationType.entry] = expandRelayLocation(bridgeLocation);
+ }
+ } else if (relaySettings?.wireguard.useMultihop) {
+ const entryLocation = relaySettings?.wireguard.entryLocation;
+ if (entryLocation && entryLocation !== 'any') {
+ expandedLocations[LocationType.entry] = expandRelayLocation(entryLocation);
+ }
+ }
+
+ return expandedLocations;
+}
+
+// Expands a relay location and its parents
+function expandRelayLocation(location: RelayLocation): RelayLocation[] {
+ if ('city' in location) {
+ return [{ country: location.city[0] }];
+ } else if ('hostname' in location) {
+ return [
+ { country: location.hostname[0] },
+ { city: [location.hostname[0], location.hostname[1]] },
+ ];
+ } else {
+ return [];
+ }
+}
+
+// Formats the label that is discplayed for a country, city or relay
+export function formatRowName(
+ name: string,
+ location: RelayLocation,
+ disabledReason?: DisabledReason,
+): string {
+ const translatedName = 'hostname' in location ? name : relayLocations.gettext(name);
+
+ // In some situations the exit/entry server should be marked on a location
+ let info: string | undefined;
+ if (disabledReason === DisabledReason.entry) {
+ info = messages.pgettext('select-location-view', 'Entry');
+ } else if (disabledReason === DisabledReason.exit) {
+ info = messages.pgettext('select-location-view', 'Exit');
+ }
+
+ return info !== undefined
+ ? sprintf(
+ // TRANSLATORS: This is used for appending information about a location.
+ // TRANSLATORS: E.g. "Gothenburg (Entry)" if Gothenburg has been selected as the entrypoint.
+ // TRANSLATORS: Available placeholders:
+ // TRANSLATORS: %(location)s - Translated location name
+ // TRANSLATORS: %(info)s - Information about the location
+ messages.pgettext('select-location-view', '%(location)s (%(info)s)'),
+ {
+ location: translatedName,
+ info,
+ },
+ )
+ : translatedName;
+}
+
+export function isRelayDisabled(
+ relay: IRelayLocationRelayRedux,
+ location: [string, string, string],
+ disabledLocation?: { location: RelayLocation; reason: DisabledReason },
+): DisabledReason | undefined {
+ if (!relay.active) {
+ return DisabledReason.inactive;
+ } else if (
+ disabledLocation &&
+ compareRelayLocation({ hostname: location }, disabledLocation.location)
+ ) {
+ return disabledLocation.reason;
+ } else {
+ return undefined;
+ }
+}
+
+export function isCityDisabled(
+ city: IRelayLocationCityRedux,
+ location: [string, string],
+ disabledLocation?: { location: RelayLocation; reason: DisabledReason },
+): DisabledReason | undefined {
+ const relaysDisabled = city.relays.map((relay) =>
+ isRelayDisabled(relay, [...location, relay.hostname]),
+ );
+ if (relaysDisabled.every((status) => status === DisabledReason.inactive)) {
+ return DisabledReason.inactive;
+ }
+
+ const disabledDueToSelection = relaysDisabled.find(
+ (status) => status === DisabledReason.entry || status === DisabledReason.exit,
+ );
+
+ if (
+ relaysDisabled.every((status) => status !== undefined) &&
+ disabledDueToSelection !== undefined
+ ) {
+ return disabledDueToSelection;
+ }
+
+ if (
+ disabledLocation &&
+ compareRelayLocation({ city: location }, disabledLocation.location) &&
+ city.relays.filter((relay) => relay.active).length <= 1
+ ) {
+ return disabledLocation.reason;
+ }
+
+ return undefined;
+}
+
+export function isCountryDisabled(
+ country: IRelayLocationRedux,
+ location: string,
+ disabledLocation?: { location: RelayLocation; reason: DisabledReason },
+): DisabledReason | undefined {
+ const citiesDisabled = country.cities.map((city) => isCityDisabled(city, [location, city.code]));
+ if (citiesDisabled.every((status) => status === DisabledReason.inactive)) {
+ return DisabledReason.inactive;
+ }
+
+ const disabledDueToSelection = citiesDisabled.find(
+ (status) => status === DisabledReason.entry || status === DisabledReason.exit,
+ );
+ if (
+ citiesDisabled.every((status) => status !== undefined) &&
+ disabledDueToSelection !== undefined
+ ) {
+ return disabledDueToSelection;
+ }
+
+ if (
+ disabledLocation &&
+ compareRelayLocation({ country: location }, disabledLocation.location) &&
+ country.cities.flatMap((city) => city.relays).filter((relay) => relay.active).length <= 1
+ ) {
+ return disabledLocation.reason;
+ }
+
+ return undefined;
+}
diff --git a/gui/src/renderer/components/select-location/select-location-hooks.ts b/gui/src/renderer/components/select-location/select-location-hooks.ts
new file mode 100644
index 0000000000..a1d54b435d
--- /dev/null
+++ b/gui/src/renderer/components/select-location/select-location-hooks.ts
@@ -0,0 +1,93 @@
+import { useCallback } from 'react';
+
+import BridgeSettingsBuilder from '../../../shared/bridge-settings-builder';
+import { RelaySettingsUpdate } from '../../../shared/daemon-rpc-types';
+import log from '../../../shared/logging';
+import RelaySettingsBuilder from '../../../shared/relay-settings-builder';
+import { useAppContext } from '../../context';
+import { createWireguardRelayUpdater } from '../../lib/constraint-updater';
+import { useHistory } from '../../lib/history';
+import { useSelector } from '../../redux/store';
+import {
+ LocationSelection,
+ LocationSelectionType,
+ LocationType,
+ SpecialBridgeLocationType,
+} from './select-location-types';
+import { useSelectLocationContext } from './SelectLocationContainer';
+
+export function useOnSelectExitLocation() {
+ const onSelectLocation = useOnSelectLocation();
+ const history = useHistory();
+ const { connectTunnel } = useAppContext();
+
+ return useCallback(
+ async (relayLocation: LocationSelection<undefined>) => {
+ if (relayLocation.value === undefined) {
+ throw new Error('relayLocation should never be undefiend');
+ }
+
+ history.dismiss();
+ const relayUpdate = RelaySettingsBuilder.normal()
+ .location.fromRaw(relayLocation.value)
+ .build();
+ await onSelectLocation(relayUpdate);
+ await connectTunnel();
+ },
+ [history],
+ );
+}
+
+export function useOnSelectEntryLocation() {
+ const onSelectLocation = useOnSelectLocation();
+ const { setLocationType } = useSelectLocationContext();
+ const baseRelaySettings = useSelector((state) => state.settings.relaySettings);
+
+ return useCallback(async (entryLocation: LocationSelection<never>) => {
+ setLocationType(LocationType.exit);
+ const relayUpdate = createWireguardRelayUpdater(baseRelaySettings)
+ .tunnel.wireguard((wireguard) => wireguard.entryLocation.exact(entryLocation.value))
+ .build();
+ await onSelectLocation(relayUpdate);
+ }, []);
+}
+
+function useOnSelectLocation() {
+ const { updateRelaySettings } = useAppContext();
+
+ return useCallback(async (relayUpdate: RelaySettingsUpdate) => {
+ try {
+ await updateRelaySettings(relayUpdate);
+ } catch (e) {
+ const error = e as Error;
+ log.error(`Failed to select the exit location: ${error.message}`);
+ }
+ }, []);
+}
+
+export function useOnSelectBridgeLocation() {
+ const { updateBridgeSettings } = useAppContext();
+ const { setLocationType } = useSelectLocationContext();
+
+ return useCallback(async (location: LocationSelection<SpecialBridgeLocationType>) => {
+ let bridgeUpdate;
+ if (location.type === LocationSelectionType.relay) {
+ bridgeUpdate = new BridgeSettingsBuilder().location.fromRaw(location.value).build();
+ } else if (
+ location.type === LocationSelectionType.special &&
+ location.value === SpecialBridgeLocationType.closestToExit
+ ) {
+ bridgeUpdate = new BridgeSettingsBuilder().location.any().build();
+ }
+
+ if (bridgeUpdate) {
+ setLocationType(LocationType.exit);
+ try {
+ await updateBridgeSettings(bridgeUpdate);
+ } catch (e) {
+ const error = e as Error;
+ log.error(`Failed to select the bridge location: ${error.message}`);
+ }
+ }
+ }, []);
+}
diff --git a/gui/src/renderer/components/select-location/select-location-types.ts b/gui/src/renderer/components/select-location/select-location-types.ts
new file mode 100644
index 0000000000..6d895deeec
--- /dev/null
+++ b/gui/src/renderer/components/select-location/select-location-types.ts
@@ -0,0 +1,87 @@
+import { RelayLocation } from '../../../shared/daemon-rpc-types';
+import {
+ IRelayLocationCityRedux,
+ IRelayLocationRedux,
+ IRelayLocationRelayRedux,
+} from '../../redux/settings/reducers';
+
+export enum LocationType {
+ entry = 0,
+ exit,
+}
+
+export enum LocationSelectionType {
+ relay = 'relay',
+ special = 'special',
+}
+
+export type LocationSelection<T> =
+ | { type: LocationSelectionType.special; value: T }
+ | { type: LocationSelectionType.relay; value: RelayLocation };
+
+export type LocationList<T> = Array<CountrySpecification | SpecialLocation<T>>;
+export type RelayList = Array<CountrySpecification>;
+
+export enum SpecialBridgeLocationType {
+ closestToExit = 0,
+}
+
+export enum SpecialLocationIcon {
+ geoLocation = 'icon-nearest',
+}
+
+export interface SpecialLocation<T> {
+ type: LocationSelectionType.special;
+ label: string;
+ icon?: SpecialLocationIcon;
+ info?: string;
+ value: T;
+ disabled?: boolean;
+ selected: boolean;
+}
+
+export type LocationSpecification = CountrySpecification | CitySpecification | RelaySpecification;
+
+export interface CountrySpecification extends Omit<IRelayLocationRedux, 'cities'> {
+ type: LocationSelectionType.relay;
+ label: string;
+ location: RelayLocation;
+ active: boolean;
+ disabled: boolean;
+ expanded: boolean;
+ selected: boolean;
+ cities: Array<CitySpecification>;
+}
+
+export interface CitySpecification extends Omit<IRelayLocationCityRedux, 'relays'> {
+ label: string;
+ location: RelayLocation;
+ active: boolean;
+ disabled: boolean;
+ expanded: boolean;
+ selected: boolean;
+ relays: Array<RelaySpecification>;
+}
+
+export interface RelaySpecification extends IRelayLocationRelayRedux {
+ label: string;
+ location: RelayLocation;
+ disabled: boolean;
+ selected: boolean;
+}
+
+export enum DisabledReason {
+ entry,
+ exit,
+ inactive,
+}
+
+export function getLocationChildren(location: LocationSpecification): Array<LocationSpecification> {
+ if ('cities' in location) {
+ return location.cities;
+ } else if ('relays' in location) {
+ return location.relays;
+ } else {
+ return [];
+ }
+}
diff --git a/gui/src/renderer/containers/SelectLocationPage.tsx b/gui/src/renderer/containers/SelectLocationPage.tsx
deleted file mode 100644
index 1c3f3ff32a..0000000000
--- a/gui/src/renderer/containers/SelectLocationPage.tsx
+++ /dev/null
@@ -1,176 +0,0 @@
-import { useCallback, useMemo } from 'react';
-
-import BridgeSettingsBuilder from '../../shared/bridge-settings-builder';
-import { LiftedConstraint, Ownership, RelayLocation } from '../../shared/daemon-rpc-types';
-import log from '../../shared/logging';
-import RelaySettingsBuilder from '../../shared/relay-settings-builder';
-import SelectLocation from '../components/SelectLocation';
-import { useAppContext } from '../context';
-import { createWireguardRelayUpdater } from '../lib/constraint-updater';
-import filterLocations from '../lib/filter-locations';
-import { useHistory } from '../lib/history';
-import { RoutePath } from '../lib/routes';
-import { useSelector } from '../redux/store';
-
-export default function SelectLocationPage() {
- const history = useHistory();
-
- const { updateRelaySettings, connectTunnel, updateBridgeSettings } = useAppContext();
-
- const locale = useSelector((state) => state.userInterface.locale);
- const settings = useSelector((state) => state.settings);
- const { relaySettings, bridgeSettings, bridgeState } = settings;
-
- const providers = useMemo(
- () => ('normal' in relaySettings ? relaySettings.normal.providers : []),
- [relaySettings],
- );
-
- const ownership = useMemo(
- () => ('normal' in relaySettings ? relaySettings.normal.ownership : Ownership.any),
- [relaySettings],
- );
-
- const tunnelProtocol = useMemo(
- () => ('normal' in relaySettings ? relaySettings.normal.tunnelProtocol : 'any'),
- [relaySettings],
- );
-
- const selectedExitLocation = useMemo<RelayLocation | undefined>(() => {
- if ('normal' in relaySettings) {
- const exitLocation = relaySettings.normal.location;
- if (exitLocation !== 'any') {
- return exitLocation;
- }
- }
- return undefined;
- }, [relaySettings]);
-
- const selectedBridgeLocation = useMemo<LiftedConstraint<RelayLocation> | undefined>(() => {
- return tunnelProtocol === 'openvpn' && 'normal' in bridgeSettings
- ? bridgeSettings.normal.location
- : undefined;
- }, [tunnelProtocol, bridgeSettings]);
-
- const multihopEnabled = useMemo(() => {
- return (
- tunnelProtocol !== 'openvpn' &&
- 'normal' in relaySettings &&
- relaySettings.normal.wireguard.useMultihop
- );
- }, [tunnelProtocol, relaySettings]);
-
- const selectedEntryLocation = useMemo<RelayLocation | undefined>(() => {
- if (multihopEnabled && 'normal' in relaySettings) {
- const entryLocation = relaySettings.normal.wireguard.entryLocation;
- if (multihopEnabled && entryLocation !== 'any') {
- return entryLocation;
- }
- }
- return undefined;
- }, [relaySettings, multihopEnabled]);
-
- const allowEntrySelection = useMemo(() => {
- return (
- (tunnelProtocol === 'openvpn' && bridgeState === 'on') ||
- ((tunnelProtocol === 'any' || tunnelProtocol === 'wireguard') && multihopEnabled)
- );
- }, [tunnelProtocol, bridgeState, multihopEnabled]);
-
- const relayLocations = filterLocations(settings.relayLocations, providers, ownership);
- const bridgeLocations = filterLocations(settings.bridgeLocations, providers, ownership);
-
- const onClose = useCallback(() => history.dismiss(), [history]);
- const onViewFilter = useCallback(() => history.push(RoutePath.filter), [history]);
- const onSelectExitLocation = useCallback(
- async (relayLocation: RelayLocation) => {
- // dismiss the view first
- history.dismiss();
- try {
- const relayUpdate = RelaySettingsBuilder.normal().location.fromRaw(relayLocation).build();
-
- await updateRelaySettings(relayUpdate);
- await connectTunnel();
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to select the exit location: ${error.message}`);
- }
- },
- [connectTunnel, updateRelaySettings, history],
- );
- const onSelectEntryLocation = useCallback(
- async (entryLocation: RelayLocation) => {
- // dismiss the view first
- history.dismiss();
-
- const relayUpdate = createWireguardRelayUpdater(relaySettings)
- .tunnel.wireguard((wireguard) => wireguard.entryLocation.exact(entryLocation))
- .build();
-
- try {
- await updateRelaySettings(relayUpdate);
- } catch (e) {
- const error = e as Error;
- log.error('Failed to select the entry location', error.message);
- }
- },
- [history, relaySettings, updateRelaySettings],
- );
- const onSelectBridgeLocation = useCallback(
- async (bridgeLocation: RelayLocation) => {
- // dismiss the view first
- history.dismiss();
-
- try {
- await updateBridgeSettings(
- new BridgeSettingsBuilder().location.fromRaw(bridgeLocation).build(),
- );
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to select the bridge location: ${error.message}`);
- }
- },
- [history, updateBridgeSettings],
- );
- const onSelectClosestToExit = useCallback(async () => {
- history.dismiss();
-
- try {
- await updateBridgeSettings(new BridgeSettingsBuilder().location.any().build());
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to set the bridge location to closest to exit: ${error.message}`);
- }
- }, [updateBridgeSettings, history]);
-
- const onClearProviders = useCallback(async () => {
- await updateRelaySettings({ normal: { providers: [] } });
- }, [updateRelaySettings]);
-
- const onClearOwnership = useCallback(async () => {
- await updateRelaySettings({ normal: { ownership: Ownership.any } });
- }, [updateRelaySettings]);
-
- return (
- <SelectLocation
- locale={locale}
- selectedExitLocation={selectedExitLocation}
- selectedEntryLocation={selectedEntryLocation}
- selectedBridgeLocation={selectedBridgeLocation}
- relayLocations={relayLocations}
- bridgeLocations={bridgeLocations}
- allowEntrySelection={allowEntrySelection}
- tunnelProtocol={tunnelProtocol}
- providers={providers}
- ownership={ownership}
- onClose={onClose}
- onViewFilter={onViewFilter}
- onSelectExitLocation={onSelectExitLocation}
- onSelectEntryLocation={onSelectEntryLocation}
- onSelectBridgeLocation={onSelectBridgeLocation}
- onSelectClosestToExit={onSelectClosestToExit}
- onClearProviders={onClearProviders}
- onClearOwnership={onClearOwnership}
- />
- );
-}
diff --git a/gui/src/renderer/lib/filter-locations.ts b/gui/src/renderer/lib/filter-locations.ts
index 9459c06530..93d20f0c88 100644
--- a/gui/src/renderer/lib/filter-locations.ts
+++ b/gui/src/renderer/lib/filter-locations.ts
@@ -1,59 +1,159 @@
-import { Ownership } from '../../shared/daemon-rpc-types';
-import { IRelayLocationRedux } from '../redux/settings/reducers';
+import { Ownership, RelayEndpointType, RelayLocation } from '../../shared/daemon-rpc-types';
+import { SpecialLocation } from '../components/select-location/select-location-types';
+import {
+ IRelayLocationCityRedux,
+ IRelayLocationRedux,
+ IRelayLocationRelayRedux,
+ NormalRelaySettingsRedux,
+} from '../redux/settings/reducers';
-export default function filterLocations(
+export enum EndpointType {
+ any,
+ entry,
+ exit,
+}
+
+export function filterLocationsByEndPointType(
locations: IRelayLocationRedux[],
- providers: string[],
- ownership: Ownership,
+ endpointType: EndpointType,
+ relaySettings?: NormalRelaySettingsRedux,
): IRelayLocationRedux[] {
- const locationsFilteredByOwnership = filterLocationsByOwnership(locations, ownership);
- const locationsFilteredByProvider = filterLocationsByProvider(
- locationsFilteredByOwnership,
- providers,
- );
-
- return locationsFilteredByProvider;
+ return filterLocationsImpl(locations, getTunnelProtocolFilter(endpointType, relaySettings));
}
-function filterLocationsByOwnership(
+export function filterLocations(
locations: IRelayLocationRedux[],
- ownership: Ownership,
+ ownership?: Ownership,
+ providers?: Array<string>,
): IRelayLocationRedux[] {
- if (ownership === Ownership.any) {
- return locations;
+ const filters = [getOwnershipFilter(ownership), getProviderFilter(providers)];
+
+ return filters.some((filter) => filter !== undefined)
+ ? filterLocationsImpl(locations, (relay) => filters.every((filter) => filter?.(relay) ?? true))
+ : locations;
+}
+
+function getTunnelProtocolFilter(
+ endpointType: EndpointType,
+ relaySettings?: NormalRelaySettingsRedux,
+): (relay: IRelayLocationRelayRedux) => boolean {
+ const tunnelProtocol = relaySettings?.tunnelProtocol ?? 'any';
+ const endpointTypes: Array<RelayEndpointType> = [];
+ if (endpointType !== EndpointType.exit && tunnelProtocol === 'openvpn') {
+ endpointTypes.push('bridge');
+ } else if (tunnelProtocol === 'any') {
+ endpointTypes.push('wireguard');
+ if (!relaySettings?.wireguard.useMultihop) {
+ endpointTypes.push('openvpn');
+ }
+ } else {
+ endpointTypes.push(tunnelProtocol);
+ }
+
+ return (relay) => endpointTypes.includes(relay.endpointType);
+}
+
+function getOwnershipFilter(
+ ownership?: Ownership,
+): ((relay: IRelayLocationRelayRedux) => boolean) | undefined {
+ if (ownership === undefined || ownership === Ownership.any) {
+ return undefined;
}
const expectOwned = ownership === Ownership.mullvadOwned;
+ return (relay) => relay.owned === expectOwned;
+}
+
+function getProviderFilter(
+ providers?: string[],
+): ((relay: IRelayLocationRelayRedux) => boolean) | undefined {
+ return providers === undefined || providers.length === 0
+ ? undefined
+ : (relay) => providers.includes(relay.provider);
+}
+
+function filterLocationsImpl(
+ locations: Array<IRelayLocationRedux>,
+ filter: (relay: IRelayLocationRelayRedux) => boolean,
+): Array<IRelayLocationRedux> {
return locations
.map((country) => ({
...country,
cities: country.cities
- .map((city) => ({
- ...city,
- relays: city.relays.filter((relay) => relay.owned === expectOwned),
- }))
+ .map((city) => ({ ...city, relays: city.relays.filter(filter) }))
.filter((city) => city.relays.length > 0),
}))
.filter((country) => country.cities.length > 0);
}
-function filterLocationsByProvider(
- locations: IRelayLocationRedux[],
- providers: string[],
-): IRelayLocationRedux[] {
- if (providers.length === 0) {
- return locations;
+export function searchForLocations(
+ countries: Array<IRelayLocationRedux>,
+ searchTerm: string,
+): Array<IRelayLocationRedux> {
+ if (searchTerm === '') {
+ return countries;
}
- return 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);
+ return countries.reduce((countries, country) => {
+ const matchingCities = searchCities(country.cities, searchTerm);
+ const expanded = matchingCities.length > 0;
+ const match = search(searchTerm, country.code) || search(searchTerm, country.name);
+ const resultingCities = match ? country.cities : matchingCities;
+ return expanded || match ? [...countries, { ...country, cities: resultingCities }] : countries;
+ }, [] as Array<IRelayLocationRedux>);
+}
+
+function searchCities(
+ cities: Array<IRelayLocationCityRedux>,
+ searchTerm: string,
+): Array<IRelayLocationCityRedux> {
+ return cities.reduce((cities, city) => {
+ const matchingRelays = city.relays.filter((relay) => search(searchTerm, relay.hostname));
+ const expanded = matchingRelays.length > 0;
+ const match = search(searchTerm, city.code) || search(searchTerm, city.name);
+ const resultingRelays = match ? city.relays : matchingRelays;
+ return expanded || match ? [...cities, { ...city, relays: resultingRelays }] : cities;
+ }, [] as Array<IRelayLocationCityRedux>);
+}
+
+export function getLocationsExpandedBySearch(
+ countries: Array<IRelayLocationRedux>,
+ searchTerm: string,
+): Array<RelayLocation> {
+ return countries.reduce((locations, country) => {
+ const cityLocations = getCityLocationsExpandecBySearch(
+ country.cities,
+ country.code,
+ searchTerm,
+ );
+ const cityMatches = country.cities.some(
+ (city) => search(searchTerm, city.code) || search(searchTerm, city.name),
+ );
+ const location = { country: country.code };
+ const expanded = cityMatches || cityLocations.length > 0;
+ return expanded ? [...locations, ...cityLocations, location] : locations;
+ }, [] as Array<RelayLocation>);
+}
+
+function getCityLocationsExpandecBySearch(
+ cities: Array<IRelayLocationCityRedux>,
+ countryCode: string,
+ searchTerm: string,
+): Array<RelayLocation> {
+ return cities.reduce((locations, city) => {
+ const expanded = city.relays.filter((relay) => search(searchTerm, relay.hostname)).length > 0;
+ const location: RelayLocation = { city: [countryCode, city.code] };
+ return expanded ? [...locations, location] : locations;
+ }, [] as Array<RelayLocation>);
+}
+
+function search(searchTerm: string, value: string): boolean {
+ return value.toLowerCase().includes(searchTerm.toLowerCase());
+}
+
+export function filterSpecialLocations<T>(
+ searchTerm: string,
+ locations: Array<SpecialLocation<T>>,
+): Array<SpecialLocation<T>> {
+ return locations.filter((location) => search(searchTerm, location.label));
}
diff --git a/gui/src/renderer/lib/utilityHooks.ts b/gui/src/renderer/lib/utilityHooks.ts
index 59686f1d6d..378a6d5ae5 100644
--- a/gui/src/renderer/lib/utilityHooks.ts
+++ b/gui/src/renderer/lib/utilityHooks.ts
@@ -1,5 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { useSelector } from '../redux/store';
+
export function useMounted() {
const mountedRef = useRef(false);
const isMounted = useCallback(() => mountedRef.current, []);
@@ -53,3 +55,13 @@ export function useBoolean(initialValue = false) {
return [value, setTrue, setFalse, toggle] as const;
}
+
+export function useNormalRelaySettings() {
+ const relaySettings = useSelector((state) => state.settings.relaySettings);
+ return 'normal' in relaySettings ? relaySettings.normal : undefined;
+}
+
+export function useNormalBridgeSettings() {
+ const bridgeSettings = useSelector((state) => state.settings.bridgeSettings);
+ return 'normal' in bridgeSettings ? bridgeSettings.normal : undefined;
+}
diff --git a/gui/src/renderer/redux/settings/actions.ts b/gui/src/renderer/redux/settings/actions.ts
index c6e5aed985..ef03ce14b8 100644
--- a/gui/src/renderer/redux/settings/actions.ts
+++ b/gui/src/renderer/redux/settings/actions.ts
@@ -23,11 +23,6 @@ export interface IUpdateRelayLocationsAction {
relayLocations: IRelayLocationRedux[];
}
-export interface IUpdateBridgeLocationsAction {
- type: 'UPDATE_BRIDGE_LOCATIONS';
- bridgeLocations: IRelayLocationRedux[];
-}
-
export interface IUpdateWireguardEndpointData {
type: 'UPDATE_WIREGUARD_ENDPOINT_DATA';
wireguardEndpointData: IWireguardEndpointData;
@@ -102,7 +97,6 @@ export type SettingsAction =
| IUpdateGuiSettingsAction
| IUpdateRelayAction
| IUpdateRelayLocationsAction
- | IUpdateBridgeLocationsAction
| IUpdateWireguardEndpointData
| IUpdateAllowLanAction
| IUpdateEnableIpv6Action
@@ -139,15 +133,6 @@ function updateRelayLocations(relayLocations: IRelayLocationRedux[]): IUpdateRel
};
}
-function updateBridgeLocations(
- bridgeLocations: IRelayLocationRedux[],
-): IUpdateBridgeLocationsAction {
- return {
- type: 'UPDATE_BRIDGE_LOCATIONS',
- bridgeLocations,
- };
-}
-
function updateWireguardEndpointData(
wireguardEndpointData: IWireguardEndpointData,
): IUpdateWireguardEndpointData {
@@ -258,7 +243,6 @@ export default {
updateGuiSettings,
updateRelay,
updateRelayLocations,
- updateBridgeLocations,
updateWireguardEndpointData,
updateAllowLan,
updateEnableIpv6,
diff --git a/gui/src/renderer/redux/settings/reducers.ts b/gui/src/renderer/redux/settings/reducers.ts
index e4437563ec..2d1d287f00 100644
--- a/gui/src/renderer/redux/settings/reducers.ts
+++ b/gui/src/renderer/redux/settings/reducers.ts
@@ -9,6 +9,7 @@ import {
ObfuscationType,
Ownership,
ProxySettings,
+ RelayEndpointType,
RelayLocation,
RelayProtocol,
TunnelProtocol,
@@ -16,24 +17,30 @@ import {
import { IGuiSettingsState } from '../../../shared/gui-settings-state';
import { ReduxAction } from '../store';
+export type NormalRelaySettingsRedux = {
+ tunnelProtocol: LiftedConstraint<TunnelProtocol>;
+ location: LiftedConstraint<RelayLocation>;
+ providers: string[];
+ ownership: Ownership;
+ openvpn: {
+ port: LiftedConstraint<number>;
+ protocol: LiftedConstraint<RelayProtocol>;
+ };
+ wireguard: {
+ port: LiftedConstraint<number>;
+ ipVersion: LiftedConstraint<IpVersion>;
+ useMultihop: boolean;
+ entryLocation: LiftedConstraint<RelayLocation>;
+ };
+};
+
+export type NormalBridgeSettingsRedux = {
+ location: LiftedConstraint<RelayLocation>;
+};
+
export type RelaySettingsRedux =
| {
- normal: {
- tunnelProtocol: LiftedConstraint<TunnelProtocol>;
- location: LiftedConstraint<RelayLocation>;
- providers: string[];
- ownership: Ownership;
- openvpn: {
- port: LiftedConstraint<number>;
- protocol: LiftedConstraint<RelayProtocol>;
- };
- wireguard: {
- port: LiftedConstraint<number>;
- ipVersion: LiftedConstraint<IpVersion>;
- useMultihop: boolean;
- entryLocation: LiftedConstraint<RelayLocation>;
- };
- };
+ normal: NormalRelaySettingsRedux;
}
| {
customTunnelEndpoint: {
@@ -45,9 +52,7 @@ export type RelaySettingsRedux =
export type BridgeSettingsRedux =
| {
- normal: {
- location: LiftedConstraint<RelayLocation>;
- };
+ normal: NormalBridgeSettingsRedux;
}
| {
custom: ProxySettings;
@@ -61,6 +66,7 @@ export interface IRelayLocationRelayRedux {
active: boolean;
owned: boolean;
weight: number;
+ endpointType: RelayEndpointType;
}
export interface IRelayLocationCityRedux {
@@ -82,7 +88,6 @@ export interface ISettingsReduxState {
guiSettings: IGuiSettingsState;
relaySettings: RelaySettingsRedux;
relayLocations: IRelayLocationRedux[];
- bridgeLocations: IRelayLocationRedux[];
wireguardEndpointData: IWireguardEndpointData;
allowLan: boolean;
enableIpv6: boolean;
@@ -128,7 +133,6 @@ const initialState: ISettingsReduxState = {
},
},
relayLocations: [],
- bridgeLocations: [],
wireguardEndpointData: { portRanges: [], udp2tcpPorts: [] },
allowLan: false,
enableIpv6: true,
@@ -188,12 +192,6 @@ export default function (
relayLocations: action.relayLocations,
};
- case 'UPDATE_BRIDGE_LOCATIONS':
- return {
- ...state,
- bridgeLocations: action.bridgeLocations,
- };
-
case 'UPDATE_WIREGUARD_ENDPOINT_DATA':
return {
...state,
diff --git a/gui/src/shared/daemon-rpc-types.ts b/gui/src/shared/daemon-rpc-types.ts
index 6b75ee7d3c..735bb91224 100644
--- a/gui/src/shared/daemon-rpc-types.ts
+++ b/gui/src/shared/daemon-rpc-types.ts
@@ -162,7 +162,7 @@ export type TunnelProtocol = 'wireguard' | 'openvpn';
export type IpVersion = 'ipv4' | 'ipv6';
-interface IRelaySettingsNormal<OpenVpn, Wireguard> {
+export interface IRelaySettingsNormal<OpenVpn, Wireguard> {
location: Constraint<RelayLocation>;
tunnelProtocol: Constraint<TunnelProtocol>;
providers: string[];
diff --git a/gui/src/shared/ipc-schema.ts b/gui/src/shared/ipc-schema.ts
index 210e85e007..21d7b6216d 100644
--- a/gui/src/shared/ipc-schema.ts
+++ b/gui/src/shared/ipc-schema.ts
@@ -13,9 +13,8 @@ import {
IDeviceRemoval,
IDnsOptions,
ILocation,
- IRelayList,
+ IRelayListWithEndpointData,
ISettings,
- IWireguardEndpointData,
ObfuscationSettings,
RelaySettingsUpdate,
TunnelState,
@@ -42,12 +41,6 @@ export interface ITranslations {
relayLocations?: GetTextTranslations;
}
-export interface IRelayListPair {
- relays: IRelayList;
- bridges: IRelayList;
- wireguardEndpointData: IWireguardEndpointData;
-}
-
export type LaunchApplicationResult = { success: true } | { error: string };
export enum MacOsScrollbarVisibility {
@@ -65,7 +58,7 @@ export interface IAppStateSnapshot {
settings: ISettings;
isPerformingPostUpgrade: boolean;
deviceState?: DeviceState;
- relayListPair: IRelayListPair;
+ relayList?: IRelayListWithEndpointData;
currentVersion: ICurrentAppVersionInfo;
upgradeVersion: IAppVersionInfo;
guiSettings: IGuiSettingsState;
@@ -135,7 +128,7 @@ export const ipcSchema = {
disconnected: notifyRenderer<void>(),
},
relays: {
- '': notifyRenderer<IRelayListPair>(),
+ '': notifyRenderer<IRelayListWithEndpointData>(),
},
currentVersion: {
'': notifyRenderer<ICurrentAppVersionInfo>(),