summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2022-11-09 15:14:11 +0100
committerOskar Nyberg <oskar@mullvad.net>2022-11-24 16:26:28 +0100
commitac580446338e20571c2089e9bede1b22fb8c0d73 (patch)
tree7bdd763924641388612b7703cb4dd943d842e095
parent8c40662a0816a43f2423a68596ea2f1b0b3a492a (diff)
downloadmullvadvpn-ac580446338e20571c2089e9bede1b22fb8c0d73.tar.xz
mullvadvpn-ac580446338e20571c2089e9bede1b22fb8c0d73.zip
Move select location components to new directory and split components up
-rw-r--r--gui/src/renderer/components/LocationList.tsx635
-rw-r--r--gui/src/renderer/components/select-location/BridgeLocations.tsx (renamed from gui/src/renderer/components/BridgeLocations.tsx)44
-rw-r--r--gui/src/renderer/components/select-location/LocationList.tsx198
-rw-r--r--gui/src/renderer/components/select-location/LocationRow.tsx (renamed from gui/src/renderer/components/LocationRow.tsx)23
-rw-r--r--gui/src/renderer/components/select-location/Locations.tsx (renamed from gui/src/renderer/components/Locations.tsx)16
-rw-r--r--gui/src/renderer/components/select-location/RelayLocations.tsx365
-rw-r--r--gui/src/renderer/components/select-location/SelectLocation.tsx (renamed from gui/src/renderer/components/SelectLocation.tsx)100
-rw-r--r--gui/src/renderer/components/select-location/SelectLocationStyles.tsx (renamed from gui/src/renderer/components/SelectLocationStyles.tsx)13
-rw-r--r--gui/src/renderer/components/select-location/SpacePreAllocationView.tsx30
-rw-r--r--gui/src/renderer/components/select-location/SpecialLocation.tsx72
-rw-r--r--gui/src/renderer/components/select-location/SpecialLocations.tsx31
-rw-r--r--gui/src/renderer/components/select-location/types.ts30
-rw-r--r--gui/src/renderer/containers/SelectLocationPage.tsx2
13 files changed, 818 insertions, 741 deletions
diff --git a/gui/src/renderer/components/LocationList.tsx b/gui/src/renderer/components/LocationList.tsx
deleted file mode 100644
index 19c3eca0a5..0000000000
--- a/gui/src/renderer/components/LocationList.tsx
+++ /dev/null
@@ -1,635 +0,0 @@
-import * as React from 'react';
-import { sprintf } from 'sprintf-js';
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import {
- compareRelayLocation,
- compareRelayLocationLoose,
- RelayLocation,
- relayLocationComponents,
-} from '../../shared/daemon-rpc-types';
-import { messages, relayLocations } from '../../shared/gettext';
-import {
- IRelayLocationCityRedux,
- IRelayLocationRedux,
- IRelayLocationRelayRedux,
-} from '../redux/settings/reducers';
-import * as Cell from './cell';
-import InfoButton from './InfoButton';
-import LocationRow, {
- StyledLocationRowButton,
- StyledLocationRowContainer,
- StyledLocationRowIcon,
- StyledLocationRowLabel,
-} from './LocationRow';
-
-export enum LocationSelectionType {
- relay = 'relay',
- special = 'special',
-}
-
-export type LocationSelection<SpecialValueType> =
- | { type: LocationSelectionType.special; value: SpecialValueType }
- | { type: LocationSelectionType.relay; value: RelayLocation };
-
-interface ILocationListState<SpecialValueType> {
- selectedValue?: LocationSelection<SpecialValueType>;
- expandedLocations: RelayLocation[];
-}
-
-interface ILocationListProps<SpecialValueType> {
- defaultExpandedLocations?: RelayLocation[];
- selectedValue?: LocationSelection<SpecialValueType>;
- selectedElementRef?: React.Ref<React.ReactInstance>;
- onSelect?: (value: LocationSelection<SpecialValueType>) => void;
- children?: React.ReactNode;
-}
-
-export default class LocationList<SpecialValueType> extends React.Component<
- ILocationListProps<SpecialValueType>,
- ILocationListState<SpecialValueType>
-> {
- public state: ILocationListState<SpecialValueType> = {
- expandedLocations: [],
- };
-
- public selectedRelayLocationRef: React.ReactInstance | null = null;
- public selectedSpecialLocationRef: React.ReactInstance | null = null;
-
- constructor(props: ILocationListProps<SpecialValueType>) {
- super(props);
-
- if (props.selectedValue) {
- const expandedLocations =
- props.defaultExpandedLocations ||
- (props.selectedValue.type === LocationSelectionType.relay
- ? expandRelayLocation(props.selectedValue.value)
- : []);
-
- this.state = {
- selectedValue: props.selectedValue,
- expandedLocations,
- };
- }
- }
-
- public getExpandedLocations(): RelayLocation[] {
- return this.state.expandedLocations;
- }
-
- public componentDidUpdate(prevProps: ILocationListProps<SpecialValueType>) {
- if (!compareLocationSelectionLoose(prevProps.selectedValue, this.props.selectedValue)) {
- this.setState({ selectedValue: this.props.selectedValue });
- }
- }
-
- public render() {
- const selection = this.state.selectedValue;
- const specialSelection =
- selection && selection.type === LocationSelectionType.special ? selection.value : undefined;
- const relaySelection =
- selection && selection.type === LocationSelectionType.relay ? selection.value : undefined;
-
- return (
- <>
- {React.Children.map(this.props.children, (child) => {
- if (React.isValidElement(child)) {
- if (child.type === SpecialLocations) {
- return React.cloneElement(child, {
- ...child.props,
- selectedElementRef: this.onSpecialLocationRef,
- selectedValue: specialSelection,
- onSelect: this.onSelectSpecialLocation,
- });
- } else if (child.type === RelayLocations) {
- return React.cloneElement(child, {
- ...child.props,
- selectedLocation: relaySelection,
- selectedElementRef: this.onRelayLocationRef,
- expandedItems: this.state.expandedLocations,
- onSelect: this.onSelectRelayLocation,
- onExpand: this.onExpandRelayLocation,
- });
- }
- }
- return child;
- })}
- </>
- );
- }
-
- private onSpecialLocationRef = (ref: React.ReactInstance | null) => {
- this.selectedSpecialLocationRef = ref;
-
- this.updateExternalRef();
- };
-
- private onRelayLocationRef = (ref: React.ReactInstance | null) => {
- this.selectedRelayLocationRef = ref;
-
- this.updateExternalRef();
- };
-
- private updateExternalRef() {
- if (this.props.selectedElementRef) {
- const value = this.selectedRelayLocationRef || this.selectedSpecialLocationRef;
-
- if (typeof this.props.selectedElementRef === 'function') {
- this.props.selectedElementRef(value);
- } else {
- const ref = this.props
- .selectedElementRef as React.MutableRefObject<React.ReactInstance | null>;
- ref.current = value;
- }
- }
- }
-
- private onSelectRelayLocation = (value: RelayLocation) => {
- const selectedValue: LocationSelection<SpecialValueType> = {
- type: LocationSelectionType.relay,
- value,
- };
-
- this.setState({ selectedValue }, () => {
- this.notifySelection(selectedValue);
- });
- };
-
- private onSelectSpecialLocation = (value: SpecialValueType) => {
- const selectedValue: LocationSelection<SpecialValueType> = {
- type: LocationSelectionType.special,
- value,
- };
-
- this.setState({ selectedValue }, () => {
- this.notifySelection(selectedValue);
- });
- };
-
- private notifySelection(value: LocationSelection<SpecialValueType>) {
- if (this.props.onSelect) {
- this.props.onSelect(value);
- }
- }
-
- private onExpandRelayLocation = (location: RelayLocation, expand: boolean) => {
- this.setState((state) => {
- const expandedLocations = state.expandedLocations.filter(
- (item) => !compareRelayLocation(item, location),
- );
-
- if (expand) {
- expandedLocations.push(location);
- }
-
- return {
- ...state,
- expandedLocations,
- };
- });
- };
-}
-
-export enum SpecialLocationIcon {
- geoLocation = 'icon-nearest',
-}
-
-interface ISpecialLocationsProps<T> {
- children: React.ReactNode;
- selectedValue?: T;
- selectedElementRef?: React.Ref<SpecialLocation<T>>;
- onSelect?: (value: T) => void;
-}
-
-export function SpecialLocations<T>(props: ISpecialLocationsProps<T>) {
- return (
- <>
- {React.Children.map(props.children, (child) => {
- if (React.isValidElement(child) && child.type === SpecialLocation) {
- const isSelected = props.selectedValue === child.props.value;
-
- return React.cloneElement(child, {
- ...child.props,
- forwardedRef: isSelected ? props.selectedElementRef : undefined,
- onSelect: props.onSelect,
- isSelected,
- });
- } else {
- return undefined;
- }
- })}
- </>
- );
-}
-
-const StyledLocationRowContainerWithMargin = styled(StyledLocationRowContainer)({
- marginBottom: 1,
-});
-
-const StyledSpecialLocationIcon = styled(Cell.Icon)({
- flex: 0,
- marginLeft: '2px',
- marginRight: '8px',
-});
-
-const StyledSpecialLocationInfoButton = styled(InfoButton)({
- margin: 0,
- padding: '0 25px',
-});
-
-interface ISpecialLocationProps<T> {
- icon: SpecialLocationIcon;
- value: T;
- isSelected?: boolean;
- onSelect?: (value: T) => void;
- info?: string;
- forwardedRef?: React.Ref<HTMLButtonElement>;
- children?: React.ReactNode;
-}
-
-export class SpecialLocation<T> extends React.Component<ISpecialLocationProps<T>> {
- public render() {
- return (
- <StyledLocationRowContainerWithMargin>
- <StyledLocationRowButton onClick={this.onSelect} selected={this.props.isSelected ?? false}>
- <StyledSpecialLocationIcon
- source={this.props.isSelected ? 'icon-tick' : this.props.icon}
- tintColor={colors.white}
- height={22}
- width={22}
- />
- <StyledLocationRowLabel>{this.props.children}</StyledLocationRowLabel>
- </StyledLocationRowButton>
- <StyledLocationRowIcon
- as={StyledSpecialLocationInfoButton}
- message={this.props.info}
- selected={this.props.isSelected ?? false}
- aria-label={messages.pgettext('accessibility', 'info')}
- />
- </StyledLocationRowContainerWithMargin>
- );
- }
-
- private onSelect = () => {
- if (!this.props.isSelected && this.props.onSelect) {
- this.props.onSelect(this.props.value);
- }
- };
-}
-
-export enum DisabledReason {
- entry,
- exit,
- inactive,
-}
-
-interface IRelayLocationsProps {
- source: IRelayLocationRedux[];
- locale: string;
- selectedLocation?: RelayLocation;
- selectedElementRef?: React.Ref<React.ReactInstance>;
- expandedItems?: RelayLocation[];
- disabledLocation?: { location: RelayLocation; reason: DisabledReason };
- onSelect?: (location: RelayLocation) => void;
- onExpand?: (location: RelayLocation, expand: boolean) => void;
- onWillExpand?: (locationRect: DOMRect, expandedContentHeight: number) => void;
- onTransitionEnd?: () => void;
-}
-
-interface Relay extends IRelayLocationRelayRedux {
- label: string;
- disabled: boolean;
-}
-
-interface City extends Omit<IRelayLocationCityRedux, 'relays'> {
- label: string;
- active: boolean;
- disabled: boolean;
- relays: Array<Relay>;
-}
-
-interface Country extends Omit<IRelayLocationRedux, 'cities'> {
- label: string;
- active: boolean;
- disabled: boolean;
- cities: Array<City>;
-}
-
-type CountryList = Array<Country>;
-
-interface IRelayLocationsState {
- countries: CountryList;
-}
-
-interface ICommonCellProps {
- location: RelayLocation;
- selected: boolean;
- ref?: React.Ref<HTMLDivElement>;
-}
-
-export class RelayLocations extends React.PureComponent<
- IRelayLocationsProps,
- IRelayLocationsState
-> {
- public state = {
- countries: this.prepareRelaysForPresentation(this.props.source),
- };
-
- public componentDidUpdate(prevProps: IRelayLocationsProps) {
- if (this.props.source !== prevProps.source) {
- this.setState({ countries: this.prepareRelaysForPresentation(this.props.source) });
- }
- }
-
- public render() {
- return (
- <Cell.Group noMarginBottom>
- {this.state.countries.map((relayCountry) => {
- const countryLocation: RelayLocation = { country: relayCountry.code };
-
- return (
- <LocationRow
- key={getLocationKey(countryLocation)}
- name={relayCountry.label}
- active={relayCountry.active}
- disabled={relayCountry.disabled}
- expanded={this.isExpanded(countryLocation)}
- onSelect={this.handleSelection}
- onExpand={this.handleExpand}
- onWillExpand={this.props.onWillExpand}
- onTransitionEnd={this.props.onTransitionEnd}
- {...this.getCommonCellProps(countryLocation)}>
- {relayCountry.cities.map((relayCity) => {
- const cityLocation: RelayLocation = {
- city: [relayCountry.code, relayCity.code],
- };
-
- return (
- <LocationRow
- key={getLocationKey(cityLocation)}
- name={relayCity.label}
- active={relayCity.active}
- disabled={relayCity.disabled}
- expanded={this.isExpanded(cityLocation)}
- onSelect={this.handleSelection}
- onExpand={this.handleExpand}
- onWillExpand={this.props.onWillExpand}
- onTransitionEnd={this.props.onTransitionEnd}
- {...this.getCommonCellProps(cityLocation)}>
- {relayCity.relays.map((relay) => {
- const relayLocation: RelayLocation = {
- hostname: [relayCountry.code, relayCity.code, relay.hostname],
- };
-
- return (
- <LocationRow
- key={getLocationKey(relayLocation)}
- name={relay.label}
- active={relay.active}
- disabled={relay.disabled}
- onSelect={this.handleSelection}
- {...this.getCommonCellProps(relayLocation)}
- />
- );
- })}
- </LocationRow>
- );
- })}
- </LocationRow>
- );
- })}
- </Cell.Group>
- );
- }
-
- private prepareRelaysForPresentation(relayList: IRelayLocationRedux[]): CountryList {
- return relayList
- .map((country) => {
- const countryDisabled = this.isCountryDisabled(country, country.code);
- const countryLocation = { country: country.code };
-
- return {
- ...country,
- label: this.formatRowName(country.name, countryLocation, countryDisabled),
- active: countryDisabled !== DisabledReason.inactive,
- disabled: countryDisabled !== undefined,
- cities: country.cities
- .map((city) => {
- const cityDisabled =
- countryDisabled ?? this.isCityDisabled(city, [country.code, city.code]);
- const cityLocation: RelayLocation = { city: [country.code, city.code] };
-
- return {
- ...city,
- label: this.formatRowName(city.name, cityLocation, cityDisabled),
- active: cityDisabled !== DisabledReason.inactive,
- disabled: cityDisabled !== undefined,
- relays: city.relays
- .map((relay) => {
- const relayDisabled =
- countryDisabled ??
- cityDisabled ??
- this.isRelayDisabled(relay, [country.code, city.code, relay.hostname]);
- const relayLocation: RelayLocation = {
- hostname: [country.code, city.code, relay.hostname],
- };
-
- return {
- ...relay,
- label: this.formatRowName(relay.hostname, relayLocation, relayDisabled),
- disabled: relayDisabled !== undefined,
- };
- })
- .sort((a, b) =>
- a.hostname.localeCompare(b.hostname, this.props.locale, { numeric: true }),
- ),
- };
- })
- .sort((a, b) => a.label.localeCompare(b.label, this.props.locale)),
- };
- })
- .sort((a, b) => a.label.localeCompare(b.label, this.props.locale));
- }
-
- private formatRowName(
- name: string,
- location: RelayLocation,
- disabledReason?: DisabledReason,
- ): string {
- const translatedName = 'hostname' in location ? name : relayLocations.gettext(name);
- const disabledLocation = this.props.disabledLocation;
- const matchDisabledLocation = compareRelayLocationLoose(location, disabledLocation?.location);
-
- let info: string | undefined;
- if (
- disabledReason === DisabledReason.entry ||
- (matchDisabledLocation && disabledLocation?.reason === DisabledReason.entry)
- ) {
- info = messages.pgettext('select-location-view', 'Entry');
- } else if (
- disabledReason === DisabledReason.exit ||
- (matchDisabledLocation && disabledLocation?.reason === DisabledReason.exit)
- ) {
- info = messages.pgettext('select-location-view', 'Exit');
- }
-
- return info !== undefined
- ? sprintf(
- // TRANSLATORS: This is used for appending information about a location.
- // TRANSLATORS: E.g. "Gothenburg (Entry)" if Gothenburg has been selected as the entrypoint.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(location)s - Translated location name
- // TRANSLATORS: %(info)s - Information about the location
- messages.pgettext('select-location-view', '%(location)s (%(info)s)'),
- {
- location: translatedName,
- info,
- },
- )
- : translatedName;
- }
-
- private isRelayDisabled(
- relay: IRelayLocationRelayRedux,
- location: [string, string, string],
- ): DisabledReason | undefined {
- if (!relay.active) {
- return DisabledReason.inactive;
- } else if (
- this.props.disabledLocation &&
- compareRelayLocation({ hostname: location }, this.props.disabledLocation.location)
- ) {
- return this.props.disabledLocation.reason;
- } else {
- return undefined;
- }
- }
-
- private isCityDisabled(
- city: IRelayLocationCityRedux,
- location: [string, string],
- ): DisabledReason | undefined {
- const relaysDisabled = city.relays.map((relay) =>
- this.isRelayDisabled(relay, [...location, relay.hostname]),
- );
- if (relaysDisabled.every((status) => status === DisabledReason.inactive)) {
- return DisabledReason.inactive;
- }
-
- const disabledDueToSelection = relaysDisabled.find(
- (status) => status === DisabledReason.entry || status === DisabledReason.exit,
- );
-
- if (
- relaysDisabled.every((status) => status !== undefined) &&
- disabledDueToSelection !== undefined
- ) {
- return disabledDueToSelection;
- }
-
- if (
- this.props.disabledLocation &&
- compareRelayLocation({ city: location }, this.props.disabledLocation.location) &&
- city.relays.filter((relay) => relay.active).length <= 1
- ) {
- return this.props.disabledLocation.reason;
- }
-
- return undefined;
- }
-
- private isCountryDisabled(
- country: IRelayLocationRedux,
- location: string,
- ): DisabledReason | undefined {
- const citiesDisabled = country.cities.map((city) =>
- this.isCityDisabled(city, [location, city.code]),
- );
- if (citiesDisabled.every((status) => status === DisabledReason.inactive)) {
- return DisabledReason.inactive;
- }
-
- const disabledDueToSelection = citiesDisabled.find(
- (status) => status === DisabledReason.entry || status === DisabledReason.exit,
- );
- if (
- citiesDisabled.every((status) => status !== undefined) &&
- disabledDueToSelection !== undefined
- ) {
- return disabledDueToSelection;
- }
-
- if (
- this.props.disabledLocation &&
- compareRelayLocation({ country: location }, this.props.disabledLocation.location) &&
- country.cities.flatMap((city) => city.relays).filter((relay) => relay.active).length <= 1
- ) {
- return this.props.disabledLocation.reason;
- }
-
- return undefined;
- }
-
- private isExpanded(relayLocation: RelayLocation) {
- return (this.props.expandedItems || []).some((location) =>
- compareRelayLocation(location, relayLocation),
- );
- }
-
- private isSelected(relayLocation: RelayLocation) {
- return compareRelayLocationLoose(this.props.selectedLocation, relayLocation);
- }
-
- private handleSelection = (location: RelayLocation) => {
- if (!compareRelayLocationLoose(this.props.selectedLocation, location)) {
- if (this.props.onSelect) {
- this.props.onSelect(location);
- }
- }
- };
-
- private handleExpand = (location: RelayLocation, expand: boolean) => {
- if (this.props.onExpand) {
- this.props.onExpand(location, expand);
- }
- };
-
- private getCommonCellProps(location: RelayLocation): ICommonCellProps {
- const selected = this.isSelected(location);
- const ref =
- selected && this.props.selectedElementRef ? this.props.selectedElementRef : undefined;
-
- return { ref: ref as React.Ref<HTMLDivElement>, selected, location };
- }
-}
-
-function expandRelayLocation(location: RelayLocation): RelayLocation[] {
- const expandedItems: RelayLocation[] = [];
-
- if ('city' in location) {
- expandedItems.push({ country: location.city[0] });
- } else if ('hostname' in location) {
- expandedItems.push({ country: location.hostname[0] });
- expandedItems.push({ city: [location.hostname[0], location.hostname[1]] });
- }
-
- return expandedItems;
-}
-
-function getLocationKey(location: RelayLocation): string {
- return relayLocationComponents(location).join('-');
-}
-
-function compareLocationSelectionLoose<SpecialValueType>(
- lhs?: LocationSelection<SpecialValueType>,
- rhs?: LocationSelection<SpecialValueType>,
-) {
- if (!lhs || !rhs) {
- return lhs === rhs;
- } else if (lhs.type === LocationSelectionType.relay && rhs.type === LocationSelectionType.relay) {
- return compareRelayLocation(lhs.value, rhs.value);
- } else {
- return lhs.value === rhs.value;
- }
-}
diff --git a/gui/src/renderer/components/BridgeLocations.tsx b/gui/src/renderer/components/select-location/BridgeLocations.tsx
index 355b462222..4ab227de7f 100644
--- a/gui/src/renderer/components/BridgeLocations.tsx
+++ b/gui/src/renderer/components/select-location/BridgeLocations.tsx
@@ -1,16 +1,12 @@
import * as React from 'react';
-import { LiftedConstraint, RelayLocation } from '../../shared/daemon-rpc-types';
-import { messages } from '../../shared/gettext';
-import { IRelayLocationRedux } from '../redux/settings/reducers';
-import LocationList, {
- LocationSelection,
- LocationSelectionType,
- RelayLocations,
- SpecialLocation,
- SpecialLocationIcon,
- SpecialLocations,
-} from './LocationList';
+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,
@@ -18,6 +14,7 @@ export enum SpecialBridgeLocationType {
interface IBridgeLocationsProps {
source: IRelayLocationRedux[];
+ filter: string;
locale: string;
defaultExpandedLocations?: RelayLocation[];
selectedValue?: LiftedConstraint<RelayLocation>;
@@ -46,19 +43,22 @@ const BridgeLocations = React.forwardRef(function BridgeLocationsT(
selectedValue={selectedValue}
selectedElementRef={props.selectedElementRef}
onSelect={props.onSelect}>
- <SpecialLocations>
- <SpecialLocation
- icon={SpecialLocationIcon.geoLocation}
- value={SpecialBridgeLocationType.closestToExit}
- info={messages.pgettext(
- 'select-location-view',
- 'The app selects a random bridge server, but servers have a higher probability the closer they are to you.',
- )}>
- {messages.gettext('Automatic')}
- </SpecialLocation>
- </SpecialLocations>
+ {!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}
diff --git a/gui/src/renderer/components/select-location/LocationList.tsx b/gui/src/renderer/components/select-location/LocationList.tsx
new file mode 100644
index 0000000000..7ba3b76740
--- /dev/null
+++ b/gui/src/renderer/components/select-location/LocationList.tsx
@@ -0,0 +1,198 @@
+import * as React from 'react';
+
+import { compareRelayLocation, RelayLocation } from '../../../shared/daemon-rpc-types';
+import { RelayLocations } from './RelayLocations';
+import { SpecialLocations } from './SpecialLocations';
+
+export enum LocationSelectionType {
+ relay = 'relay',
+ special = 'special',
+}
+
+export type LocationSelection<SpecialValueType> =
+ | { type: LocationSelectionType.special; value: SpecialValueType }
+ | { type: LocationSelectionType.relay; value: RelayLocation };
+
+interface ILocationListState<SpecialValueType> {
+ selectedValue?: LocationSelection<SpecialValueType>;
+ expandedLocations: RelayLocation[];
+}
+
+interface ILocationListProps<SpecialValueType> {
+ defaultExpandedLocations?: RelayLocation[];
+ selectedValue?: LocationSelection<SpecialValueType>;
+ selectedElementRef?: React.Ref<React.ReactInstance>;
+ onSelect?: (value: LocationSelection<SpecialValueType>) => void;
+ children?: React.ReactNode;
+}
+
+export default class LocationList<SpecialValueType> extends React.Component<
+ ILocationListProps<SpecialValueType>,
+ ILocationListState<SpecialValueType>
+> {
+ public state: ILocationListState<SpecialValueType> = {
+ expandedLocations: [],
+ };
+
+ public selectedRelayLocationRef: React.ReactInstance | null = null;
+ public selectedSpecialLocationRef: React.ReactInstance | null = null;
+
+ constructor(props: ILocationListProps<SpecialValueType>) {
+ super(props);
+
+ if (props.selectedValue) {
+ const expandedLocations =
+ props.defaultExpandedLocations ||
+ (props.selectedValue.type === LocationSelectionType.relay
+ ? expandRelayLocation(props.selectedValue.value)
+ : []);
+
+ this.state = {
+ selectedValue: props.selectedValue,
+ expandedLocations,
+ };
+ }
+ }
+
+ public getExpandedLocations(): RelayLocation[] {
+ return this.state.expandedLocations;
+ }
+
+ public componentDidUpdate(prevProps: ILocationListProps<SpecialValueType>) {
+ if (!compareLocationSelectionLoose(prevProps.selectedValue, this.props.selectedValue)) {
+ this.setState({ selectedValue: this.props.selectedValue });
+ }
+ }
+
+ public render() {
+ const selection = this.state.selectedValue;
+ const specialSelection =
+ selection && selection.type === LocationSelectionType.special ? selection.value : undefined;
+ const relaySelection =
+ selection && selection.type === LocationSelectionType.relay ? selection.value : undefined;
+
+ return (
+ <>
+ {React.Children.map(this.props.children, (child) => {
+ if (React.isValidElement(child)) {
+ if (child.type === SpecialLocations) {
+ return React.cloneElement(child, {
+ ...child.props,
+ selectedElementRef: this.onSpecialLocationRef,
+ selectedValue: specialSelection,
+ onSelect: this.onSelectSpecialLocation,
+ });
+ } else if (child.type === RelayLocations) {
+ return React.cloneElement(child, {
+ ...child.props,
+ selectedLocation: relaySelection,
+ selectedElementRef: this.onRelayLocationRef,
+ expandedItems: this.state.expandedLocations,
+ onSelect: this.onSelectRelayLocation,
+ onExpand: this.onExpandRelayLocation,
+ });
+ }
+ }
+ return child;
+ })}
+ </>
+ );
+ }
+
+ private onSpecialLocationRef = (ref: React.ReactInstance | null) => {
+ this.selectedSpecialLocationRef = ref;
+
+ this.updateExternalRef();
+ };
+
+ private onRelayLocationRef = (ref: React.ReactInstance | null) => {
+ this.selectedRelayLocationRef = ref;
+
+ this.updateExternalRef();
+ };
+
+ private updateExternalRef() {
+ if (this.props.selectedElementRef) {
+ const value = this.selectedRelayLocationRef || this.selectedSpecialLocationRef;
+
+ if (typeof this.props.selectedElementRef === 'function') {
+ this.props.selectedElementRef(value);
+ } else {
+ const ref = this.props
+ .selectedElementRef as React.MutableRefObject<React.ReactInstance | null>;
+ ref.current = value;
+ }
+ }
+ }
+
+ private onSelectRelayLocation = (value: RelayLocation) => {
+ const selectedValue: LocationSelection<SpecialValueType> = {
+ type: LocationSelectionType.relay,
+ value,
+ };
+
+ this.setState({ selectedValue }, () => {
+ this.notifySelection(selectedValue);
+ });
+ };
+
+ private onSelectSpecialLocation = (value: SpecialValueType) => {
+ const selectedValue: LocationSelection<SpecialValueType> = {
+ type: LocationSelectionType.special,
+ value,
+ };
+
+ this.setState({ selectedValue }, () => {
+ this.notifySelection(selectedValue);
+ });
+ };
+
+ private notifySelection(value: LocationSelection<SpecialValueType>) {
+ if (this.props.onSelect) {
+ this.props.onSelect(value);
+ }
+ }
+
+ private onExpandRelayLocation = (location: RelayLocation, expand: boolean) => {
+ this.setState((state) => {
+ const expandedLocations = state.expandedLocations.filter(
+ (item) => !compareRelayLocation(item, location),
+ );
+
+ if (expand) {
+ expandedLocations.push(location);
+ }
+
+ return {
+ ...state,
+ expandedLocations,
+ };
+ });
+ };
+}
+
+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;
+ }
+}
diff --git a/gui/src/renderer/components/LocationRow.tsx b/gui/src/renderer/components/select-location/LocationRow.tsx
index 9172f8bcd3..10a55a17ce 100644
--- a/gui/src/renderer/components/LocationRow.tsx
+++ b/gui/src/renderer/components/select-location/LocationRow.tsx
@@ -2,14 +2,14 @@ import React, { useCallback, useRef } from 'react';
import { sprintf } from 'sprintf-js';
import styled from 'styled-components';
-import { colors } from '../../config.json';
-import { compareRelayLocation, RelayLocation } from '../../shared/daemon-rpc-types';
-import { messages } from '../../shared/gettext';
-import Accordion from './Accordion';
-import * as Cell from './cell';
-import ChevronButton from './ChevronButton';
-import { measurements, normalText } from './common-styles';
-import RelayStatusIndicator from './RelayStatusIndicator';
+import { colors } from '../../../config.json';
+import { compareRelayLocation, RelayLocation } from '../../../shared/daemon-rpc-types';
+import { messages } from '../../../shared/gettext';
+import Accordion from '../Accordion';
+import * as Cell from '../cell';
+import ChevronButton from '../ChevronButton';
+import { measurements, normalText } from '../common-styles';
+import RelayStatusIndicator from '../RelayStatusIndicator';
interface IButtonColorProps {
selected: boolean;
@@ -99,6 +99,7 @@ interface IProps {
location: RelayLocation;
selected: boolean;
expanded?: boolean;
+ expandable: boolean;
onSelect?: (location: RelayLocation) => void;
onExpand?: (location: RelayLocation, value: boolean) => void;
onWillExpand?: (locationRect: DOMRect, expandedContentHeight: number) => void;
@@ -107,7 +108,7 @@ interface IProps {
}
function LocationRow(props: IProps, ref: React.Ref<HTMLDivElement>) {
- const hasChildren = props.children !== undefined;
+ const hasChildren = React.Children.count(props.children) > 0;
const buttonRef = useRef<HTMLButtonElement>() as React.RefObject<HTMLButtonElement>;
const toggleCollapse = useCallback(() => {
@@ -142,7 +143,7 @@ function LocationRow(props: IProps, ref: React.Ref<HTMLDivElement>) {
<RelayStatusIndicator active={props.active} selected={props.selected} />
<StyledLocationRowLabel>{props.name}</StyledLocationRowLabel>
</StyledLocationRowButton>
- {hasChildren ? (
+ {hasChildren && props.expandable ? (
<StyledLocationRowIcon
as={ChevronButton}
onClick={toggleCollapse}
@@ -163,7 +164,7 @@ function LocationRow(props: IProps, ref: React.Ref<HTMLDivElement>) {
{hasChildren && (
<Accordion
expanded={props.expanded}
- onWillExpand={onWillExpand}
+ onWillExpand={props.expandable ? onWillExpand : undefined}
onTransitionEnd={props.onTransitionEnd}
animationDuration={150}>
<Cell.Group noMarginBottom>{props.children}</Cell.Group>
diff --git a/gui/src/renderer/components/Locations.tsx b/gui/src/renderer/components/select-location/Locations.tsx
index 4f7fadfb5d..acfe21a8f6 100644
--- a/gui/src/renderer/components/Locations.tsx
+++ b/gui/src/renderer/components/select-location/Locations.tsx
@@ -1,16 +1,13 @@
-import * as React from 'react';
+import React from 'react';
-import { RelayLocation } from '../../shared/daemon-rpc-types';
-import { IRelayLocationRedux } from '../redux/settings/reducers';
-import LocationList, {
- DisabledReason,
- LocationSelection,
- LocationSelectionType,
- RelayLocations,
-} from './LocationList';
+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;
@@ -35,6 +32,7 @@ function Locations(props: ILocationsProps, ref: React.Ref<LocationList<never>>)
onSelect={props.onSelect}>
<RelayLocations
source={props.source}
+ filter={props.filter}
locale={props.locale}
disabledLocation={props.disabledLocation}
onWillExpand={props.onWillExpand}
diff --git a/gui/src/renderer/components/select-location/RelayLocations.tsx b/gui/src/renderer/components/select-location/RelayLocations.tsx
new file mode 100644
index 0000000000..120c7c548c
--- /dev/null
+++ b/gui/src/renderer/components/select-location/RelayLocations.tsx
@@ -0,0 +1,365 @@
+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/SelectLocation.tsx b/gui/src/renderer/components/select-location/SelectLocation.tsx
index 4c228c9a53..0af4a0d8d5 100644
--- a/gui/src/renderer/components/SelectLocation.tsx
+++ b/gui/src/renderer/components/select-location/SelectLocation.tsx
@@ -1,34 +1,32 @@
import React from 'react';
import { sprintf } from 'sprintf-js';
-import { colors } from '../../config.json';
+import { colors } from '../../../config.json';
import {
LiftedConstraint,
Ownership,
RelayLocation,
TunnelProtocol,
-} from '../../shared/daemon-rpc-types';
-import { messages } from '../../shared/gettext';
-import { IRelayLocationRedux } from '../redux/settings/reducers';
-import BridgeLocations, { SpecialBridgeLocationType } from './BridgeLocations';
-import { CustomScrollbarsRef } from './CustomScrollbars';
-import ImageView from './ImageView';
-import { BackAction } from './KeyboardNavigation';
-import { Layout, SettingsContainer } from './Layout';
-import LocationList, {
- DisabledReason,
- LocationSelection,
- LocationSelectionType,
-} from './LocationList';
-import { EntryLocations, ExitLocations } from './Locations';
+} from '../../../shared/daemon-rpc-types';
+import { messages } from '../../../shared/gettext';
+import { IRelayLocationRedux } from '../../redux/settings/reducers';
+import { CustomScrollbarsRef } from '../CustomScrollbars';
+import ImageView from '../ImageView';
+import { BackAction } from '../KeyboardNavigation';
+import { Layout, SettingsContainer } from '../Layout';
import {
NavigationBar,
NavigationContainer,
NavigationItems,
NavigationScrollbars,
TitleBarItem,
-} from './NavigationBar';
-import { ScopeBarItem } from './ScopeBar';
+} 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 {
StyledClearFilterButton,
StyledContent,
@@ -37,9 +35,10 @@ import {
StyledFilterRow,
StyledNavigationBarAttachment,
StyledScopeBar,
+ StyledSearchBar,
StyledSettingsHeader,
} from './SelectLocationStyles';
-import { HeaderSubTitle, HeaderTitle } from './SettingsHeader';
+import { SpacePreAllocationView } from './SpacePreAllocationView';
interface IProps {
locale: string;
@@ -70,6 +69,7 @@ enum LocationScope {
interface IState {
headingHeight: number;
locationScope: LocationScope;
+ filter: string;
}
interface ISelectLocationSnapshot {
@@ -78,7 +78,7 @@ interface ISelectLocationSnapshot {
}
export default class SelectLocation extends React.Component<IProps, IState> {
- public state = { headingHeight: 0, locationScope: LocationScope.exit };
+ public state = { headingHeight: 0, locationScope: LocationScope.exit, filter: '' };
private scrollView = React.createRef<CustomScrollbarsRef>();
private spacePreAllocationViewRef = React.createRef<SpacePreAllocationView>();
@@ -173,9 +173,23 @@ export default class SelectLocation extends React.Component<IProps, IState> {
messages.pgettext('select-location-view', 'Select location')
}
</HeaderTitle>
- {this.renderHeaderSubtitle()}
</StyledSettingsHeader>
+ {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>
+ )}
+
+ {this.renderHeaderSubtitle()}
+
{showFilters && (
<StyledFilterRow>
{messages.pgettext('select-location-view', 'Filtered:')}
@@ -223,18 +237,8 @@ export default class SelectLocation extends React.Component<IProps, IState> {
)}
</StyledFilterRow>
)}
- {this.props.allowEntrySelection && (
- <StyledScopeBar
- defaultSelectedIndex={this.state.locationScope}
- onChange={this.onChangeLocationScope}>
- <ScopeBarItem>
- {messages.pgettext('select-location-view', 'Entry')}
- </ScopeBarItem>
- <ScopeBarItem>
- {messages.pgettext('select-location-view', 'Exit')}
- </ScopeBarItem>
- </StyledScopeBar>
- )}
+
+ <StyledSearchBar searchTerm={this.state.filter} onSearch={this.updateFilter} />
</StyledNavigationBarAttachment>
<StyledContent>{this.renderLocationList()}</StyledContent>
@@ -325,6 +329,7 @@ export default class SelectLocation extends React.Component<IProps, IState> {
return (
<ExitLocations
ref={this.exitLocationList}
+ filter={this.state.filter}
source={this.props.relayLocations}
locale={this.props.locale}
defaultExpandedLocations={this.getExpandedLocationsFromSnapshot()}
@@ -346,6 +351,7 @@ export default class SelectLocation extends React.Component<IProps, IState> {
return (
<EntryLocations
ref={this.entryLocationList}
+ filter={this.state.filter}
source={this.props.relayLocations}
locale={this.props.locale}
defaultExpandedLocations={this.getExpandedLocationsFromSnapshot()}
@@ -361,6 +367,7 @@ export default class SelectLocation extends React.Component<IProps, IState> {
return (
<BridgeLocations
ref={this.bridgeLocationList}
+ filter={this.state.filter}
source={this.props.bridgeLocations}
locale={this.props.locale}
defaultExpandedLocations={this.getExpandedLocationsFromSnapshot()}
@@ -439,33 +446,8 @@ export default class SelectLocation extends React.Component<IProps, IState> {
this.spacePreAllocationViewRef.current?.allocate(expandedContentHeight);
this.scrollView.current?.scrollIntoView(locationRect);
};
-}
-
-interface ISpacePreAllocationView {
- children?: React.ReactNode;
-}
-
-class SpacePreAllocationView extends React.Component<ISpacePreAllocationView> {
- private ref = React.createRef<HTMLDivElement>();
-
- public allocate(height: number) {
- if (this.ref.current) {
- this.minHeight = this.ref.current.offsetHeight + height + 'px';
- }
- }
- public reset = () => {
- this.minHeight = 'auto';
+ private updateFilter = (filter: string) => {
+ this.setState({ filter });
};
-
- public render() {
- return <div ref={this.ref}>{this.props.children}</div>;
- }
-
- private set minHeight(value: string) {
- const element = this.ref.current;
- if (element) {
- element.style.minHeight = value;
- }
- }
}
diff --git a/gui/src/renderer/components/SelectLocationStyles.tsx b/gui/src/renderer/components/select-location/SelectLocationStyles.tsx
index d4a0450c7c..00c8d02d97 100644
--- a/gui/src/renderer/components/SelectLocationStyles.tsx
+++ b/gui/src/renderer/components/select-location/SelectLocationStyles.tsx
@@ -1,9 +1,10 @@
import styled from 'styled-components';
-import { colors } from '../../config.json';
-import { tinyText } from './common-styles';
-import { ScopeBar } from './ScopeBar';
-import SettingsHeader from './SettingsHeader';
+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',
@@ -64,3 +65,7 @@ export const StyledClearFilterButton = styled.div({
cursor: 'default',
backgroundColor: 'transparent',
});
+
+export const StyledSearchBar = styled(SearchBar)({
+ marginBottom: '14px',
+});
diff --git a/gui/src/renderer/components/select-location/SpacePreAllocationView.tsx b/gui/src/renderer/components/select-location/SpacePreAllocationView.tsx
new file mode 100644
index 0000000000..4b493aeed1
--- /dev/null
+++ b/gui/src/renderer/components/select-location/SpacePreAllocationView.tsx
@@ -0,0 +1,30 @@
+import React from 'react';
+
+interface ISpacePreAllocationView {
+ children?: React.ReactNode;
+}
+
+export class SpacePreAllocationView extends React.Component<ISpacePreAllocationView> {
+ private ref = React.createRef<HTMLDivElement>();
+
+ public allocate(height: number) {
+ if (this.ref.current) {
+ this.minHeight = this.ref.current.offsetHeight + height + 'px';
+ }
+ }
+
+ public reset = () => {
+ this.minHeight = 'auto';
+ };
+
+ public render() {
+ return <div ref={this.ref}>{this.props.children}</div>;
+ }
+
+ private set minHeight(value: string) {
+ const element = this.ref.current;
+ if (element) {
+ element.style.minHeight = value;
+ }
+ }
+}
diff --git a/gui/src/renderer/components/select-location/SpecialLocation.tsx b/gui/src/renderer/components/select-location/SpecialLocation.tsx
new file mode 100644
index 0000000000..06d3a8b408
--- /dev/null
+++ b/gui/src/renderer/components/select-location/SpecialLocation.tsx
@@ -0,0 +1,72 @@
+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/SpecialLocations.tsx b/gui/src/renderer/components/select-location/SpecialLocations.tsx
new file mode 100644
index 0000000000..fb65f9c6ae
--- /dev/null
+++ b/gui/src/renderer/components/select-location/SpecialLocations.tsx
@@ -0,0 +1,31 @@
+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/types.ts b/gui/src/renderer/components/select-location/types.ts
new file mode 100644
index 0000000000..dd0d563401
--- /dev/null
+++ b/gui/src/renderer/components/select-location/types.ts
@@ -0,0 +1,30 @@
+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
index 1c3f3ff32a..d459696ed4 100644
--- a/gui/src/renderer/containers/SelectLocationPage.tsx
+++ b/gui/src/renderer/containers/SelectLocationPage.tsx
@@ -4,7 +4,7 @@ import BridgeSettingsBuilder from '../../shared/bridge-settings-builder';
import { LiftedConstraint, Ownership, RelayLocation } from '../../shared/daemon-rpc-types';
import log from '../../shared/logging';
import RelaySettingsBuilder from '../../shared/relay-settings-builder';
-import SelectLocation from '../components/SelectLocation';
+import SelectLocation from '../components/select-location/SelectLocation';
import { useAppContext } from '../context';
import { createWireguardRelayUpdater } from '../lib/constraint-updater';
import filterLocations from '../lib/filter-locations';