summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2022-11-15 15:35:36 +0100
committerOskar Nyberg <oskar@mullvad.net>2022-11-24 16:26:28 +0100
commit27a09008679cd99cf4e78a8b9b630527f0e0f7f1 (patch)
tree4cae27cd42d3f33a4bf3f958334d81c57cc28b11
parentac580446338e20571c2089e9bede1b22fb8c0d73 (diff)
downloadmullvadvpn-27a09008679cd99cf4e78a8b9b630527f0e0f7f1.tar.xz
mullvadvpn-27a09008679cd99cf4e78a8b9b630527f0e0f7f1.zip
Refactor SelectLocation and its subcomponents
-rw-r--r--gui/src/renderer/components/AppRouter.tsx4
-rw-r--r--gui/src/renderer/components/Filter.tsx2
-rw-r--r--gui/src/renderer/components/select-location/BridgeLocations.tsx70
-rw-r--r--gui/src/renderer/components/select-location/LocationList.tsx224
-rw-r--r--gui/src/renderer/components/select-location/LocationRow.tsx148
-rw-r--r--gui/src/renderer/components/select-location/Locations.tsx46
-rw-r--r--gui/src/renderer/components/select-location/RelayLocationList.tsx54
-rw-r--r--gui/src/renderer/components/select-location/RelayLocations.tsx365
-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/SelectLocation.tsx659
-rw-r--r--gui/src/renderer/components/select-location/SelectLocationContainer.tsx63
-rw-r--r--gui/src/renderer/components/select-location/SelectLocationStyles.tsx24
-rw-r--r--gui/src/renderer/components/select-location/SpecialLocation.tsx72
-rw-r--r--gui/src/renderer/components/select-location/SpecialLocationList.tsx84
-rw-r--r--gui/src/renderer/components/select-location/SpecialLocations.tsx31
-rw-r--r--gui/src/renderer/components/select-location/select-location-helpers.ts183
-rw-r--r--gui/src/renderer/components/select-location/select-location-hooks.ts279
-rw-r--r--gui/src/renderer/components/select-location/select-location-types.ts87
-rw-r--r--gui/src/renderer/components/select-location/types.ts30
-rw-r--r--gui/src/renderer/containers/SelectLocationPage.tsx176
-rw-r--r--gui/src/renderer/lib/filter-locations.ts96
-rw-r--r--gui/src/renderer/lib/utilityHooks.ts12
-rw-r--r--gui/src/renderer/redux/settings/reducers.ts42
-rw-r--r--gui/src/shared/daemon-rpc-types.ts2
24 files changed, 1268 insertions, 1504 deletions
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/Filter.tsx b/gui/src/renderer/components/Filter.tsx
index 1bb208cd7f..4c0184bc24 100644
--- a/gui/src/renderer/components/Filter.tsx
+++ b/gui/src/renderer/components/Filter.tsx
@@ -5,7 +5,7 @@ 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 { filterLocations } from '../lib/filter-locations';
import { useHistory } from '../lib/history';
import { useBoolean } from '../lib/utilityHooks';
import { IRelayLocationRedux } from '../redux/settings/reducers';
diff --git a/gui/src/renderer/components/select-location/BridgeLocations.tsx b/gui/src/renderer/components/select-location/BridgeLocations.tsx
deleted file mode 100644
index 4ab227de7f..0000000000
--- a/gui/src/renderer/components/select-location/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 } from './LocationList';
-import { RelayLocations } from './RelayLocations';
-import { SpecialLocation, SpecialLocationIcon } from './SpecialLocation';
-import { SpecialLocations } from './SpecialLocations';
-
-export enum SpecialBridgeLocationType {
- closestToExit = 0,
-}
-
-interface IBridgeLocationsProps {
- source: IRelayLocationRedux[];
- filter: string;
- 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}>
- {!props.filter && (
- <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}
- filter={props.filter}
- locale={props.locale}
- onWillExpand={props.onWillExpand}
- onTransitionEnd={props.onTransitionEnd}
- />
- </LocationList>
- );
-});
-
-export default BridgeLocations;
diff --git a/gui/src/renderer/components/select-location/LocationList.tsx b/gui/src/renderer/components/select-location/LocationList.tsx
index 7ba3b76740..d28780abcc 100644
--- a/gui/src/renderer/components/select-location/LocationList.tsx
+++ b/gui/src/renderer/components/select-location/LocationList.tsx
@@ -1,198 +1,46 @@
-import * as React from 'react';
+import React from 'react';
-import { compareRelayLocation, RelayLocation } from '../../../shared/daemon-rpc-types';
-import { RelayLocations } from './RelayLocations';
-import { SpecialLocations } from './SpecialLocations';
+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 enum LocationSelectionType {
- relay = 'relay',
- special = 'special',
+interface LocationListProps<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) => void;
+ onTransitionEnd: () => void;
}
-export type LocationSelection<SpecialValueType> =
- | { type: LocationSelectionType.special; value: SpecialValueType }
- | { type: LocationSelectionType.relay; value: RelayLocation };
+export default function LocationsList<T>(props: LocationListProps<T>) {
+ const specialLocations = props.source.filter(isSpecialLocation);
+ const relayLocations = props.source.filter(isRelayLocation);
-interface ILocationListState<SpecialValueType> {
- selectedValue?: LocationSelection<SpecialValueType>;
- expandedLocations: RelayLocation[];
+ return (
+ <>
+ <SpecialLocationList {...props} source={specialLocations} />
+ <RelayLocationList {...props} source={relayLocations} />
+ </>
+ );
}
-interface ILocationListProps<SpecialValueType> {
- defaultExpandedLocations?: RelayLocation[];
- selectedValue?: LocationSelection<SpecialValueType>;
- selectedElementRef?: React.Ref<React.ReactInstance>;
- onSelect?: (value: LocationSelection<SpecialValueType>) => void;
- children?: React.ReactNode;
+function isSpecialLocation<T>(
+ location: CountrySpecification | SpecialLocation<T>,
+): location is SpecialLocation<T> {
+ return location.type === LocationSelectionType.special;
}
-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,
- };
- });
- };
-}
-
-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 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;
- }
+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
index 10a55a17ce..17261d79e4 100644
--- a/gui/src/renderer/components/select-location/LocationRow.tsx
+++ b/gui/src/renderer/components/select-location/LocationRow.tsx
@@ -10,6 +10,15 @@ 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;
@@ -92,70 +101,76 @@ export const StyledLocationRowLabel = styled(Cell.Label)(normalText, {
fontWeight: 400,
});
-interface IProps {
- name: string;
- active: boolean;
- disabled: boolean;
- location: RelayLocation;
- selected: boolean;
- expanded?: boolean;
- expandable: boolean;
- onSelect?: (location: RelayLocation) => void;
- onExpand?: (location: RelayLocation, value: boolean) => void;
- onWillExpand?: (locationRect: DOMRect, expandedContentHeight: number) => void;
- onTransitionEnd?: () => void;
- children?: React.ReactElement<IProps>[];
+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) => void;
+ onTransitionEnd: () => void;
+ children?: C extends RelaySpecification
+ ? never
+ : React.ReactElement<
+ IProps<C extends CountrySpecification ? CitySpecification : RelaySpecification>
+ >[];
}
-function LocationRow(props: IProps, ref: React.Ref<HTMLDivElement>) {
+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 expanded = 'expanded' in props.source ? props.source.expanded : undefined;
const toggleCollapse = useCallback(() => {
- props.onExpand?.(props.location, !props.expanded);
- }, [props.onExpand, props.expanded, props.location]);
+ if (expanded !== undefined) {
+ const callback = expanded ? props.onCollapse : props.onExpand;
+ callback(props.source.location);
+ }
+ }, [props.onExpand, props.onCollapse, props.source.location, expanded]);
- const handleClick = useCallback(() => props.onSelect?.(props.location), [
- props.onSelect,
- props.location,
- ]);
+ 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 (buttonRect) {
- props.onWillExpand?.(buttonRect, nextHeight);
+ if (expanded !== undefined && buttonRect) {
+ props.onWillExpand(buttonRect, nextHeight);
}
},
[props.onWillExpand],
);
+ const selectedRef = props.source.selected ? props.selectedElementRef : undefined;
return (
<>
- <StyledLocationRowContainer ref={ref} disabled={props.disabled}>
+ <StyledLocationRowContainer ref={selectedRef} disabled={props.source.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>
+ 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 && props.expandable ? (
+ {hasChildren ? (
<StyledLocationRowIcon
as={ChevronButton}
onClick={toggleCollapse}
- up={props.expanded ?? false}
- selected={props.selected}
- disabled={props.disabled}
- location={props.location}
+ up={expanded ?? false}
+ selected={props.source.selected}
+ disabled={props.source.disabled}
+ location={props.source.location}
aria-label={sprintf(
- props.expanded
+ expanded === true
? messages.pgettext('accessibility', 'Collapse %(location)s')
: messages.pgettext('accessibility', 'Expand %(location)s'),
- { location: props.name },
+ { location: props.source.label },
)}
/>
) : null}
@@ -163,8 +178,8 @@ function LocationRow(props: IProps, ref: React.Ref<HTMLDivElement>) {
{hasChildren && (
<Accordion
- expanded={props.expanded}
- onWillExpand={props.expandable ? onWillExpand : undefined}
+ expanded={expanded}
+ onWillExpand={onWillExpand}
onTransitionEnd={props.onTransitionEnd}
animationDuration={150}>
<Cell.Group noMarginBottom>{props.children}</Cell.Group>
@@ -174,35 +189,58 @@ function LocationRow(props: IProps, ref: React.Ref<HTMLDivElement>) {
);
}
-export default React.memo(React.forwardRef(LocationRow), compareProps);
+export default React.memo(LocationRow, compareProps);
-function compareProps(oldProps: IProps, nextProps: IProps): boolean {
+function compareProps<C extends LocationSpecification>(
+ oldProps: IProps<C>,
+ nextProps: IProps<C>,
+): 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)
+ 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(
- oldChildren?: React.ReactElement<IProps>[],
- nextChildren?: React.ReactElement<IProps>[],
-) {
- if (oldChildren === undefined || nextChildren === undefined) {
- return oldChildren === nextChildren;
- }
+ 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 (
- oldChildren.length === nextChildren.length &&
- oldChildren.every((oldChild, i) => compareProps(oldChild.props, nextChildren[i].props))
+ !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/Locations.tsx b/gui/src/renderer/components/select-location/Locations.tsx
deleted file mode 100644
index acfe21a8f6..0000000000
--- a/gui/src/renderer/components/select-location/Locations.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import React from 'react';
-
-import { RelayLocation } from '../../../shared/daemon-rpc-types';
-import { IRelayLocationRedux } from '../../redux/settings/reducers';
-import LocationList, { LocationSelection, LocationSelectionType } from './LocationList';
-import { DisabledReason, RelayLocations } from './RelayLocations';
-
-interface ILocationsProps {
- source: IRelayLocationRedux[];
- filter: string;
- 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}
- filter={props.filter}
- 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/select-location/RelayLocationList.tsx b/gui/src/renderer/components/select-location/RelayLocationList.tsx
new file mode 100644
index 0000000000..7ac2974cbb
--- /dev/null
+++ b/gui/src/renderer/components/select-location/RelayLocationList.tsx
@@ -0,0 +1,54 @@
+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) => 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/select-location/RelayLocations.tsx b/gui/src/renderer/components/select-location/RelayLocations.tsx
deleted file mode 100644
index 120c7c548c..0000000000
--- a/gui/src/renderer/components/select-location/RelayLocations.tsx
+++ /dev/null
@@ -1,365 +0,0 @@
-import React from 'react';
-import { sprintf } from 'sprintf-js';
-
-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 LocationRow from './LocationRow';
-import { City, Country, Relay } from './types';
-
-export enum DisabledReason {
- entry,
- exit,
- inactive,
-}
-
-interface IRelayLocationsProps {
- source: IRelayLocationRedux[];
- filter: string;
- 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 IRelayLocationsState {
- countries: Array<Country>;
-}
-
-interface ICommonCellProps {
- location: RelayLocation;
- selected: boolean;
- ref?: React.Ref<HTMLDivElement>;
-}
-
-export class RelayLocations extends React.PureComponent<
- IRelayLocationsProps,
- IRelayLocationsState
-> {
- public state = {
- countries: this.applyFilter(this.prepareRelaysForPresentation(this.props.source)),
- };
-
- public componentDidUpdate(prevProps: IRelayLocationsProps) {
- if (
- this.props.source !== prevProps.source ||
- this.props.filter !== prevProps.filter ||
- this.props.expandedItems !== prevProps.expandedItems
- ) {
- this.setState({
- countries: this.applyFilter(this.prepareRelaysForPresentation(this.props.source)),
- });
- }
- }
-
- public render() {
- return (
- <Cell.Group noMarginBottom>
- {this.state.countries.map((relayCountry) => {
- return (
- <LocationRow
- key={getLocationKey(relayCountry.location)}
- name={relayCountry.label}
- active={relayCountry.active}
- disabled={relayCountry.disabled}
- expanded={relayCountry.expanded}
- expandable={!this.props.filter}
- onSelect={this.handleSelection}
- onExpand={this.handleExpand}
- onWillExpand={this.props.onWillExpand}
- onTransitionEnd={this.props.onTransitionEnd}
- {...this.getCommonCellProps(relayCountry.location)}>
- {relayCountry.cities.map((relayCity) => {
- return (
- <LocationRow
- key={getLocationKey(relayCity.location)}
- name={relayCity.label}
- active={relayCity.active}
- disabled={relayCity.disabled}
- expanded={relayCity.expanded}
- expandable={!this.props.filter}
- onSelect={this.handleSelection}
- onExpand={this.handleExpand}
- onWillExpand={this.props.onWillExpand}
- onTransitionEnd={this.props.onTransitionEnd}
- {...this.getCommonCellProps(relayCity.location)}>
- {relayCity.relays.map((relay) => {
- return (
- <LocationRow
- key={getLocationKey(relay.location)}
- name={relay.label}
- active={relay.active}
- disabled={relay.disabled}
- expandable={false}
- onSelect={this.handleSelection}
- {...this.getCommonCellProps(relay.location)}
- />
- );
- })}
- </LocationRow>
- );
- })}
- </LocationRow>
- );
- })}
- </Cell.Group>
- );
- }
-
- private prepareRelaysForPresentation(relayList: IRelayLocationRedux[]): Array<Country> {
- 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),
- location: countryLocation,
- active: countryDisabled !== DisabledReason.inactive,
- disabled: countryDisabled !== undefined,
- expanded: this.isExpanded(countryLocation),
- 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),
- location: cityLocation,
- active: cityDisabled !== DisabledReason.inactive,
- disabled: cityDisabled !== undefined,
- expanded: this.isExpanded(cityLocation),
- 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),
- location: relayLocation,
- 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 applyFilter(countries: Array<Country>): Array<Country> {
- if (!this.props.filter) {
- return countries;
- }
-
- const filter = this.props.filter.toLowerCase();
- return countries.reduce((countries, country) => {
- const cities = RelayLocations.filterCities(country.cities, filter);
- const match =
- cities.length > 0 ||
- country.code.toLowerCase().includes(filter) ||
- country.name.toLowerCase().includes(filter);
- return match
- ? [...countries, { ...country, expanded: cities.length > 0, cities }]
- : countries;
- }, [] as Array<Country>);
- }
-
- private static filterCities(cities: Array<City>, filter: string): Array<City> {
- return cities.reduce((cities, city) => {
- const relays = RelayLocations.filterRelays(city.relays, filter);
- const match =
- relays.length > 0 ||
- city.code.toLowerCase().includes(filter) ||
- city.name.toLowerCase().includes(filter);
- return match ? [...cities, { ...city, expanded: relays.length > 0, relays }] : cities;
- }, [] as Array<City>);
- }
-
- private static filterRelays(relays: Array<Relay>, filter: string): Array<Relay> {
- return relays.filter((relay) => relay.hostname.toLowerCase().includes(filter));
- }
-
- 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 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/SelectLocation.tsx b/gui/src/renderer/components/select-location/SelectLocation.tsx
index 0af4a0d8d5..66d70bc174 100644
--- a/gui/src/renderer/components/select-location/SelectLocation.tsx
+++ b/gui/src/renderer/components/select-location/SelectLocation.tsx
@@ -1,15 +1,14 @@
-import React from 'react';
+import React, { useCallback, useEffect, useRef } from 'react';
import { sprintf } from 'sprintf-js';
import { colors } from '../../../config.json';
-import {
- LiftedConstraint,
- Ownership,
- RelayLocation,
- TunnelProtocol,
-} from '../../../shared/daemon-rpc-types';
+import { Ownership } from '../../../shared/daemon-rpc-types';
import { messages } from '../../../shared/gettext';
-import { IRelayLocationRedux } from '../../redux/settings/reducers';
+import { useAppContext } from '../../context';
+import { useHistory } from '../../lib/history';
+import { RoutePath } from '../../lib/routes';
+import { useNormalBridgeSettings, useNormalRelaySettings } from '../../lib/utilityHooks';
+import { useSelector } from '../../redux/store';
import { CustomScrollbarsRef } from '../CustomScrollbars';
import ImageView from '../ImageView';
import { BackAction } from '../KeyboardNavigation';
@@ -21,12 +20,22 @@ import {
NavigationScrollbars,
TitleBarItem,
} from '../NavigationBar';
-import { ScopeBarItem } from '../ScopeBar';
-import { HeaderSubTitle, HeaderTitle } from '../SettingsHeader';
-import BridgeLocations, { SpecialBridgeLocationType } from './BridgeLocations';
-import LocationList, { LocationSelection, LocationSelectionType } from './LocationList';
-import { EntryLocations, ExitLocations } from './Locations';
-import { DisabledReason } from './RelayLocations';
+import LocationList from './LocationList';
+import { ScopeBar, ScopeBarItem } from './ScopeBar';
+import {
+ useExpandedLocations,
+ useOnSelectBridgeLocation,
+ useOnSelectLocation,
+ useRelayList,
+} from './select-location-hooks';
+import {
+ LocationSelectionType,
+ LocationType,
+ SpecialBridgeLocationType,
+ SpecialLocation,
+ SpecialLocationIcon,
+} from './select-location-types';
+import { useSelectLocationContext } from './SelectLocationContainer';
import {
StyledClearFilterButton,
StyledContent,
@@ -34,420 +43,282 @@ import {
StyledFilterIconButton,
StyledFilterRow,
StyledNavigationBarAttachment,
- StyledScopeBar,
- StyledSearchBar,
- StyledSettingsHeader,
} from './SelectLocationStyles';
import { SpacePreAllocationView } from './SpacePreAllocationView';
-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;
- filter: string;
-}
+export default function SelectLocation() {
+ const history = useHistory();
+ const { updateRelaySettings } = useAppContext();
+ const { scrollViewRef, saveScrollPosition, resetScrollPositions } = useScrollPosition();
+ const { resetExpandedLocations } = useExpandedLocations();
+ const spacePreAllocationViewRef = useRef() as React.RefObject<SpacePreAllocationView>;
+ const { locationType, setLocationType } = useSelectLocationContext();
-interface ISelectLocationSnapshot {
- scrollPosition: [number, number];
- expandedLocations: RelayLocation[];
-}
+ const relaySettings = useNormalRelaySettings();
+ const ownership = relaySettings?.ownership ?? Ownership.any;
+ const providers = relaySettings?.providers ?? [];
-export default class SelectLocation extends React.Component<IProps, IState> {
- public state = { headingHeight: 0, locationScope: LocationScope.exit, filter: '' };
+ const onClose = useCallback(() => history.dismiss(), [history]);
+ const onViewFilter = useCallback(() => history.push(RoutePath.filter), [history]);
- 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>();
+ const tunnelProtocol = relaySettings?.tunnelProtocol ?? 'any';
+ const bridgeState = useSelector((state) => state.settings.bridgeState);
+ const allowEntrySelection =
+ (tunnelProtocol === 'openvpn' && bridgeState === 'on') ||
+ (tunnelProtocol !== 'openvpn' && relaySettings?.wireguard.useMultihop);
- private exitLocationList = React.createRef<LocationList<never>>();
- private entryLocationList = React.createRef<LocationList<never>>();
- private bridgeLocationList = React.createRef<LocationList<SpecialBridgeLocationType>>();
+ const onClearProviders = useCallback(async () => {
+ resetScrollPositions();
+ resetExpandedLocations();
+ await updateRelaySettings({ normal: { providers: [] } });
+ }, []);
- private snapshotByScope: Partial<Record<LocationScope, ISelectLocationSnapshot>> = {};
+ const onClearOwnership = useCallback(async () => {
+ resetScrollPositions();
+ resetExpandedLocations();
+ await updateRelaySettings({ normal: { ownership: Ownership.any } });
+ }, []);
- private headerRef = React.createRef<HTMLHeadingElement>();
+ const changeLocationType = useCallback(
+ (locationType: LocationType) => {
+ saveScrollPosition();
+ setLocationType(locationType);
+ },
+ [saveScrollPosition],
+ );
- public componentDidMount() {
- this.scrollToSelectedCell();
- this.setState((state) => ({
- headingHeight: this.headerRef.current?.offsetHeight ?? state.headingHeight,
- }));
- }
+ 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>
- public componentDidUpdate(
- _prevProps: IProps,
- prevState: IState,
- snapshot?: ISelectLocationSnapshot,
- ) {
- if (this.state.locationScope !== prevState.locationScope) {
- this.restoreScrollPosition(this.state.locationScope);
+ <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>
- if (snapshot) {
- this.snapshotByScope[prevState.locationScope] = snapshot;
- }
- }
- }
+ <StyledNavigationBarAttachment>
+ {allowEntrySelection && (
+ <ScopeBar selectedIndex={locationType} onChange={changeLocationType}>
+ <ScopeBarItem>{messages.pgettext('select-location-view', 'Entry')}</ScopeBarItem>
+ <ScopeBarItem>{messages.pgettext('select-location-view', 'Exit')}</ScopeBarItem>
+ </ScopeBar>
+ )}
- public getSnapshotBeforeUpdate(
- prevProps: IProps,
- prevState: IState,
- ): ISelectLocationSnapshot | undefined {
- const scrollView = this.scrollView.current;
- const locationList = this.getLocationListRef(prevProps, prevState);
+ {showFilters && (
+ <StyledFilterRow>
+ {messages.pgettext('select-location-view', 'Filtered:')}
- if (scrollView && locationList) {
- return {
- scrollPosition: scrollView.getScrollPosition(),
- expandedLocations: locationList.getExpandedLocations(),
- };
- } else {
- return undefined;
- }
- }
+ {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>
+ )}
- 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>
+ {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>
+ )}
+ </StyledNavigationBarAttachment>
- <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>
- </StyledSettingsHeader>
+ <NavigationScrollbars ref={scrollViewRef}>
+ <SpacePreAllocationView ref={spacePreAllocationViewRef}>
+ <StyledContent>
+ <SelectLocationContent
+ spacePreAllocationViewRef={spacePreAllocationViewRef}
+ scrollViewRef={scrollViewRef}
+ />
+ </StyledContent>
+ </SpacePreAllocationView>
+ </NavigationScrollbars>
+ </NavigationContainer>
+ </SettingsContainer>
+ </Layout>
+ </BackAction>
+ );
+}
- {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>
- )}
+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');
+ }
+}
- {this.renderHeaderSubtitle()}
+interface SelectLocationContentProps {
+ spacePreAllocationViewRef: React.RefObject<SpacePreAllocationView>;
+ scrollViewRef: React.RefObject<CustomScrollbarsRef>;
+}
- {showFilters && (
- <StyledFilterRow>
- {messages.pgettext('select-location-view', 'Filtered:')}
+function SelectLocationContent(props: SelectLocationContentProps) {
+ const { locationType, selectedLocationRef } = useSelectLocationContext();
+ const relayList = useRelayList();
+ const { expandLocation, collapseLocation } = useExpandedLocations();
+ const onSelectLocation = useOnSelectLocation();
+ const onSelectBridgeLocation = useOnSelectBridgeLocation();
- {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>
- )}
+ const relaySettings = useNormalRelaySettings();
+ const bridgeSettings = useNormalBridgeSettings();
- {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>
- )}
+ const onWillExpand = useCallback((locationRect: DOMRect, expandedContentHeight: number) => {
+ locationRect.height += expandedContentHeight;
+ props.spacePreAllocationViewRef.current?.allocate(expandedContentHeight);
+ props.scrollViewRef.current?.scrollIntoView(locationRect);
+ }, []);
- <StyledSearchBar searchTerm={this.state.filter} onSearch={this.updateFilter} />
- </StyledNavigationBarAttachment>
+ const resetHeight = useCallback(() => {
+ props.spacePreAllocationViewRef.current?.reset();
+ }, []);
- <StyledContent>{this.renderLocationList()}</StyledContent>
- </SpacePreAllocationView>
- </NavigationScrollbars>
- </NavigationContainer>
- </SettingsContainer>
- </Layout>
- </BackAction>
+ if (locationType === LocationType.exit) {
+ return (
+ <LocationList
+ key={locationType}
+ source={relayList}
+ selectedElementRef={selectedLocationRef}
+ onSelect={onSelectLocation}
+ onExpand={expandLocation}
+ onCollapse={collapseLocation}
+ onWillExpand={onWillExpand}
+ onTransitionEnd={resetHeight}
+ />
);
- }
-
- public restoreScrollPosition(scope: LocationScope) {
- const snapshot = this.snapshotByScope[scope];
-
- if (snapshot) {
- this.scrollToPosition(...snapshot.scrollPosition);
- } else {
- this.scrollToSelectedCell();
- }
- }
+ } else if (relaySettings?.tunnelProtocol !== 'openvpn') {
+ return (
+ <LocationList
+ key={locationType}
+ source={relayList}
+ selectedElementRef={selectedLocationRef}
+ onSelect={onSelectLocation}
+ onExpand={expandLocation}
+ onCollapse={collapseLocation}
+ onWillExpand={onWillExpand}
+ onTransitionEnd={resetHeight}
+ />
+ );
+ } else {
+ const automaticItem: 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',
+ disabled: false,
+ };
- 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');
- }
+ const bridgeRelayList = [automaticItem, ...relayList];
+ return (
+ <LocationList
+ key={locationType}
+ source={bridgeRelayList}
+ selectedElementRef={selectedLocationRef}
+ onSelect={onSelectBridgeLocation}
+ onExpand={expandLocation}
+ onCollapse={collapseLocation}
+ onWillExpand={onWillExpand}
+ onTransitionEnd={resetHeight}
+ />
+ );
}
+}
- 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;
- }
- }
+function useScrollPosition() {
+ const {
+ activeFilter,
+ locationType,
+ scrollPositions,
+ selectedLocationRef,
+ } = useSelectLocationContext();
+ const scrollViewRef = useRef<CustomScrollbarsRef>(null);
- 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;
+ const saveScrollPosition = useCallback(() => {
+ const scrollPosition = scrollViewRef.current?.getScrollPosition();
+ if (scrollPositions.current) {
+ scrollPositions.current[locationType] = scrollPosition;
}
- }
+ }, [locationType]);
- 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>
- );
+ const resetScrollPositions = useCallback(() => {
+ for (const locationTypeVariant of [LocationType.entry, LocationType.exit]) {
+ if (
+ scrollPositions.current &&
+ (scrollPositions.current[locationTypeVariant] || locationTypeVariant === locationType)
+ ) {
+ scrollPositions.current[locationTypeVariant] = [0, 0];
}
- } 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}
- filter={this.state.filter}
- 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}
- filter={this.state.filter}
- 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}
- filter={this.state.filter}
- 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}
- />
- );
}
- }
+ }, [locationType]);
- private resetHeight = () => {
- this.spacePreAllocationViewRef.current?.reset();
- };
-
- private getExpandedLocationsFromSnapshot(): RelayLocation[] | undefined {
- const snapshot = this.snapshotByScope[this.state.locationScope];
- if (snapshot) {
- return snapshot.expandedLocations;
+ useEffect(() => {
+ const scrollPosition = scrollPositions.current?.[locationType];
+ if (scrollPosition) {
+ scrollViewRef.current?.scrollTo(...scrollPosition);
+ } else if (selectedLocationRef.current) {
+ scrollViewRef.current?.scrollToElement(selectedLocationRef.current, 'middle');
} 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();
+ scrollViewRef.current?.scrollToTop();
}
- };
-
- private onWillExpand = (locationRect: DOMRect, expandedContentHeight: number) => {
- locationRect.height += expandedContentHeight;
- this.spacePreAllocationViewRef.current?.allocate(expandedContentHeight);
- this.scrollView.current?.scrollIntoView(locationRect);
- };
+ }, [locationType, activeFilter]);
- private updateFilter = (filter: string) => {
- this.setState({ filter });
- };
+ return { scrollViewRef, saveScrollPosition, resetScrollPositions };
}
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..71e898a229
--- /dev/null
+++ b/gui/src/renderer/components/select-location/SelectLocationContainer.tsx
@@ -0,0 +1,63 @@
+import React, { useContext, useMemo, useRef, useState } from 'react';
+
+import { Ownership, RelayLocation } from '../../../shared/daemon-rpc-types';
+import { useNormalBridgeSettings, useNormalRelaySettings } from '../../lib/utilityHooks';
+import { defaultExpandedLocations } from './select-location-helpers';
+import { LocationType } from './select-location-types';
+import SelectLocation from './SelectLocation';
+
+type ExpandedLocations = Partial<Record<LocationType, Array<RelayLocation>>>;
+type ScrollPosition = [number, number];
+
+interface SelectLocationContext {
+ locationType: LocationType;
+ setLocationType: (locationType: LocationType) => void;
+ activeFilter: boolean;
+ expandedLocations: ExpandedLocations;
+ setExpandedLocations: (
+ arg: ExpandedLocations | ((prev: ExpandedLocations) => ExpandedLocations),
+ ) => void;
+ scrollPositions: React.RefObject<Partial<Record<LocationType, ScrollPosition>>>;
+ selectedLocationRef: React.RefObject<HTMLDivElement>;
+}
+
+const selectLocationContext = React.createContext<SelectLocationContext | undefined>(undefined);
+
+export function useSelectLocationContext() {
+ return useContext(selectLocationContext)!;
+}
+
+export default function SelectLocationContainer() {
+ const relaySettings = useNormalRelaySettings();
+ const bridgeSettings = useNormalBridgeSettings();
+ const [locationType, setLocationType] = useState(LocationType.exit);
+
+ const selectedLocationRef = useRef<HTMLDivElement>(null);
+
+ const [expandedLocations, setExpandedLocations] = useState<
+ Partial<Record<LocationType, Array<RelayLocation>>>
+ >(() => defaultExpandedLocations(relaySettings, bridgeSettings));
+ const scrollPositions = useRef<Partial<Record<LocationType, ScrollPosition>>>({});
+
+ const ownershipActive = relaySettings !== undefined && relaySettings.ownership !== Ownership.any;
+ const providersActive = relaySettings !== undefined && relaySettings.providers.length > 0;
+
+ const value = useMemo(
+ () => ({
+ locationType,
+ setLocationType,
+ activeFilter: ownershipActive || providersActive,
+ expandedLocations,
+ setExpandedLocations,
+ scrollPositions,
+ selectedLocationRef,
+ }),
+ [locationType, relaySettings?.ownership, relaySettings?.providers, expandedLocations],
+ );
+
+ return (
+ <selectLocationContext.Provider value={value}>
+ <SelectLocation />
+ </selectLocationContext.Provider>
+ );
+}
diff --git a/gui/src/renderer/components/select-location/SelectLocationStyles.tsx b/gui/src/renderer/components/select-location/SelectLocationStyles.tsx
index 00c8d02d97..ba5c0c5f24 100644
--- a/gui/src/renderer/components/select-location/SelectLocationStyles.tsx
+++ b/gui/src/renderer/components/select-location/SelectLocationStyles.tsx
@@ -2,13 +2,7 @@ import styled from 'styled-components';
import { colors } from '../../../config.json';
import { tinyText } from '../common-styles';
-import { ScopeBar } from '../ScopeBar';
import SearchBar from '../SearchBar';
-import SettingsHeader from '../SettingsHeader';
-
-export const StyledScopeBar = styled(ScopeBar)({
- marginTop: '8px',
-});
export const StyledContent = styled.div({
display: 'flex',
@@ -17,13 +11,9 @@ 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 StyledNavigationBarAttachment = styled.div({
+ padding: '0px 16px 8px',
+});
export const StyledFilterIconButton = styled.button({
justifySelf: 'end',
@@ -34,16 +24,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: '10px 6px 2px',
});
export const StyledFilter = styled.div({
diff --git a/gui/src/renderer/components/select-location/SpecialLocation.tsx b/gui/src/renderer/components/select-location/SpecialLocation.tsx
deleted file mode 100644
index 06d3a8b408..0000000000
--- a/gui/src/renderer/components/select-location/SpecialLocation.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import React 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';
-
-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',
-});
-
-export enum SpecialLocationIcon {
- geoLocation = 'icon-nearest',
-}
-
-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);
- }
- };
-}
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..6667f14fa9
--- /dev/null
+++ b/gui/src/renderer/components/select-location/SpecialLocationList.tsx
@@ -0,0 +1,84 @@
+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,
+ });
+ }
+ }, []);
+
+ const selectedRef = props.source.selected ? props.selectedElementRef : undefined;
+ return (
+ <StyledLocationRowContainerWithMargin ref={selectedRef}>
+ <StyledLocationRowButton onClick={onSelect} selected={props.source.selected}>
+ <StyledSpecialLocationIcon
+ source={props.source.selected ? 'icon-tick' : props.source.icon}
+ tintColor={colors.white}
+ height={22}
+ width={22}
+ />
+ <StyledLocationRowLabel>{props.source.label}</StyledLocationRowLabel>
+ </StyledLocationRowButton>
+ <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/SpecialLocations.tsx b/gui/src/renderer/components/select-location/SpecialLocations.tsx
deleted file mode 100644
index fb65f9c6ae..0000000000
--- a/gui/src/renderer/components/select-location/SpecialLocations.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import React from 'react';
-
-import { SpecialLocation } from './SpecialLocation';
-
-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;
- }
- })}
- </>
- );
-}
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..380023af06
--- /dev/null
+++ b/gui/src/renderer/components/select-location/select-location-helpers.ts
@@ -0,0 +1,183 @@
+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 [];
+ }
+}
+
+export function formatRowName(
+ name: string,
+ location: RelayLocation,
+ disabledReason?: DisabledReason,
+): string {
+ const translatedName = 'hostname' in location ? name : relayLocations.gettext(name);
+
+ 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..747dfac9c3
--- /dev/null
+++ b/gui/src/renderer/components/select-location/select-location-hooks.ts
@@ -0,0 +1,279 @@
+import { useCallback, useMemo } from 'react';
+
+import BridgeSettingsBuilder from '../../../shared/bridge-settings-builder';
+import {
+ compareRelayLocation,
+ RelayLocation,
+ 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 { filterLocations } from '../../lib/filter-locations';
+import { useHistory } from '../../lib/history';
+import { useNormalBridgeSettings, useNormalRelaySettings } from '../../lib/utilityHooks';
+import { IRelayLocationRedux } from '../../redux/settings/reducers';
+import { useSelector } from '../../redux/store';
+import {
+ defaultExpandedLocations,
+ formatRowName,
+ isCityDisabled,
+ isCountryDisabled,
+ isExpanded,
+ isRelayDisabled,
+ isSelected,
+} from './select-location-helpers';
+import {
+ DisabledReason,
+ LocationList,
+ LocationSelection,
+ LocationSelectionType,
+ LocationType,
+ SpecialBridgeLocationType,
+} from './select-location-types';
+import { useSelectLocationContext } from './SelectLocationContainer';
+
+function useFullRelayList(): Array<IRelayLocationRedux> {
+ const { locationType } = useSelectLocationContext();
+ const relaySettings = useNormalRelaySettings();
+ const relayLocations = useSelector((state) => state.settings.relayLocations);
+ const bridgeLocations = useSelector((state) => state.settings.bridgeLocations);
+ return locationType === LocationType.entry && relaySettings?.tunnelProtocol === 'openvpn'
+ ? bridgeLocations
+ : relayLocations;
+}
+
+// Return all locations that matches both the set filters and the search term.
+function useFilteredRelays(): Array<IRelayLocationRedux> {
+ const relayList = useFullRelayList();
+ const relaySettings = useNormalRelaySettings();
+
+ const filteredRelayList = useMemo(
+ () =>
+ relaySettings
+ ? filterLocations(relayList, relaySettings.providers, relaySettings.ownership)
+ : relayList,
+ [relaySettings, relayList, relaySettings?.providers, relaySettings?.ownership],
+ );
+
+ return filteredRelayList;
+}
+
+// Return all RelayLocations that should be expanded
+export function useExpandedLocations() {
+ const relaySettings = useNormalRelaySettings();
+ const bridgeSettings = useNormalBridgeSettings();
+ const { locationType, expandedLocations, setExpandedLocations } = useSelectLocationContext();
+
+ const expandedLocationsForType = useMemo(() => expandedLocations[locationType], [
+ expandedLocations,
+ locationType,
+ ]);
+
+ 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],
+ );
+
+ const resetExpandedLocations = useCallback(() => {
+ setExpandedLocations(defaultExpandedLocations(relaySettings, bridgeSettings));
+ }, [relaySettings, bridgeSettings]);
+
+ return {
+ expandedLocations: expandedLocationsForType,
+ expandLocation,
+ collapseLocation,
+ resetExpandedLocations,
+ };
+}
+
+// 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.
+export function useRelayList(): LocationList<never> {
+ const locale = useSelector((state) => state.userInterface.locale);
+ const { expandedLocations } = useExpandedLocations();
+ const relayList = useFilteredRelays();
+ const selectedLocation = useSelectedLocation();
+ const disabledLocation = useDisabledLocation();
+
+ return relayList
+ .map((country) => {
+ const countryLocation = { country: country.code };
+ const countryDisabled = isCountryDisabled(country, countryLocation.country, 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));
+}
+
+function useDisabledLocation() {
+ const { locationType } = useSelectLocationContext();
+ const relaySettings = useNormalRelaySettings();
+
+ 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;
+}
+
+// Returns the selected location for the current tunnel protocol and location type
+function useSelectedLocation() {
+ const { locationType } = useSelectLocationContext();
+ const relaySettings = useNormalRelaySettings();
+ const bridgeSettings = useNormalBridgeSettings();
+
+ 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;
+ }
+}
+
+export function useOnSelectLocation() {
+ const history = useHistory();
+ const { updateRelaySettings } = useAppContext();
+ const { locationType } = useSelectLocationContext();
+ const baseRelaySettings = useSelector((state) => state.settings.relaySettings);
+
+ const onSelectLocation = useCallback(
+ async (relayUpdate: RelaySettingsUpdate) => {
+ // dismiss the view first
+ history.dismiss();
+ try {
+ await updateRelaySettings(relayUpdate);
+ } catch (e) {
+ const error = e as Error;
+ log.error(`Failed to select the exit location: ${error.message}`);
+ }
+ },
+ [history],
+ );
+
+ const onSelectExitLocation = useCallback(
+ async (relayLocation: LocationSelection<never>) => {
+ const relayUpdate = RelaySettingsBuilder.normal()
+ .location.fromRaw(relayLocation.value)
+ .build();
+ await onSelectLocation(relayUpdate);
+ },
+ [onSelectLocation],
+ );
+ const onSelectEntryLocation = useCallback(
+ async (entryLocation: LocationSelection<never>) => {
+ const relayUpdate = createWireguardRelayUpdater(baseRelaySettings)
+ .tunnel.wireguard((wireguard) => wireguard.entryLocation.exact(entryLocation.value))
+ .build();
+ await onSelectLocation(relayUpdate);
+ },
+ [onSelectLocation],
+ );
+
+ return locationType === LocationType.exit ? onSelectExitLocation : onSelectEntryLocation;
+}
+
+export function useOnSelectBridgeLocation() {
+ const history = useHistory();
+ const { updateBridgeSettings } = useAppContext();
+
+ return useCallback(
+ async (location: LocationSelection<SpecialBridgeLocationType>) => {
+ // dismiss the view first
+ history.dismiss();
+
+ 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) {
+ try {
+ await updateBridgeSettings(bridgeUpdate);
+ } catch (e) {
+ const error = e as Error;
+ log.error(`Failed to select the bridge location: ${error.message}`);
+ }
+ }
+ },
+ [history, updateBridgeSettings],
+ );
+}
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..3fbd2ef0c2
--- /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/components/select-location/types.ts b/gui/src/renderer/components/select-location/types.ts
deleted file mode 100644
index dd0d563401..0000000000
--- a/gui/src/renderer/components/select-location/types.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { RelayLocation } from '../../../shared/daemon-rpc-types';
-import {
- IRelayLocationCityRedux,
- IRelayLocationRedux,
- IRelayLocationRelayRedux,
-} from '../../redux/settings/reducers';
-
-export interface Relay extends IRelayLocationRelayRedux {
- label: string;
- location: RelayLocation;
- disabled: boolean;
-}
-
-export interface City extends Omit<IRelayLocationCityRedux, 'relays'> {
- label: string;
- location: RelayLocation;
- active: boolean;
- disabled: boolean;
- expanded: boolean;
- relays: Array<Relay>;
-}
-
-export interface Country extends Omit<IRelayLocationRedux, 'cities'> {
- label: string;
- location: RelayLocation;
- active: boolean;
- disabled: boolean;
- expanded: boolean;
- cities: Array<City>;
-}
diff --git a/gui/src/renderer/containers/SelectLocationPage.tsx b/gui/src/renderer/containers/SelectLocationPage.tsx
deleted file mode 100644
index d459696ed4..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/select-location/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..f9e78e40b1 100644
--- a/gui/src/renderer/lib/filter-locations.ts
+++ b/gui/src/renderer/lib/filter-locations.ts
@@ -1,7 +1,11 @@
-import { Ownership } from '../../shared/daemon-rpc-types';
-import { IRelayLocationRedux } from '../redux/settings/reducers';
+import { Ownership, RelayLocation } from '../../shared/daemon-rpc-types';
+import {
+ IRelayLocationCityRedux,
+ IRelayLocationRedux,
+ IRelayLocationRelayRedux,
+} from '../redux/settings/reducers';
-export default function filterLocations(
+export function filterLocations(
locations: IRelayLocationRedux[],
providers: string[],
ownership: Ownership,
@@ -24,36 +28,86 @@ function filterLocationsByOwnership(
}
const expectOwned = ownership === Ownership.mullvadOwned;
- return locations
- .map((country) => ({
- ...country,
- cities: country.cities
- .map((city) => ({
- ...city,
- relays: city.relays.filter((relay) => relay.owned === expectOwned),
- }))
- .filter((city) => city.relays.length > 0),
- }))
- .filter((country) => country.cities.length > 0);
+ return filterLocationsImpl(locations, (relay) => relay.owned === expectOwned);
}
function filterLocationsByProvider(
locations: IRelayLocationRedux[],
providers: string[],
): IRelayLocationRedux[] {
- if (providers.length === 0) {
- return locations;
- }
+ return providers.length === 0
+ ? locations
+ : filterLocationsImpl(locations, (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) => providers.includes(relay.provider)),
- }))
+ .map((city) => ({ ...city, relays: city.relays.filter(filter) }))
.filter((city) => city.relays.length > 0),
}))
.filter((country) => country.cities.length > 0);
}
+
+export function searchForLocations(
+ countries: Array<IRelayLocationRedux>,
+ searchTerm: string,
+): Array<IRelayLocationRedux> {
+ return countries.reduce((countries, country) => {
+ const matchingCities = searchCities(country.cities, searchTerm);
+ const expanded = matchingCities.length > 0;
+ const match = search(country.code, searchTerm) || search(country.name, searchTerm);
+ 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(city.code, searchTerm) || search(city.name, searchTerm);
+ 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 location = { country: country.code };
+ const expanded = 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());
+}
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/reducers.ts b/gui/src/renderer/redux/settings/reducers.ts
index e4437563ec..dfa68b97c8 100644
--- a/gui/src/renderer/redux/settings/reducers.ts
+++ b/gui/src/renderer/redux/settings/reducers.ts
@@ -16,24 +16,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 +51,7 @@ export type RelaySettingsRedux =
export type BridgeSettingsRedux =
| {
- normal: {
- location: LiftedConstraint<RelayLocation>;
- };
+ normal: NormalBridgeSettingsRedux;
}
| {
custom: ProxySettings;
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[];