summaryrefslogtreecommitdiffhomepage
path: root/gui/src
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2023-10-04 12:24:19 +0200
committerOskar Nyberg <oskar@mullvad.net>2023-10-09 10:16:53 +0200
commit39d32b0bd8cdc904ccce5f9623981848b8850602 (patch)
treed5f25df11278d1ce3ec1bbf77b8a68b03d868fc7 /gui/src
parent14325fe08cd20f9efa70cdace8cd7b066f31a0da (diff)
downloadmullvadvpn-39d32b0bd8cdc904ccce5f9623981848b8850602.tar.xz
mullvadvpn-39d32b0bd8cdc904ccce5f9623981848b8850602.zip
Add custom lists list in select location view
Diffstat (limited to 'gui/src')
-rw-r--r--gui/src/config.json1
-rw-r--r--gui/src/renderer/components/cell/Row.tsx6
-rw-r--r--gui/src/renderer/components/select-location/CombinedLocationList.tsx40
-rw-r--r--gui/src/renderer/components/select-location/CustomLists.tsx229
-rw-r--r--gui/src/renderer/components/select-location/LocationRow.tsx218
-rw-r--r--gui/src/renderer/components/select-location/RelayListContext.tsx79
-rw-r--r--gui/src/renderer/components/select-location/RelayLocationList.tsx29
-rw-r--r--gui/src/renderer/components/select-location/ScrollPositionContext.tsx6
-rw-r--r--gui/src/renderer/components/select-location/SelectLocation.tsx154
-rw-r--r--gui/src/renderer/components/select-location/SelectLocationStyles.tsx6
-rw-r--r--gui/src/renderer/components/select-location/SpecialLocationList.tsx15
-rw-r--r--gui/src/renderer/components/select-location/custom-list-helpers.ts166
-rw-r--r--gui/src/renderer/components/select-location/select-location-helpers.ts40
-rw-r--r--gui/src/renderer/components/select-location/select-location-hooks.ts73
-rw-r--r--gui/src/renderer/components/select-location/select-location-types.ts88
15 files changed, 910 insertions, 240 deletions
diff --git a/gui/src/config.json b/gui/src/config.json
index b5d454fe4a..783be371bc 100644
--- a/gui/src/config.json
+++ b/gui/src/config.json
@@ -21,6 +21,7 @@
"white40": "rgba(255, 255, 255, 0.4)",
"white20": "rgba(255, 255, 255, 0.2)",
"white10": "rgba(255, 255, 255, 0.1)",
+ "blue10": "rgba(41, 77, 115, 0.1)",
"blue20": "rgba(41, 77, 115, 0.2)",
"blue40": "rgba(41, 77, 115, 0.4)",
"blue60": "rgba(41, 77, 115, 0.6)",
diff --git a/gui/src/renderer/components/cell/Row.tsx b/gui/src/renderer/components/cell/Row.tsx
index 60a1ef6f9b..08309d6843 100644
--- a/gui/src/renderer/components/cell/Row.tsx
+++ b/gui/src/renderer/components/cell/Row.tsx
@@ -4,7 +4,7 @@ import { colors } from '../../../config.json';
import { measurements } from '../common-styles';
import { Group } from './Group';
-export const Row = styled.div({
+export const Row = styled.div((props: { includeMarginBottomOnLast?: boolean }) => ({
display: 'flex',
alignItems: 'center',
backgroundColor: colors.blue,
@@ -13,6 +13,6 @@ export const Row = styled.div({
paddingRight: measurements.viewMargin,
marginBottom: '1px',
[`${Group} > &:last-child`]: {
- marginBottom: '0px',
+ marginBottom: props.includeMarginBottomOnLast ? '1px' : '0px',
},
-});
+}));
diff --git a/gui/src/renderer/components/select-location/CombinedLocationList.tsx b/gui/src/renderer/components/select-location/CombinedLocationList.tsx
index d2a13af845..5ef9918b8f 100644
--- a/gui/src/renderer/components/select-location/CombinedLocationList.tsx
+++ b/gui/src/renderer/components/select-location/CombinedLocationList.tsx
@@ -2,19 +2,16 @@ import React from 'react';
import { RelayLocation } from '../../../shared/daemon-rpc-types';
import RelayLocationList from './RelayLocationList';
-import {
- CountrySpecification,
- LocationList,
- LocationSelection,
- LocationSelectionType,
- SpecialLocation,
-} from './select-location-types';
+import { RelayList, SpecialLocation } from './select-location-types';
import SpecialLocationList from './SpecialLocationList';
export interface CombinedLocationListProps<T> {
- source: LocationList<T>;
+ relayLocations: RelayList;
+ specialLocations?: Array<SpecialLocation<T>>;
+ allowAddToCustomList: boolean;
selectedElementRef: React.Ref<HTMLDivElement>;
- onSelect: (value: LocationSelection<T>) => void;
+ onSelectRelay: (value: RelayLocation) => void;
+ onSelectSpecial: (value: T) => void;
onExpand: (location: RelayLocation) => void;
onCollapse: (location: RelayLocation) => void;
onWillExpand: (
@@ -27,25 +24,16 @@ export interface CombinedLocationListProps<T> {
// Renders the special locations and the regular locations as separate lists
export default function CombinedLocationList<T>(props: CombinedLocationListProps<T>) {
- const specialLocations = props.source.filter(isSpecialLocation);
- const relayLocations = props.source.filter(isRelayLocation);
-
return (
<>
- <SpecialLocationList {...props} source={specialLocations} />
- <RelayLocationList {...props} source={relayLocations} />
+ {props.specialLocations !== undefined && props.specialLocations.length > 0 && (
+ <SpecialLocationList
+ {...props}
+ source={props.specialLocations}
+ onSelect={props.onSelectSpecial}
+ />
+ )}
+ <RelayLocationList {...props} source={props.relayLocations} onSelect={props.onSelectRelay} />
</>
);
}
-
-function isSpecialLocation<T>(
- location: CountrySpecification | SpecialLocation<T>,
-): location is SpecialLocation<T> {
- return location.type === LocationSelectionType.special;
-}
-
-function isRelayLocation<T>(
- location: CountrySpecification | SpecialLocation<T>,
-): location is CountrySpecification {
- return location.type === LocationSelectionType.relay;
-}
diff --git a/gui/src/renderer/components/select-location/CustomLists.tsx b/gui/src/renderer/components/select-location/CustomLists.tsx
new file mode 100644
index 0000000000..843be91055
--- /dev/null
+++ b/gui/src/renderer/components/select-location/CustomLists.tsx
@@ -0,0 +1,229 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import styled from 'styled-components';
+
+import { colors } from '../../../config.json';
+import { CustomListError, CustomLists, RelayLocation } from '../../../shared/daemon-rpc-types';
+import { messages } from '../../../shared/gettext';
+import log from '../../../shared/logging';
+import { useAppContext } from '../../context';
+import { useBoolean } from '../../lib/utilityHooks';
+import Accordion from '../Accordion';
+import * as Cell from '../cell';
+import { measurements } from '../common-styles';
+import SimpleInput from '../SimpleInput';
+import { StyledLocationRowIcon } from './LocationRow';
+import { useRelayListContext } from './RelayListContext';
+import RelayLocationList from './RelayLocationList';
+import { useScrollPositionContext } from './ScrollPositionContext';
+import { useSelectLocationContext } from './SelectLocationContainer';
+
+const StyledCellContainer = styled(Cell.Container)({
+ padding: 0,
+ background: 'none',
+});
+
+const StyledInputContainer = styled.div({
+ display: 'flex',
+ alignItems: 'center',
+ flex: 1,
+ backgroundColor: colors.blue,
+ paddingLeft: measurements.viewMargin,
+ height: measurements.rowMinHeight,
+});
+
+const StyledHeaderLabel = styled(Cell.Label)({
+ display: 'block',
+ flex: 1,
+ backgroundColor: colors.blue,
+ paddingLeft: measurements.viewMargin,
+ margin: 0,
+ height: measurements.rowMinHeight,
+ lineHeight: measurements.rowMinHeight,
+});
+
+const StyledCellButton = styled(StyledLocationRowIcon)({
+ border: 'none',
+});
+
+const StyledAddListCellButton = styled(StyledCellButton)({
+ marginLeft: 'auto',
+});
+
+const StyledSideButtonIcon = styled(Cell.Icon)({
+ padding: '3px',
+
+ [`${StyledCellButton}:hover &&, ${StyledAddListCellButton}:hover &&`]: {
+ backgroundColor: colors.white,
+ },
+});
+
+const StyledInput = styled(SimpleInput)((props: { error: boolean }) => ({
+ color: props.error ? colors.red : 'auto',
+}));
+
+interface CustomListsProps {
+ selectedElementRef: React.Ref<HTMLDivElement>;
+ onSelect: (value: RelayLocation) => void;
+}
+
+export default function CustomLists(props: CustomListsProps) {
+ const [addListVisible, showAddList, hideAddList] = useBoolean();
+ const { createCustomList } = useAppContext();
+ const { searchTerm } = useSelectLocationContext();
+ const { customLists } = useRelayListContext();
+
+ const createList = useCallback(async (name: string): Promise<void | CustomListError> => {
+ const result = await createCustomList(name);
+ // If an error is returned it should be passed as the return value.
+ if (result) {
+ return result;
+ }
+
+ hideAddList();
+ }, []);
+
+ if (searchTerm !== '' && customLists.length === 0) {
+ return null;
+ }
+
+ return (
+ <Cell.Group>
+ <StyledCellContainer>
+ <StyledHeaderLabel>
+ {messages.pgettext('select-location-view', 'Custom lists')}
+ </StyledHeaderLabel>
+ <StyledCellButton
+ backgroundColor={colors.blue}
+ backgroundColorHover={colors.blue80}
+ onClick={showAddList}>
+ <StyledSideButtonIcon source="icon-add" tintColor={colors.white60} width={18} />
+ </StyledCellButton>
+ </StyledCellContainer>
+
+ <Accordion expanded>
+ <CustomListsImpl selectedElementRef={props.selectedElementRef} onSelect={props.onSelect} />
+ </Accordion>
+
+ <AddListForm visible={addListVisible} onCreateList={createList} cancel={hideAddList} />
+ </Cell.Group>
+ );
+}
+
+interface AddListFormProps {
+ visible: boolean;
+ onCreateList: (list: string) => Promise<void | CustomListError>;
+ cancel: () => void;
+}
+
+function AddListForm(props: AddListFormProps) {
+ const [name, setName] = useState('');
+ const [error, setError, unsetError] = useBoolean();
+ const containerRef = useRef<HTMLDivElement>() as React.RefObject<HTMLDivElement>;
+ const inputRef = useRef<HTMLInputElement>() as React.RefObject<HTMLInputElement>;
+
+ // Errors should be reset when editing the value
+ const onChange = useCallback((value: string) => {
+ setName(value);
+ unsetError();
+ }, []);
+
+ const createList = useCallback(async () => {
+ try {
+ const result = await props.onCreateList(name);
+ if (result) {
+ setError();
+ }
+ } catch (e) {
+ const error = e as Error;
+ log.error('Failed to create list:', error.message);
+ }
+ }, [name, props.onCreateList]);
+
+ const onBlur = useCallback(
+ (event: React.FocusEvent<HTMLInputElement>) => {
+ // Only cancel if losing focus to something else than the contents of the row container.
+ if (!event.relatedTarget || !containerRef.current?.contains(event.relatedTarget)) {
+ props.cancel();
+ }
+ },
+ [props.cancel],
+ );
+
+ const onTransitionEnd = useCallback(() => {
+ if (!props.visible) {
+ setName('');
+ }
+ }, [props.visible]);
+
+ useEffect(() => {
+ if (props.visible) {
+ inputRef.current?.focus();
+ }
+ }, [props.visible]);
+
+ return (
+ <Accordion expanded={props.visible} onTransitionEnd={onTransitionEnd}>
+ <StyledCellContainer ref={containerRef}>
+ <StyledInputContainer>
+ <StyledInput
+ ref={inputRef}
+ value={name}
+ onChangeValue={onChange}
+ onSubmitValue={createList}
+ onBlur={onBlur}
+ maxLength={30}
+ error={error}
+ autoFocus
+ />
+ </StyledInputContainer>
+
+ <StyledAddListCellButton
+ backgroundColor={colors.blue}
+ backgroundColorHover={colors.blue80}
+ onClick={createList}>
+ <StyledSideButtonIcon source="icon-check" tintColor={colors.white60} width={18} />
+ </StyledAddListCellButton>
+ </StyledCellContainer>
+ <Cell.CellFooter>
+ <Cell.CellFooterText>
+ {messages.pgettext('select-location-view', 'List names must be unique.')}
+ </Cell.CellFooterText>
+ </Cell.CellFooter>
+ </Accordion>
+ );
+}
+
+interface CustomListsImplProps {
+ selectedElementRef: React.Ref<HTMLDivElement>;
+ onSelect: (value: RelayLocation) => void;
+}
+
+function CustomListsImpl(props: CustomListsImplProps) {
+ const { customLists, expandLocation, collapseLocation, onBeforeExpand } = useRelayListContext();
+ const { resetHeight } = useScrollPositionContext();
+
+ const onSelect = useCallback(
+ (value: RelayLocation) => {
+ const location = { ...value };
+ if ('country' in location) {
+ // Only the geographical part should be sent to the daemon when setting a location.
+ delete location.customList;
+ }
+ props.onSelect(location);
+ },
+ [props.onSelect],
+ );
+
+ return (
+ <RelayLocationList
+ source={customLists}
+ onExpand={expandLocation}
+ onCollapse={collapseLocation}
+ onWillExpand={onBeforeExpand}
+ selectedElementRef={props.selectedElementRef}
+ onSelect={onSelect}
+ onTransitionEnd={resetHeight}
+ allowAddToCustomList={false}
+ />
+ );
+}
diff --git a/gui/src/renderer/components/select-location/LocationRow.tsx b/gui/src/renderer/components/select-location/LocationRow.tsx
index eafae9c509..3d6c818e42 100644
--- a/gui/src/renderer/components/select-location/LocationRow.tsx
+++ b/gui/src/renderer/components/select-location/LocationRow.tsx
@@ -3,52 +3,41 @@ import { sprintf } from 'sprintf-js';
import styled from 'styled-components';
import { colors } from '../../../config.json';
-import { compareRelayLocation, RelayLocation } from '../../../shared/daemon-rpc-types';
+import {
+ compareRelayLocation,
+ compareRelayLocationGeographical,
+ RelayLocation,
+} from '../../../shared/daemon-rpc-types';
import { messages } from '../../../shared/gettext';
+import log from '../../../shared/logging';
+import { useAppContext } from '../../context';
+import { useBoolean } from '../../lib/utilityHooks';
+import { useSelector } from '../../redux/store';
import Accordion from '../Accordion';
import * as Cell from '../cell';
import ChevronButton from '../ChevronButton';
import { measurements, normalText } from '../common-styles';
+import ImageView from '../ImageView';
import RelayStatusIndicator from '../RelayStatusIndicator';
+import { AddToListDialog, EditListDialog } from './CustomListDialogs';
import {
CitySpecification,
CountrySpecification,
getLocationChildren,
- LocationSelection,
- LocationSelectionType,
LocationSpecification,
RelaySpecification,
} from './select-location-types';
interface IButtonColorProps {
- selected: boolean;
- disabled?: boolean;
- location?: RelayLocation;
+ backgroundColor: string;
+ backgroundColorHover: string;
}
const buttonColor = (props: IButtonColorProps) => {
- let background = colors.blue;
- if (props.selected) {
- background = colors.green;
- } else if (props.location) {
- if ('hostname' in props.location) {
- background = colors.blue20;
- } else if ('city' in props.location) {
- background = colors.blue40;
- }
- }
-
- let backgroundHover = colors.blue80;
- if (props.selected || props.disabled) {
- backgroundHover = background;
- } else if (props.location) {
- backgroundHover = colors.blue80;
- }
-
return {
- backgroundColor: background,
+ backgroundColor: props.backgroundColor,
':not(:disabled):hover': {
- backgroundColor: backgroundHover,
+ backgroundColor: props.backgroundColorHover,
},
};
};
@@ -61,16 +50,13 @@ export const StyledLocationRowContainer = styled(Cell.Container)({
export const StyledLocationRowButton = styled(Cell.Row)(
buttonColor,
- (props: { location?: RelayLocation }) => {
- const paddingLeft =
- props.location && 'hostname' in props.location
- ? 50
- : props.location && 'city' in props.location
- ? 34
- : 18;
+ (props: IButtonColorProps & { level: number }) => {
+ const paddingLeft = (props.level + 1) * 16 + 2;
return {
+ display: 'flex',
flex: 1,
+ overflow: 'hidden',
border: 'none',
padding: `0 10px 0 ${paddingLeft}px`,
margin: 0,
@@ -81,7 +67,7 @@ export const StyledLocationRowButton = styled(Cell.Row)(
export const StyledLocationRowIcon = styled.button(buttonColor, {
position: 'relative',
alignSelf: 'stretch',
- paddingLeft: '22px',
+ paddingLeft: measurements.viewMargin,
paddingRight: measurements.viewMargin,
'&::before': {
@@ -98,15 +84,57 @@ export const StyledLocationRowIcon = styled.button(buttonColor, {
});
export const StyledLocationRowLabel = styled(Cell.Label)(normalText, {
+ flex: 1,
+ minWidth: 0,
fontWeight: 400,
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+});
+
+const StyledHoverIconButton = styled.button(
+ buttonColor,
+ (props: { isLast?: boolean } & IButtonColorProps) => ({
+ flex: 0,
+ display: 'none',
+ padding: '0 10px',
+ paddingRight: props.isLast ? '17px' : '10px',
+ margin: 0,
+ border: 0,
+ height: measurements.rowMinHeight,
+ appearance: 'none',
+
+ ':not(:disabled):hover': {
+ backgroundColor: props.backgroundColor,
+ },
+ [`${StyledLocationRowContainer}:hover &&`]: {
+ display: 'block',
+ },
+ [`${StyledLocationRowButton}:hover ~ &&`]: {
+ backgroundColor: props.backgroundColorHover,
+ },
+ }),
+);
+
+const StyledHoverIcon = styled(ImageView).attrs({
+ width: 18,
+ height: 18,
+ tintColor: colors.white60,
+ tintHoverColor: colors.white,
+})({
+ [`${StyledHoverIconButton}:hover &&`]: {
+ backgroundColor: colors.white,
+ },
});
interface IProps<C extends LocationSpecification> {
source: C;
+ level: number;
selectedElementRef: React.Ref<HTMLDivElement>;
- onSelect: (value: LocationSelection<never>) => void;
+ onSelect: (value: RelayLocation) => void;
onExpand: (location: RelayLocation) => void;
onCollapse: (location: RelayLocation) => void;
+ allowAddToCustomList: boolean;
onWillExpand: (
locationRect: DOMRect,
expandedContentHeight: number,
@@ -126,6 +154,13 @@ function LocationRow<C extends LocationSpecification>(props: IProps<C>) {
const buttonRef = useRef<HTMLButtonElement>() as React.RefObject<HTMLButtonElement>;
const userInvokedExpand = useRef(false);
+ const { updateCustomList, deleteCustomList } = useAppContext();
+ const [addToListDialogVisible, showAddToListDialog, hideAddToListDialog] = useBoolean();
+ const [editDialogVisible, showEditDialog, hideEditDialog] = useBoolean();
+ const background = getButtonColor(props.source.selected, props.level, props.source.disabled);
+
+ const customLists = useSelector((state) => state.settings.customLists);
+
// Expand/collapse should only be available if the expanded property is provided in the source
const expanded = 'expanded' in props.source ? props.source.expanded : undefined;
const toggleCollapse = useCallback(() => {
@@ -138,7 +173,7 @@ function LocationRow<C extends LocationSpecification>(props: IProps<C>) {
const handleClick = useCallback(() => {
if (!props.source.selected) {
- props.onSelect({ type: LocationSelectionType.relay, value: props.source.location });
+ props.onSelect(props.source.location);
}
}, [props.onSelect, props.source.location, props.source.selected]);
@@ -153,6 +188,44 @@ function LocationRow<C extends LocationSpecification>(props: IProps<C>) {
[props.onWillExpand, expanded],
);
+ const onRemoveFromList = useCallback(async () => {
+ if (props.source.location.customList) {
+ // Find the list and remove the location from it.
+ const list = customLists.find((list) => list.id === props.source.location.customList);
+ if (list !== undefined) {
+ const updatedList = {
+ ...list,
+ locations: list.locations.filter((location) => {
+ return !compareRelayLocationGeographical(location, props.source.location);
+ }),
+ };
+
+ try {
+ await updateCustomList(updatedList);
+ } catch (e) {
+ const error = e as Error;
+ log.error(
+ `Failed to edit custom list ${props.source.location.customList}: ${error.message}`,
+ );
+ }
+ }
+ }
+ }, [customLists, props.source.location]);
+
+ // Remove an entire custom list.
+ const onRemoveCustomList = useCallback(async () => {
+ if (props.source.location.customList) {
+ try {
+ await deleteCustomList(props.source.location.customList);
+ } catch (e) {
+ const error = e as Error;
+ log.error(
+ `Failed to delete custom list ${props.source.location.customList}: ${error.message}`,
+ );
+ }
+ }
+ }, [props.source.location.customList]);
+
// The selectedRef should only be used if the element is selected
const selectedRef = props.source.selected ? props.selectedElementRef : undefined;
return (
@@ -162,26 +235,53 @@ function LocationRow<C extends LocationSpecification>(props: IProps<C>) {
as="button"
ref={buttonRef}
onClick={handleClick}
- selected={props.source.selected}
- location={props.source.location}
- disabled={props.source.disabled}>
+ level={props.level}
+ disabled={props.source.disabled}
+ includeMarginBottomOnLast
+ {...background}>
<RelayStatusIndicator active={props.source.active} selected={props.source.selected} />
<StyledLocationRowLabel>{props.source.label}</StyledLocationRowLabel>
</StyledLocationRowButton>
+
+ {props.allowAddToCustomList ? (
+ <StyledHoverIconButton onClick={showAddToListDialog} isLast {...background}>
+ <StyledHoverIcon source="icon-add" />
+ </StyledHoverIconButton>
+ ) : null}
+
+ {/* Show remove from custom list button if location is top level item in a custom list. */}
+ {'customList' in props.source.location &&
+ 'country' in props.source.location &&
+ props.level === 1 ? (
+ <StyledHoverIconButton onClick={onRemoveFromList} isLast {...background}>
+ <StyledHoverIcon source="icon-remove" />
+ </StyledHoverIconButton>
+ ) : null}
+
+ {/* Show buttons for editing and removing a custom list */}
+ {'customList' in props.source.location && !('country' in props.source.location) ? (
+ <>
+ <StyledHoverIconButton onClick={showEditDialog} {...background}>
+ <StyledHoverIcon source="icon-edit" />
+ </StyledHoverIconButton>
+ <StyledHoverIconButton onClick={onRemoveCustomList} isLast {...background}>
+ <StyledHoverIcon source="icon-close" />
+ </StyledHoverIconButton>
+ </>
+ ) : null}
+
{hasChildren ? (
<StyledLocationRowIcon
as={ChevronButton}
onClick={toggleCollapse}
up={expanded ?? false}
- selected={props.source.selected}
- disabled={props.source.disabled}
- location={props.source.location}
aria-label={sprintf(
expanded === true
? messages.pgettext('accessibility', 'Collapse %(location)s')
: messages.pgettext('accessibility', 'Expand %(location)s'),
{ location: props.source.label },
)}
+ {...background}
/>
) : null}
</StyledLocationRowContainer>
@@ -195,6 +295,18 @@ function LocationRow<C extends LocationSpecification>(props: IProps<C>) {
<Cell.Group noMarginBottom>{props.children}</Cell.Group>
</Accordion>
)}
+
+ {'country' in props.source.location && (
+ <AddToListDialog
+ isOpen={addToListDialogVisible}
+ hide={hideAddToListDialog}
+ location={props.source.location}
+ />
+ )}
+
+ {'list' in props.source && (
+ <EditListDialog list={props.source.list} isOpen={editDialogVisible} hide={hideEditDialog} />
+ )}
</>
);
}
@@ -203,6 +315,24 @@ function LocationRow<C extends LocationSpecification>(props: IProps<C>) {
// a lot more work than necessary
export default React.memo(LocationRow, compareProps);
+export function getButtonColor(selected: boolean, level: number, disabled?: boolean) {
+ let backgroundColor = colors.blue60;
+ if (selected) {
+ backgroundColor = colors.green;
+ } else if (level === 1) {
+ backgroundColor = colors.blue40;
+ } else if (level === 2) {
+ backgroundColor = colors.blue20;
+ } else if (level === 3) {
+ backgroundColor = colors.blue10;
+ }
+
+ return {
+ backgroundColor,
+ backgroundColorHover: selected || disabled ? backgroundColor : colors.blue80,
+ };
+}
+
function compareProps<C extends LocationSpecification>(
oldProps: IProps<C>,
nextProps: IProps<C>,
@@ -210,8 +340,10 @@ function compareProps<C extends LocationSpecification>(
return (
oldProps.onSelect === nextProps.onSelect &&
oldProps.onExpand === nextProps.onExpand &&
+ oldProps.onCollapse === nextProps.onCollapse &&
oldProps.onWillExpand === nextProps.onWillExpand &&
oldProps.onTransitionEnd === nextProps.onTransitionEnd &&
+ oldProps.allowAddToCustomList === nextProps.allowAddToCustomList &&
compareLocation(oldProps.source, nextProps.source)
);
}
@@ -242,7 +374,7 @@ function compareChildren(
const nextExpanded = 'expanded' in nextLocation && nextLocation.expanded;
return (
- !nextExpanded ||
+ (!nextExpanded && oldChildren.length > 0 && nextChildren.length > 0) ||
(oldChildren.length === nextChildren.length &&
oldChildren.every((oldChild, i) => compareLocation(oldChild, nextChildren[i])))
);
diff --git a/gui/src/renderer/components/select-location/RelayListContext.tsx b/gui/src/renderer/components/select-location/RelayListContext.tsx
index 0c99125605..e81ba4986b 100644
--- a/gui/src/renderer/components/select-location/RelayListContext.tsx
+++ b/gui/src/renderer/components/select-location/RelayListContext.tsx
@@ -9,8 +9,9 @@ import {
searchForLocations,
} from '../../lib/filter-locations';
import { useNormalBridgeSettings, useNormalRelaySettings } from '../../lib/utilityHooks';
-import { IRelayLocationRedux } from '../../redux/settings/reducers';
+import { IRelayLocationCountryRedux } from '../../redux/settings/reducers';
import { useSelector } from '../../redux/store';
+import { useCustomListsRelayList } from './custom-list-helpers';
import { useScrollPositionContext } from './ScrollPositionContext';
import {
defaultExpandedLocations,
@@ -22,16 +23,18 @@ import {
isSelected,
} from './select-location-helpers';
import {
+ CustomListSpecification,
DisabledReason,
- LocationList,
- LocationSelectionType,
+ GeographicalRelayList,
LocationType,
} from './select-location-types';
import { useSelectLocationContext } from './SelectLocationContainer';
// Context containing the relay list and related data and callbacks
interface RelayListContext {
- relayList: LocationList<never>;
+ relayList: GeographicalRelayList;
+ customLists: Array<CustomListSpecification>;
+ expandedLocations?: Array<RelayLocation>;
expandLocation: (location: RelayLocation) => void;
collapseLocation: (location: RelayLocation) => void;
onBeforeExpand: (
@@ -44,7 +47,7 @@ interface RelayListContext {
type ExpandedLocations = Partial<Record<LocationType, Array<RelayLocation>>>;
-const relayListContext = React.createContext<RelayListContext | undefined>(undefined);
+export const relayListContext = React.createContext<RelayListContext | undefined>(undefined);
export function useRelayListContext() {
return useContext(relayListContext)!;
@@ -93,15 +96,27 @@ export function RelayListContextProvider(props: RelayListContextProviderProps) {
// Prepares all relays and combines the data needed for rendering them
const relayList = useRelayList(relayListForSearch, expandedLocations);
+ const customLists = useCustomListsRelayList(relayList, expandedLocations);
+
const contextValue = useMemo(
() => ({
relayList,
+ customLists,
+ expandedLocations,
expandLocation,
collapseLocation,
onBeforeExpand,
expandSearchResults,
}),
- [relayList, expandLocation, collapseLocation, onBeforeExpand, expandSearchResults],
+ [
+ relayList,
+ customLists,
+ expandedLocations,
+ expandLocation,
+ collapseLocation,
+ onBeforeExpand,
+ expandSearchResults,
+ ],
);
return (
@@ -112,9 +127,9 @@ export function RelayListContextProvider(props: RelayListContextProviderProps) {
// Return the final filtered and formatted relay list. This should be the only place in the app
// where processing of the relay list is performed.
function useRelayList(
- relayList: Array<IRelayLocationRedux>,
+ relayList: Array<IRelayLocationCountryRedux>,
expandedLocations?: Array<RelayLocation>,
-): LocationList<never> {
+): GeographicalRelayList {
const locale = useSelector((state) => state.userInterface.locale);
const selectedLocation = useSelectedLocation();
const disabledLocation = useDisabledLocation();
@@ -123,46 +138,50 @@ function useRelayList(
return relayList
.map((country) => {
const countryLocation = { country: country.code };
- const countryDisabled = isCountryDisabled(country, country.code, disabledLocation);
+ const countryDisabledReason = isCountryDisabled(country, countryLocation, disabledLocation);
return {
...country,
- type: LocationSelectionType.relay as const,
- label: formatRowName(country.name, countryLocation, countryDisabled),
+ label: formatRowName(country.name, countryLocation, countryDisabledReason),
location: countryLocation,
- active: countryDisabled !== DisabledReason.inactive,
- disabled: countryDisabled !== undefined,
+ active: countryDisabledReason !== DisabledReason.inactive,
+ disabled: countryDisabledReason !== undefined,
+ disabledReason: countryDisabledReason,
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);
+ const cityLocation: RelayLocation = { country: country.code, city: city.code };
+ const cityDisabledReason =
+ countryDisabledReason ?? isCityDisabled(city, cityLocation, disabledLocation);
return {
...city,
- label: formatRowName(city.name, cityLocation, cityDisabled),
+ label: formatRowName(city.name, cityLocation, cityDisabledReason),
location: cityLocation,
- active: cityDisabled !== DisabledReason.inactive,
- disabled: cityDisabled !== undefined,
+ active: cityDisabledReason !== DisabledReason.inactive,
+ disabled: cityDisabledReason !== undefined,
+ disabledReason: cityDisabledReason,
expanded: isExpanded(cityLocation, expandedLocations),
selected: isSelected(cityLocation, selectedLocation),
relays: city.relays
.map((relay) => {
const relayLocation: RelayLocation = {
- hostname: [country.code, city.code, relay.hostname],
+ country: country.code,
+ city: city.code,
+ hostname: relay.hostname,
};
- const relayDisabled =
- countryDisabled ??
- cityDisabled ??
- isRelayDisabled(relay, relayLocation.hostname, disabledLocation);
+ const relayDisabledReason =
+ countryDisabledReason ??
+ cityDisabledReason ??
+ isRelayDisabled(relay, relayLocation, disabledLocation);
return {
...relay,
- label: formatRowName(relay.hostname, relayLocation, relayDisabled),
+ label: formatRowName(relay.hostname, relayLocation, relayDisabledReason),
location: relayLocation,
- disabled: relayDisabled !== undefined,
+ disabled: relayDisabledReason !== undefined,
+ disabledReason: relayDisabledReason,
selected: isSelected(relayLocation, selectedLocation),
};
})
@@ -177,7 +196,7 @@ function useRelayList(
}
// Return all RelayLocations that should be expanded
-function useExpandedLocations(filteredLocations: Array<IRelayLocationRedux>) {
+function useExpandedLocations(filteredLocations: Array<IRelayLocationCountryRedux>) {
const { locationType, searchTerm } = useSelectLocationContext();
const { spacePreAllocationViewRef, scrollIntoView } = useScrollPositionContext();
const relaySettings = useNormalRelaySettings();
@@ -259,7 +278,7 @@ function useExpandedLocations(filteredLocations: Array<IRelayLocationRedux>) {
// Returns the location (if any) that should be disabled. This is currently used for disabling the
// entry location when selecting exit location etc.
-function useDisabledLocation() {
+export function useDisabledLocation() {
const { locationType } = useSelectLocationContext();
const relaySettings = useNormalRelaySettings();
@@ -286,7 +305,7 @@ function useDisabledLocation() {
}
// Returns the selected location for the current tunnel protocol and location type
-function useSelectedLocation() {
+export function useSelectedLocation(): RelayLocation | undefined {
const { locationType } = useSelectLocationContext();
const relaySettings = useNormalRelaySettings();
const bridgeSettings = useNormalBridgeSettings();
@@ -299,7 +318,7 @@ function useSelectedLocation() {
? undefined
: relaySettings?.wireguard.entryLocation;
} else {
- return bridgeSettings?.location;
+ return bridgeSettings?.location === 'any' ? undefined : bridgeSettings?.location;
}
}, [
locationType,
diff --git a/gui/src/renderer/components/select-location/RelayLocationList.tsx b/gui/src/renderer/components/select-location/RelayLocationList.tsx
index f22695d775..27fe75ea50 100644
--- a/gui/src/renderer/components/select-location/RelayLocationList.tsx
+++ b/gui/src/renderer/components/select-location/RelayLocationList.tsx
@@ -1,18 +1,14 @@
import React from 'react';
-import { RelayLocation, relayLocationComponents } from '../../../shared/daemon-rpc-types';
+import { RelayLocation } from '../../../shared/daemon-rpc-types';
import * as Cell from '../cell';
import LocationRow from './LocationRow';
-import {
- getLocationChildren,
- LocationSelection,
- LocationSpecification,
- RelayList,
-} from './select-location-types';
+import { getLocationChildren, LocationSpecification, RelayList } from './select-location-types';
interface CommonProps {
selectedElementRef: React.Ref<HTMLDivElement>;
- onSelect: (value: LocationSelection<never>) => void;
+ allowAddToCustomList: boolean;
+ onSelect: (value: RelayLocation) => void;
onExpand: (location: RelayLocation) => void;
onCollapse: (location: RelayLocation) => void;
onWillExpand: (
@@ -31,7 +27,12 @@ export default function RelayLocationList({ source, ...props }: RelayLocationsPr
return (
<Cell.Group noMarginBottom>
{source.map((country) => (
- <RelayLocation key={getLocationKey(country.location)} source={country} {...props} />
+ <RelayLocation
+ key={getLocationKey(country.location)}
+ source={country}
+ level={0}
+ {...props}
+ />
))}
</Cell.Group>
);
@@ -39,6 +40,7 @@ export default function RelayLocationList({ source, ...props }: RelayLocationsPr
interface RelayLocationProps extends CommonProps {
source: LocationSpecification;
+ level: number;
}
function RelayLocation(props: RelayLocationProps) {
@@ -47,12 +49,17 @@ function RelayLocation(props: RelayLocationProps) {
return (
<LocationRow {...props}>
{children.map((child) => (
- <RelayLocation key={getLocationKey(child.location)} {...props} source={child} />
+ <RelayLocation
+ key={getLocationKey(child.location)}
+ {...props}
+ source={child}
+ level={props.level + 1}
+ />
))}
</LocationRow>
);
}
function getLocationKey(location: RelayLocation): string {
- return relayLocationComponents(location).join('-');
+ return Object.values(location).join('-');
}
diff --git a/gui/src/renderer/components/select-location/ScrollPositionContext.tsx b/gui/src/renderer/components/select-location/ScrollPositionContext.tsx
index b020e76227..973e4484b6 100644
--- a/gui/src/renderer/components/select-location/ScrollPositionContext.tsx
+++ b/gui/src/renderer/components/select-location/ScrollPositionContext.tsx
@@ -18,6 +18,7 @@ interface ScrollPositionContext {
saveScrollPosition: () => void;
resetScrollPositions: () => void;
scrollIntoView: (rect: DOMRect) => void;
+ resetHeight: () => void;
}
type ScrollPosition = [number, number];
@@ -63,6 +64,8 @@ export function ScrollPositionContextProvider(props: ScrollPositionContextProps)
scrollViewRef.current?.scrollIntoView(rect);
}, []);
+ const resetHeight = useCallback(() => spacePreAllocationViewRef.current?.reset(), []);
+
const value = useMemo(
() => ({
scrollPositions,
@@ -72,6 +75,7 @@ export function ScrollPositionContextProvider(props: ScrollPositionContextProps)
saveScrollPosition,
resetScrollPositions,
scrollIntoView,
+ resetHeight,
}),
[saveScrollPosition, resetScrollPositions],
);
@@ -86,7 +90,7 @@ export function ScrollPositionContextProvider(props: ScrollPositionContextProps)
} else {
scrollViewRef.current?.scrollToTop();
}
- }, [locationType, searchTerm, relaySettings?.ownership, relaySettings?.providers]);
+ }, [locationType, searchTerm, relaySettings?.ownership, relaySettings?.providers.length]);
return (
<scrollPositionContext.Provider value={value}>{props.children}</scrollPositionContext.Provider>
diff --git a/gui/src/renderer/components/select-location/SelectLocation.tsx b/gui/src/renderer/components/select-location/SelectLocation.tsx
index bc397506a8..8cd3dfcde1 100644
--- a/gui/src/renderer/components/select-location/SelectLocation.tsx
+++ b/gui/src/renderer/components/select-location/SelectLocation.tsx
@@ -11,6 +11,7 @@ import { formatHtml } from '../../lib/html-formatter';
import { RoutePath } from '../../lib/routes';
import { useNormalBridgeSettings, useNormalRelaySettings } from '../../lib/utilityHooks';
import { useSelector } from '../../redux/store';
+import * as Cell from '../cell';
import ImageView from '../ImageView';
import { BackAction } from '../KeyboardNavigation';
import { Layout, SettingsContainer } from '../Layout';
@@ -22,6 +23,7 @@ import {
TitleBarItem,
} from '../NavigationBar';
import CombinedLocationList, { CombinedLocationListProps } from './CombinedLocationList';
+import CustomLists from './CustomLists';
import { useRelayListContext } from './RelayListContext';
import { ScopeBarItem } from './ScopeBar';
import { useScrollPositionContext } from './ScrollPositionContext';
@@ -31,7 +33,6 @@ import {
useOnSelectExitLocation,
} from './select-location-hooks';
import {
- LocationSelectionType,
LocationType,
SpecialBridgeLocationType,
SpecialLocation,
@@ -248,16 +249,16 @@ function ownershipFilterLabel(ownership: Ownership): string {
function SelectLocationContent() {
const { locationType, searchTerm } = useSelectLocationContext();
- const { selectedLocationRef, spacePreAllocationViewRef } = useScrollPositionContext();
+ const { selectedLocationRef, resetHeight } = useScrollPositionContext();
const { relayList, expandLocation, collapseLocation, onBeforeExpand } = useRelayListContext();
- const onSelectExitLocation = useOnSelectExitLocation();
- const onSelectEntryLocation = useOnSelectEntryLocation();
- const onSelectBridgeLocation = useOnSelectBridgeLocation();
+ const [onSelectExitRelay, onSelectExitSpecial] = useOnSelectExitLocation();
+ const [onSelectEntryRelay, onSelectEntrySpecial] = useOnSelectEntryLocation();
+ const [onSelectBridgeRelay, onSelectBridgeSpecial] = useOnSelectBridgeLocation();
const relaySettings = useNormalRelaySettings();
const bridgeSettings = useNormalBridgeSettings();
- const resetHeight = useCallback(() => spacePreAllocationViewRef.current?.reset(), []);
+ const allowAddToCustomList = useSelector((state) => state.settings.customLists.length > 0);
if (locationType === LocationType.exit) {
// Add "Custom" item if a custom relay is selected
@@ -265,7 +266,6 @@ function SelectLocationContent() {
relaySettings === undefined
? [
{
- type: LocationSelectionType.special,
label: messages.gettext('Custom'),
value: undefined,
selected: true,
@@ -273,37 +273,49 @@ function SelectLocationContent() {
]
: [];
- const relayListWithSpecial = [...filterSpecialLocations(searchTerm, specialList), ...relayList];
+ const specialLocations = filterSpecialLocations(searchTerm, specialList);
return (
- <LocationList
- key={locationType}
- source={relayListWithSpecial}
- selectedElementRef={selectedLocationRef}
- onSelect={onSelectExitLocation}
- onExpand={expandLocation}
- onCollapse={collapseLocation}
- onWillExpand={onBeforeExpand}
- onTransitionEnd={resetHeight}
- />
+ <>
+ <CustomLists selectedElementRef={selectedLocationRef} onSelect={onSelectExitRelay} />
+ <LocationList
+ key={locationType}
+ relayLocations={relayList}
+ specialLocations={specialLocations}
+ selectedElementRef={selectedLocationRef}
+ onSelectRelay={onSelectExitRelay}
+ onSelectSpecial={onSelectExitSpecial}
+ onExpand={expandLocation}
+ onCollapse={collapseLocation}
+ onWillExpand={onBeforeExpand}
+ onTransitionEnd={resetHeight}
+ allowAddToCustomList={allowAddToCustomList}
+ />
+ <NoSearchResult specialLocationsLength={specialLocations.length} />
+ </>
);
} else if (relaySettings?.tunnelProtocol !== 'openvpn') {
return (
- <LocationList
- key={locationType}
- source={relayList}
- selectedElementRef={selectedLocationRef}
- onSelect={onSelectEntryLocation}
- onExpand={expandLocation}
- onCollapse={collapseLocation}
- onWillExpand={onBeforeExpand}
- onTransitionEnd={resetHeight}
- />
+ <>
+ <CustomLists selectedElementRef={selectedLocationRef} onSelect={onSelectEntryRelay} />
+ <LocationList
+ key={locationType}
+ relayLocations={relayList}
+ selectedElementRef={selectedLocationRef}
+ onSelectRelay={onSelectEntryRelay}
+ onSelectSpecial={onSelectEntrySpecial}
+ onExpand={expandLocation}
+ onCollapse={collapseLocation}
+ onWillExpand={onBeforeExpand}
+ onTransitionEnd={resetHeight}
+ allowAddToCustomList={allowAddToCustomList}
+ />
+ <NoSearchResult specialLocationsLength={0} />
+ </>
);
} else {
// Add the "Automatic" item
const specialList: Array<SpecialLocation<SpecialBridgeLocationType>> = [
{
- type: LocationSelectionType.special,
label: messages.gettext('Automatic'),
icon: SpecialLocationIcon.geoLocation,
info: messages.pgettext(
@@ -315,18 +327,25 @@ function SelectLocationContent() {
},
];
- const relayListWithSpecial = [...filterSpecialLocations(searchTerm, specialList), ...relayList];
+ const specialLocations = filterSpecialLocations(searchTerm, specialList);
return (
- <LocationList
- key={locationType}
- source={relayListWithSpecial}
- selectedElementRef={selectedLocationRef}
- onSelect={onSelectBridgeLocation}
- onExpand={expandLocation}
- onCollapse={collapseLocation}
- onWillExpand={onBeforeExpand}
- onTransitionEnd={resetHeight}
- />
+ <>
+ <CustomLists selectedElementRef={selectedLocationRef} onSelect={onSelectBridgeRelay} />
+ <LocationList
+ key={locationType}
+ relayLocations={relayList}
+ specialLocations={specialLocations}
+ selectedElementRef={selectedLocationRef}
+ onSelectRelay={onSelectBridgeRelay}
+ onSelectSpecial={onSelectBridgeSpecial}
+ onExpand={expandLocation}
+ onCollapse={collapseLocation}
+ onWillExpand={onBeforeExpand}
+ onTransitionEnd={resetHeight}
+ allowAddToCustomList={allowAddToCustomList}
+ />
+ <NoSearchResult specialLocationsLength={specialLocations.length} />
+ </>
);
}
}
@@ -334,18 +353,51 @@ function SelectLocationContent() {
function LocationList<T>(props: CombinedLocationListProps<T>) {
const { searchTerm } = useSelectLocationContext();
- if (searchTerm !== '' && props.source.length === 0) {
+ if (
+ searchTerm !== '' &&
+ props.relayLocations.length === 0 &&
+ (props.specialLocations === undefined || props.specialLocations.length === 0)
+ ) {
+ return null;
+ } else {
return (
- <StyledNoResult>
- <StyledNoResultText>
- {formatHtml(
- sprintf(messages.gettext('No result for <b>%(searchTerm)s</b>.'), { searchTerm }),
- )}
- </StyledNoResultText>
- <StyledNoResultText>{messages.gettext('Try a different search.')}</StyledNoResultText>
- </StyledNoResult>
+ <>
+ <Cell.Row>
+ <Cell.Label>{messages.pgettext('select-location-view', 'All locations')}</Cell.Label>
+ </Cell.Row>
+ <CombinedLocationList {...props} />
+ </>
);
- } else {
- return <CombinedLocationList {...props} />;
}
}
+
+interface NoSearchResultProps {
+ specialLocationsLength: number;
+}
+
+function NoSearchResult(props: NoSearchResultProps) {
+ const { relayList, customLists } = useRelayListContext();
+ const { searchTerm } = useSelectLocationContext();
+
+ if (
+ searchTerm === '' ||
+ relayList.length > 0 ||
+ customLists.length > 0 ||
+ props.specialLocationsLength > 0
+ ) {
+ return null;
+ }
+
+ return (
+ <StyledNoResult>
+ <StyledNoResultText>
+ {formatHtml(
+ sprintf(messages.gettext('No result for <b>%(searchTerm)s</b>.'), {
+ searchTerm: searchTerm,
+ }),
+ )}
+ </StyledNoResultText>
+ <StyledNoResultText>{messages.gettext('Try a different search.')}</StyledNoResultText>
+ </StyledNoResult>
+ );
+}
diff --git a/gui/src/renderer/components/select-location/SelectLocationStyles.tsx b/gui/src/renderer/components/select-location/SelectLocationStyles.tsx
index 4ed9623ee1..a031287e5e 100644
--- a/gui/src/renderer/components/select-location/SelectLocationStyles.tsx
+++ b/gui/src/renderer/components/select-location/SelectLocationStyles.tsx
@@ -2,7 +2,7 @@ import styled from 'styled-components';
import { colors } from '../../../config.json';
import * as Cell from '../cell';
-import { tinyText } from '../common-styles';
+import { normalText, tinyText } from '../common-styles';
import SearchBar from '../SearchBar';
import { HeaderSubTitle } from '../SettingsHeader';
import { ScopeBar } from './ScopeBar';
@@ -76,3 +76,7 @@ export const StyledNoResult = styled(Cell.CellFooter)({
export const StyledNoResultText = styled(Cell.CellFooterText)({
textAlign: 'center',
});
+
+export const StyledAllLocationsTitle = styled(Cell.Label)(normalText, {
+ fontWeight: 'normal',
+});
diff --git a/gui/src/renderer/components/select-location/SpecialLocationList.tsx b/gui/src/renderer/components/select-location/SpecialLocationList.tsx
index cbed2cf358..c8c0c8e787 100644
--- a/gui/src/renderer/components/select-location/SpecialLocationList.tsx
+++ b/gui/src/renderer/components/select-location/SpecialLocationList.tsx
@@ -6,17 +6,18 @@ import { messages } from '../../../shared/gettext';
import * as Cell from '../cell';
import InfoButton from '../InfoButton';
import {
+ getButtonColor,
StyledLocationRowButton,
StyledLocationRowContainer,
StyledLocationRowIcon,
StyledLocationRowLabel,
} from './LocationRow';
-import { LocationSelection, LocationSelectionType, SpecialLocation } from './select-location-types';
+import { SpecialLocation } from './select-location-types';
interface SpecialLocationsProps<T> {
source: Array<SpecialLocation<T>>;
selectedElementRef: React.Ref<HTMLDivElement>;
- onSelect: (value: LocationSelection<T>) => void;
+ onSelect: (value: T) => void;
}
export default function SpecialLocationList<T>({ source, ...props }: SpecialLocationsProps<T>) {
@@ -48,24 +49,22 @@ const StyledSpecialLocationInfoButton = styled(InfoButton)({
interface SpecialLocationRowProps<T> {
source: SpecialLocation<T>;
selectedElementRef: React.Ref<HTMLDivElement>;
- onSelect: (value: LocationSelection<T>) => void;
+ onSelect: (value: T) => void;
}
function SpecialLocationRow<T>(props: SpecialLocationRowProps<T>) {
const onSelect = useCallback(() => {
if (!props.source.selected) {
- props.onSelect({
- type: LocationSelectionType.special,
- value: props.source.value,
- });
+ props.onSelect(props.source.value);
}
}, [props.source.selected, props.onSelect, props.source.value]);
const icon = props.source.selected ? 'icon-tick' : props.source.icon ?? undefined;
const selectedRef = props.source.selected ? props.selectedElementRef : undefined;
+ const background = getButtonColor(props.source.selected, 0, props.source.disabled);
return (
<StyledLocationRowContainerWithMargin ref={selectedRef}>
- <StyledLocationRowButton onClick={onSelect} selected={props.source.selected}>
+ <StyledLocationRowButton onClick={onSelect} level={0} {...background}>
{icon && (
<StyledSpecialLocationIcon
source={icon}
diff --git a/gui/src/renderer/components/select-location/custom-list-helpers.ts b/gui/src/renderer/components/select-location/custom-list-helpers.ts
new file mode 100644
index 0000000000..39c8aa0d60
--- /dev/null
+++ b/gui/src/renderer/components/select-location/custom-list-helpers.ts
@@ -0,0 +1,166 @@
+import { useMemo } from 'react';
+
+import { ICustomList, RelayLocation } from '../../../shared/daemon-rpc-types';
+import { searchMatch } from '../../lib/filter-locations';
+import { useSelector } from '../../redux/store';
+import { useDisabledLocation, useSelectedLocation } from './RelayListContext';
+import { isCustomListDisabled, isExpanded, isSelected } from './select-location-helpers';
+import {
+ CitySpecification,
+ CountrySpecification,
+ CustomListSpecification,
+ DisabledReason,
+ GeographicalRelayList,
+ RelaySpecification,
+} from './select-location-types';
+import { useSelectLocationContext } from './SelectLocationContainer';
+
+// Hook that generates the custom lists relay list.
+export function useCustomListsRelayList(
+ relayList: GeographicalRelayList,
+ expandedLocations?: Array<RelayLocation>,
+) {
+ const disabledLocation = useDisabledLocation();
+ const selectedLocation = useSelectedLocation();
+ const { searchTerm } = useSelectLocationContext();
+ const customLists = useSelector((state) => state.settings.customLists);
+
+ // Populate all custom lists with the real location trees for the list locations.
+ return useMemo(
+ () =>
+ customLists
+ .map((list) =>
+ prepareCustomList(list, relayList, selectedLocation, disabledLocation, expandedLocations),
+ )
+ .filter((list) => searchMatch(searchTerm, list.label)),
+ [customLists, relayList, selectedLocation, disabledLocation, expandedLocations],
+ );
+}
+
+// Creates a CustomListSpecification from a ICustomList.
+function prepareCustomList(
+ list: ICustomList,
+ fullRelayList: GeographicalRelayList,
+ selectedLocation?: RelayLocation,
+ disabledLocation?: { location: RelayLocation; reason: DisabledReason },
+ expandedLocations?: Array<RelayLocation>,
+): CustomListSpecification {
+ const location = { customList: list.id };
+ const locations = prepareLocations(list, fullRelayList, expandedLocations);
+
+ const disabledReason = isCustomListDisabled(location, locations, disabledLocation);
+ return {
+ ...list,
+ label: list.name,
+ list,
+ location,
+ active: disabledReason !== DisabledReason.inactive,
+ disabled: disabledReason !== undefined,
+ disabledReason,
+ expanded: isExpanded(location, expandedLocations),
+ selected: isSelected(location, selectedLocation),
+ locations,
+ };
+}
+
+// Returns a list of CountrySpecification, CitySpecification and RelaySpecification matching the
+// contents of the custom list.
+function prepareLocations(
+ list: ICustomList,
+ fullRelayList: GeographicalRelayList,
+ expandedLocations?: Array<RelayLocation>,
+) {
+ const locationCounter = {};
+
+ return list.locations
+ .map((location) => {
+ if ('hostname' in location) {
+ // Search through all relays in all cities in all countries to find the matching relay.
+ const relay = fullRelayList
+ .find((country) => country.location.country === location.country)
+ ?.cities.find((city) => city.location.city === location.city)
+ ?.relays.find((relay) => relay.location.hostname === location.hostname);
+
+ return relay && updateRelay(relay, list.id);
+ } else if ('city' in location) {
+ // Search through all cities in all countries to find the matching city.
+ const city = fullRelayList
+ .find((country) => country.location.country === location.country)
+ ?.cities.find((city) => city.location.city === location.city);
+
+ return city && updateCity(city, list.id, locationCounter, expandedLocations);
+ } else {
+ // Search through all countries to find the matching country.
+ const country = fullRelayList.find(
+ (country) => country.location.country === location.country,
+ );
+
+ return country && updateCountry(country, list.id, locationCounter, expandedLocations);
+ }
+ })
+ .filter(hasValue);
+}
+
+// Update the CountrySpecification from the original relay list to contain the correct properties
+// for the custom list list.
+function updateCountry(
+ country: CountrySpecification,
+ customList: string,
+ locationCounter: Record<string, number>,
+ expandedLocations?: Array<RelayLocation>,
+): CountrySpecification {
+ // Since there can be multiple instances of a location in a custom list, every instance needs to
+ // be unique to avoid expanding all instances when expanding one.
+ const counterKey = `${country.location.country}`;
+ const count = locationCounter[counterKey] ?? 0;
+ locationCounter[counterKey] = count + 1;
+
+ const location = { ...country.location, customList, count };
+ return {
+ ...country,
+ location,
+ expanded: isExpanded(location, expandedLocations),
+ selected: false,
+ cities: country.cities.map((city) =>
+ updateCity(city, customList, locationCounter, expandedLocations),
+ ),
+ };
+}
+
+// Update the CitySpecification from the original relay list to contain the correct properties
+// for the custom list list.
+function updateCity(
+ city: CitySpecification,
+ customList: string,
+ locationCounter: Record<string, number>,
+ expandedLocations?: Array<RelayLocation>,
+): CitySpecification {
+ // Since there can be multiple instances of a location in a custom list, every instance needs to
+ // be unique to avoid expanding all instances when expanding one.
+ const counterKey = `${city.location.country}_${city.location.city}`;
+ const count = locationCounter[counterKey] ?? 0;
+ locationCounter[counterKey] = count + 1;
+
+ const location = { ...city.location, customList, count };
+ return {
+ ...city,
+ location,
+ expanded: isExpanded(location, expandedLocations),
+ selected: false,
+ relays: city.relays.map((relay) => updateRelay(relay, customList)),
+ };
+}
+
+// Update the RelaySpecification from the original relay list to contain the correct properties
+// for the custom list list.
+function updateRelay(relay: RelaySpecification, customList: string): RelaySpecification {
+ return {
+ ...relay,
+ location: { ...relay.location, customList },
+ selected: false,
+ };
+}
+
+function hasValue<T>(value: T): value is NonNullable<T> {
+ return value !== undefined && value !== null;
+}
diff --git a/gui/src/renderer/components/select-location/select-location-helpers.ts b/gui/src/renderer/components/select-location/select-location-helpers.ts
index 23d059ac0a..3d099adb7b 100644
--- a/gui/src/renderer/components/select-location/select-location-helpers.ts
+++ b/gui/src/renderer/components/select-location/select-location-helpers.ts
@@ -2,6 +2,7 @@ import { sprintf } from 'sprintf-js';
import {
compareRelayLocation,
+ compareRelayLocationCount,
compareRelayLocationLoose,
LiftedConstraint,
RelayLocation,
@@ -27,9 +28,13 @@ export function isSelected(
return selected !== 'any' && compareRelayLocationLoose(selected, relayLocation);
}
-export function isExpanded(relayLocation: RelayLocation, expandedLocations?: Array<RelayLocation>) {
+export function isExpanded(
+ relayLocation: RelayLocation & { count?: number },
+ expandedLocations?: Array<RelayLocation>,
+) {
return (
- expandedLocations?.some((location) => compareRelayLocation(location, relayLocation)) ?? false
+ expandedLocations?.some((location) => compareRelayLocationCount(location, relayLocation)) ??
+ false
);
}
@@ -183,3 +188,34 @@ export function isCountryDisabled(
return undefined;
}
+
+export function isCustomListDisabled(
+ location: RelayLocationCustomList,
+ locations: Array<LocationSpecification>,
+ disabledLocation?: { location: RelayLocation; reason: DisabledReason },
+) {
+ const locationsDisabled = locations.map((location) => location.disabledReason);
+ if (locationsDisabled.every((status) => status === DisabledReason.inactive)) {
+ return DisabledReason.inactive;
+ }
+
+ const disabledDueToSelection = locationsDisabled.find(
+ (status) => status === DisabledReason.entry || status === DisabledReason.exit,
+ );
+ if (
+ locationsDisabled.every((status) => status !== undefined) &&
+ disabledDueToSelection !== undefined
+ ) {
+ return disabledDueToSelection;
+ }
+
+ if (
+ disabledLocation &&
+ compareRelayLocation(location, disabledLocation.location) &&
+ locations.filter((location) => location.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
index 188a1c9aef..105e701524 100644
--- a/gui/src/renderer/components/select-location/select-location-hooks.ts
+++ b/gui/src/renderer/components/select-location/select-location-hooks.ts
@@ -1,19 +1,18 @@
import { useCallback } from 'react';
import BridgeSettingsBuilder from '../../../shared/bridge-settings-builder';
-import { RelaySettingsUpdate } from '../../../shared/daemon-rpc-types';
+import {
+ BridgeSettings,
+ 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 { useHistory } from '../../lib/history';
import { useSelector } from '../../redux/store';
-import {
- LocationSelection,
- LocationSelectionType,
- LocationType,
- SpecialBridgeLocationType,
-} from './select-location-types';
+import { LocationType, SpecialBridgeLocationType } from './select-location-types';
import { useSelectLocationContext } from './SelectLocationContainer';
export function useOnSelectExitLocation() {
@@ -21,21 +20,21 @@ export function useOnSelectExitLocation() {
const history = useHistory();
const { connectTunnel } = useAppContext();
- return useCallback(
- async (relayLocation: LocationSelection<undefined>) => {
- if (relayLocation.value === undefined) {
- throw new Error('relayLocation should never be undefined');
- }
-
+ const onSelectRelay = useCallback(
+ async (relayLocation: RelayLocation) => {
history.pop();
- const relayUpdate = RelaySettingsBuilder.normal()
- .location.fromRaw(relayLocation.value)
- .build();
+ const relayUpdate = RelaySettingsBuilder.normal().location.fromRaw(relayLocation).build();
await onSelectLocation(relayUpdate);
await connectTunnel();
},
[history],
);
+
+ const onSelectSpecial = useCallback((_location: undefined) => {
+ throw new Error('relayLocation should never be undefined');
+ }, []);
+
+ return [onSelectRelay, onSelectSpecial] as const;
}
export function useOnSelectEntryLocation() {
@@ -43,13 +42,23 @@ export function useOnSelectEntryLocation() {
const { setLocationType } = useSelectLocationContext();
const baseRelaySettings = useSelector((state) => state.settings.relaySettings);
- return useCallback(async (entryLocation: LocationSelection<never>) => {
+ const onSelectRelay = useCallback(async (entryLocation: RelayLocation) => {
setLocationType(LocationType.exit);
const relayUpdate = createWireguardRelayUpdater(baseRelaySettings)
- .tunnel.wireguard((wireguard) => wireguard.entryLocation.exact(entryLocation.value))
+ .tunnel.wireguard((wireguard) => wireguard.entryLocation.exact(entryLocation))
.build();
await onSelectLocation(relayUpdate);
}, []);
+
+ const onSelectSpecial = useCallback(async (_location: 'any') => {
+ setLocationType(LocationType.exit);
+ const relayUpdate = createWireguardRelayUpdater(baseRelaySettings)
+ .tunnel.wireguard((wireguard) => wireguard.entryLocation.any())
+ .build();
+ await onSelectLocation(relayUpdate);
+ }, []);
+
+ return [onSelectRelay, onSelectSpecial] as const;
}
function useOnSelectLocation() {
@@ -69,17 +78,7 @@ export function useOnSelectBridgeLocation() {
const { updateBridgeSettings } = useAppContext();
const { setLocationType } = useSelectLocationContext();
- return useCallback(async (location: LocationSelection<SpecialBridgeLocationType>) => {
- let bridgeUpdate;
- if (location.type === LocationSelectionType.relay) {
- bridgeUpdate = new BridgeSettingsBuilder().location.fromRaw(location.value).build();
- } else if (
- location.type === LocationSelectionType.special &&
- location.value === SpecialBridgeLocationType.closestToExit
- ) {
- bridgeUpdate = new BridgeSettingsBuilder().location.any().build();
- }
-
+ const setLocation = useCallback(async (bridgeUpdate: BridgeSettings) => {
if (bridgeUpdate) {
setLocationType(LocationType.exit);
try {
@@ -90,4 +89,20 @@ export function useOnSelectBridgeLocation() {
}
}
}, []);
+
+ const onSelectRelay = useCallback((location: RelayLocation) => {
+ const bridgeUpdate = new BridgeSettingsBuilder().location.fromRaw(location).build();
+ return setLocation(bridgeUpdate);
+ }, []);
+
+ const onSelectSpecial = useCallback((location: SpecialBridgeLocationType) => {
+ switch (location) {
+ case SpecialBridgeLocationType.closestToExit: {
+ const bridgeUpdate = new BridgeSettingsBuilder().location.any().build();
+ return setLocation(bridgeUpdate);
+ }
+ }
+ }, []);
+
+ return [onSelectRelay, onSelectSpecial] as const;
}
diff --git a/gui/src/renderer/components/select-location/select-location-types.ts b/gui/src/renderer/components/select-location/select-location-types.ts
index 6d895deeec..b96fee5f06 100644
--- a/gui/src/renderer/components/select-location/select-location-types.ts
+++ b/gui/src/renderer/components/select-location/select-location-types.ts
@@ -1,7 +1,14 @@
-import { RelayLocation } from '../../../shared/daemon-rpc-types';
+import {
+ ICustomList,
+ RelayLocation,
+ RelayLocationCity,
+ RelayLocationCountry,
+ RelayLocationCustomList,
+ RelayLocationRelay,
+} from '../../../shared/daemon-rpc-types';
import {
IRelayLocationCityRedux,
- IRelayLocationRedux,
+ IRelayLocationCountryRedux,
IRelayLocationRelayRedux,
} from '../../redux/settings/reducers';
@@ -10,17 +17,8 @@ export enum LocationType {
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 type RelayList = GeographicalRelayList | Array<CustomListSpecification>;
+export type GeographicalRelayList = Array<CountrySpecification>;
export enum SpecialBridgeLocationType {
closestToExit = 0,
@@ -30,44 +28,62 @@ export enum SpecialLocationIcon {
geoLocation = 'icon-nearest',
}
-export interface SpecialLocation<T> {
- type: LocationSelectionType.special;
+interface CommonLocationSpecification {
label: string;
+ selected: boolean;
+ disabled?: boolean;
+ disabledReason?: DisabledReason;
+}
+
+export interface SpecialLocation<T> extends CommonLocationSpecification {
icon?: SpecialLocationIcon;
info?: string;
value: T;
- disabled?: boolean;
- selected: boolean;
}
-export type LocationSpecification = CountrySpecification | CitySpecification | RelaySpecification;
+type GeographicalLocationSpecification =
+ | CountrySpecification
+ | CitySpecification
+ | RelaySpecification;
-export interface CountrySpecification extends Omit<IRelayLocationRedux, 'cities'> {
- type: LocationSelectionType.relay;
- label: string;
+export type LocationSpecification = GeographicalLocationSpecification | CustomListSpecification;
+
+interface CommonNormalLocationSpecification extends CommonLocationSpecification {
location: RelayLocation;
- active: boolean;
disabled: boolean;
- expanded: boolean;
selected: boolean;
+ active: boolean;
+}
+
+export interface CustomListSpecification
+ extends Omit<ICustomList, 'locations'>,
+ CommonNormalLocationSpecification {
+ location: RelayLocationCustomList;
+ list: ICustomList;
+ expanded: boolean;
+ locations: Array<GeographicalLocationSpecification>;
+}
+
+export interface CountrySpecification
+ extends Omit<IRelayLocationCountryRedux, 'cities'>,
+ CommonNormalLocationSpecification {
+ location: RelayLocationCountry;
+ expanded: boolean;
cities: Array<CitySpecification>;
}
-export interface CitySpecification extends Omit<IRelayLocationCityRedux, 'relays'> {
- label: string;
- location: RelayLocation;
- active: boolean;
- disabled: boolean;
+export interface CitySpecification
+ extends Omit<IRelayLocationCityRedux, 'relays'>,
+ CommonNormalLocationSpecification {
+ location: RelayLocationCity;
expanded: boolean;
- selected: boolean;
relays: Array<RelaySpecification>;
}
-export interface RelaySpecification extends IRelayLocationRelayRedux {
- label: string;
- location: RelayLocation;
- disabled: boolean;
- selected: boolean;
+export interface RelaySpecification
+ extends IRelayLocationRelayRedux,
+ CommonNormalLocationSpecification {
+ location: RelayLocationRelay;
}
export enum DisabledReason {
@@ -77,7 +93,9 @@ export enum DisabledReason {
}
export function getLocationChildren(location: LocationSpecification): Array<LocationSpecification> {
- if ('cities' in location) {
+ if ('locations' in location) {
+ return location.locations;
+ } else if ('cities' in location) {
return location.cities;
} else if ('relays' in location) {
return location.relays;