summaryrefslogtreecommitdiffhomepage
path: root/gui/src/renderer/components
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2023-10-09 10:36:33 +0200
committerOskar Nyberg <oskar@mullvad.net>2023-10-09 10:36:33 +0200
commit817866f1843b6b9366153fdfa31288af9e80c26b (patch)
tree7e921f6b1673954c80c67a9e54765d36d67560ec /gui/src/renderer/components
parentc1f9e782da8d8cd5731fe43017f056b184f126c9 (diff)
parent9551fd134a993636ffedcbe42d8d2b1407e93ddf (diff)
downloadmullvadvpn-817866f1843b6b9366153fdfa31288af9e80c26b.tar.xz
mullvadvpn-817866f1843b6b9366153fdfa31288af9e80c26b.zip
Merge branch 'custom-lists-gui-des-198'
Diffstat (limited to 'gui/src/renderer/components')
-rw-r--r--gui/src/renderer/components/Connect.tsx54
-rw-r--r--gui/src/renderer/components/Filter.tsx4
-rw-r--r--gui/src/renderer/components/SimpleInput.tsx71
-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/CustomListDialogs.tsx201
-rw-r--r--gui/src/renderer/components/select-location/CustomLists.tsx229
-rw-r--r--gui/src/renderer/components/select-location/LocationRow.tsx233
-rw-r--r--gui/src/renderer/components/select-location/RelayListContext.tsx80
-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.ts175
-rw-r--r--gui/src/renderer/components/select-location/select-location-helpers.ts82
-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.ts105
18 files changed, 1271 insertions, 292 deletions
diff --git a/gui/src/renderer/components/Connect.tsx b/gui/src/renderer/components/Connect.tsx
index 79de3c5242..58e7b4e7ba 100644
--- a/gui/src/renderer/components/Connect.tsx
+++ b/gui/src/renderer/components/Connect.tsx
@@ -2,12 +2,13 @@ import { useCallback, useMemo } from 'react';
import { sprintf } from 'sprintf-js';
import styled from 'styled-components';
+import { ICustomList } from '../../shared/daemon-rpc-types';
import { messages, relayLocations } from '../../shared/gettext';
import log from '../../shared/logging';
import { useAppContext } from '../context';
import { transitions, useHistory } from '../lib/history';
import { RoutePath } from '../lib/routes';
-import { IRelayLocationRedux, RelaySettingsRedux } from '../redux/settings/reducers';
+import { IRelayLocationCountryRedux, RelaySettingsRedux } from '../redux/settings/reducers';
import { useSelector } from '../redux/store';
import { calculateHeaderBarStyle, DefaultHeaderBar } from './HeaderBar';
import ImageView from './ImageView';
@@ -66,6 +67,7 @@ export default function Connect() {
const blockWhenDisconnected = useSelector((state) => state.settings.blockWhenDisconnected);
const relaySettings = useSelector((state) => state.settings.relaySettings);
const relayLocations = useSelector((state) => state.settings.relayLocations);
+ const customLists = useSelector((state) => state.settings.customLists);
const mapCenter = useMemo<[number, number] | undefined>(() => {
const { longitude, latitude } = connection;
@@ -136,10 +138,10 @@ export default function Connect() {
history.push(RoutePath.selectLocation, { transition: transitions.show });
}, [history.push]);
- const selectedRelayName = useMemo(() => getRelayName(relaySettings, relayLocations), [
- relaySettings,
- relayLocations,
- ]);
+ const selectedRelayName = useMemo(
+ () => getRelayName(relaySettings, customLists, relayLocations),
+ [relaySettings, customLists, relayLocations],
+ );
const onConnect = useCallback(async () => {
try {
@@ -200,31 +202,22 @@ export default function Connect() {
);
}
-function getRelayName(relaySettings: RelaySettingsRedux, locations: IRelayLocationRedux[]): string {
+function getRelayName(
+ relaySettings: RelaySettingsRedux,
+ customLists: Array<ICustomList>,
+ locations: IRelayLocationCountryRedux[],
+): string {
if ('normal' in relaySettings) {
const location = relaySettings.normal.location;
if (location === 'any') {
return 'Automatic';
- } else if ('country' in location) {
- const country = locations.find(({ code }) => code === location.country);
- if (country) {
- return relayLocations.gettext(country.name);
- }
- } else if ('city' in location) {
- const [countryCode, cityCode] = location.city;
- const country = locations.find(({ code }) => code === countryCode);
- if (country) {
- const city = country.cities.find(({ code }) => code === cityCode);
- if (city) {
- return relayLocations.gettext(city.name);
- }
- }
+ } else if ('customList' in location) {
+ return customLists.find((list) => list.id === location.customList)?.name ?? 'Unknown';
} else if ('hostname' in location) {
- const [countryCode, cityCode, hostname] = location.hostname;
- const country = locations.find(({ code }) => code === countryCode);
+ const country = locations.find(({ code }) => code === location.country);
if (country) {
- const city = country.cities.find(({ code }) => code === cityCode);
+ const city = country.cities.find(({ code }) => code === location.city);
if (city) {
return sprintf(
// TRANSLATORS: The selected location label displayed on the main view, when a user selected a specific host to connect to.
@@ -235,11 +228,24 @@ function getRelayName(relaySettings: RelaySettingsRedux, locations: IRelayLocati
messages.pgettext('connect-container', '%(city)s (%(hostname)s)'),
{
city: relayLocations.gettext(city.name),
- hostname,
+ hostname: location.hostname,
},
);
}
}
+ } else if ('city' in location) {
+ const country = locations.find(({ code }) => code === location.country);
+ if (country) {
+ const city = country.cities.find(({ code }) => code === location.city);
+ if (city) {
+ return relayLocations.gettext(city.name);
+ }
+ }
+ } else if ('country' in location) {
+ const country = locations.find(({ code }) => code === location.country);
+ if (country) {
+ return relayLocations.gettext(country.name);
+ }
}
return 'Unknown';
diff --git a/gui/src/renderer/components/Filter.tsx b/gui/src/renderer/components/Filter.tsx
index 26ed781396..6b369aa39b 100644
--- a/gui/src/renderer/components/Filter.tsx
+++ b/gui/src/renderer/components/Filter.tsx
@@ -12,7 +12,7 @@ import {
} from '../lib/filter-locations';
import { useHistory } from '../lib/history';
import { useBoolean, useNormalRelaySettings } from '../lib/utilityHooks';
-import { IRelayLocationRedux } from '../redux/settings/reducers';
+import { IRelayLocationCountryRedux } from '../redux/settings/reducers';
import { IReduxState, useSelector } from '../redux/store';
import Accordion from './Accordion';
import * as AppButton from './AppButton';
@@ -158,7 +158,7 @@ function useFilteredFilters(providers: string[], ownership: Ownership) {
}
// Returns all available providers in the provided relay list.
-function providersFromRelays(relays: IRelayLocationRedux[]) {
+function providersFromRelays(relays: IRelayLocationCountryRedux[]) {
const providers = relays.flatMap((country) =>
country.cities.flatMap((city) => city.relays.map((relay) => relay.provider)),
);
diff --git a/gui/src/renderer/components/SimpleInput.tsx b/gui/src/renderer/components/SimpleInput.tsx
new file mode 100644
index 0000000000..3d2a1a63a7
--- /dev/null
+++ b/gui/src/renderer/components/SimpleInput.tsx
@@ -0,0 +1,71 @@
+import { useCallback, useState } from 'react';
+import React from 'react';
+import styled from 'styled-components';
+
+import { useCombinedRefs } from '../lib/utilityHooks';
+import { normalText } from './common-styles';
+
+const StyledInput = styled.input.attrs({ type: 'text' })(normalText, {
+ padding: '6px 8px',
+ borderRadius: '4px',
+ outline: 0,
+ border: 0,
+ lineHeight: '21px',
+});
+
+interface SimpleInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
+ onChangeValue?: (value: string) => void;
+ onSubmitValue?: (value: string) => void;
+}
+
+function SimpleInput(props: SimpleInputProps, ref: React.Ref<HTMLInputElement>) {
+ const { onChangeValue, onSubmitValue, ...otherProps } = props;
+ const [value, setValue] = useState((props.value as string) ?? '');
+
+ const onChange = useCallback(
+ (event: React.ChangeEvent<HTMLInputElement>) => {
+ setValue(event.target.value);
+ otherProps.onChange?.(event);
+ onChangeValue?.(event.target.value);
+ },
+ [otherProps.onChange, onChangeValue],
+ );
+
+ const onSubmit = useCallback(
+ (event: React.FormEvent<HTMLInputElement>) => {
+ otherProps.onSubmit?.(event);
+ onSubmitValue?.(value);
+ },
+ [otherProps.onSubmit, onSubmitValue, value],
+ );
+
+ const onKeyPress = useCallback(
+ (event: React.KeyboardEvent<HTMLInputElement>) => {
+ props.onKeyPress?.(event);
+ if (event.key === 'Enter') {
+ onSubmitValue?.(value);
+ }
+ },
+ [props.onKeyPress, onSubmitValue, value],
+ );
+
+ const refCallback = useCallback((element: HTMLInputElement | null) => {
+ if (element && otherProps.autoFocus) {
+ setTimeout(() => element.focus());
+ }
+ }, []);
+
+ const combinedRef = useCombinedRefs(refCallback, ref);
+
+ return (
+ <StyledInput
+ {...otherProps}
+ ref={combinedRef}
+ onChange={onChange}
+ onSubmit={onSubmit}
+ onKeyPress={onKeyPress}
+ />
+ );
+}
+
+export default React.forwardRef(SimpleInput);
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/CustomListDialogs.tsx b/gui/src/renderer/components/select-location/CustomListDialogs.tsx
new file mode 100644
index 0000000000..e68c87e3a4
--- /dev/null
+++ b/gui/src/renderer/components/select-location/CustomListDialogs.tsx
@@ -0,0 +1,201 @@
+import { useCallback, useState } from 'react';
+import { sprintf } from 'sprintf-js';
+import styled from 'styled-components';
+
+import { colors } from '../../../config.json';
+import {
+ compareRelayLocationGeographical,
+ ICustomList,
+ RelayLocation,
+ RelayLocationGeographical,
+} from '../../../shared/daemon-rpc-types';
+import { messages } from '../../../shared/gettext';
+import log from '../../../shared/logging';
+import { useAppContext } from '../../context';
+import { formatHtml } from '../../lib/html-formatter';
+import { useBoolean } from '../../lib/utilityHooks';
+import { useSelector } from '../../redux/store';
+import * as AppButton from '../AppButton';
+import * as Cell from '../cell';
+import { normalText, tinyText } from '../common-styles';
+import { ModalAlert, ModalMessage } from '../Modal';
+import SimpleInput from '../SimpleInput';
+
+const StyledModalMessage = styled(ModalMessage)({
+ marginTop: '8px',
+ marginBottom: '8px',
+});
+
+interface AddToListDialogProps {
+ location: RelayLocationGeographical;
+ isOpen: boolean;
+ hide: () => void;
+}
+
+// Dialog that displays list of custom lists when adding location to custom list.
+export function AddToListDialog(props: AddToListDialogProps) {
+ const { updateCustomList } = useAppContext();
+ const customLists = useSelector((state) => state.settings.customLists);
+
+ const add = useCallback(
+ async (list: ICustomList) => {
+ // Update the list with the new location.
+ const updatedList = {
+ ...list,
+ locations: [...list.locations, props.location],
+ };
+ try {
+ await updateCustomList(updatedList);
+ } catch (e) {
+ const error = e as Error;
+ log.error(`Failed to edit custom list ${list.id}: ${error.message}`);
+ }
+
+ props.hide();
+ },
+ [location, updateCustomList],
+ );
+
+ let locationType: string;
+ if ('hostname' in props.location) {
+ // TRANSLATORS: This refers to our VPN relays/servers
+ locationType = messages.pgettext('select-location-view', 'Relay');
+ } else if ('city' in props.location) {
+ locationType = messages.pgettext('select-location-view', 'City');
+ } else {
+ locationType = messages.pgettext('select-location-view', 'Country');
+ }
+
+ const lists = customLists.map((list) => (
+ <SelectList key={list.id} list={list} location={props.location} add={add} />
+ ));
+
+ return (
+ <ModalAlert
+ isOpen={props.isOpen}
+ buttons={[
+ <AppButton.BlueButton key="cancel" onClick={props.hide}>
+ {messages.gettext('Cancel')}
+ </AppButton.BlueButton>,
+ ]}
+ close={props.hide}>
+ <StyledModalMessage>
+ {formatHtml(
+ sprintf(
+ // TRANSLATORS: This is a label shown above a list of options.
+ // TRANSLATORS: Available placeholder:
+ // TRANSLATORS: %(locationType) - Could be either "Country", "City" and "Relay"
+ messages.pgettext('select-location-view', 'Add <b>%(locationType)s</b> to list'),
+ {
+ locationType,
+ },
+ ),
+ )}
+ </StyledModalMessage>
+ {lists}
+ </ModalAlert>
+ );
+}
+
+const StyledSelectListItemLabel = styled(Cell.Label)(normalText, {
+ fontWeight: 'normal',
+});
+
+const StyledSelectListItemIcon = styled(Cell.Icon)({
+ [`${Cell.CellButton}:not(:disabled):hover &&`]: {
+ backgroundColor: colors.white80,
+ },
+});
+
+interface SelectListProps {
+ list: ICustomList;
+ location: RelayLocation;
+ add: (list: ICustomList) => void;
+}
+
+function SelectList(props: SelectListProps) {
+ const onAdd = useCallback(() => props.add(props.list), [props.list]);
+
+ // List should be disabled if location already is in list.
+ const disabled = props.list.locations.some((location) =>
+ compareRelayLocationGeographical(location, props.location),
+ );
+
+ return (
+ <Cell.CellButton onClick={onAdd} disabled={disabled}>
+ <StyledSelectListItemLabel>{props.list.name}</StyledSelectListItemLabel>
+ <StyledSelectListItemIcon source="icon-add" width={18} />
+ </Cell.CellButton>
+ );
+}
+
+const StyledInputErrorText = styled.span(tinyText, {
+ marginTop: '6px',
+ color: colors.red,
+});
+
+interface EditListProps {
+ list: ICustomList;
+ isOpen: boolean;
+ hide: () => void;
+}
+
+// Dialog for changing the name of a custom list.
+export function EditListDialog(props: EditListProps) {
+ const { updateCustomList } = useAppContext();
+
+ const [newName, setNewName] = useState(props.list.name);
+ const [error, setError, unsetError] = useBoolean();
+
+ // Update name in list and save it.
+ const save = useCallback(async () => {
+ try {
+ const updatedList = { ...props.list, name: newName };
+ const result = await updateCustomList(updatedList);
+ if (result && result.type === 'name already exists') {
+ setError();
+ } else {
+ props.hide();
+ }
+ } catch (e) {
+ const error = e as Error;
+ log.error(`Failed to edit custom list ${props.list.id}: ${error.message}`);
+ }
+ }, [props.list, newName, props.hide]);
+
+ // Errors should be reset when editing the value
+ const onChange = useCallback((value: string) => {
+ setNewName(value);
+ unsetError();
+ }, []);
+
+ return (
+ <ModalAlert
+ isOpen={props.isOpen}
+ buttons={[
+ <AppButton.BlueButton key="save" onClick={save}>
+ {messages.gettext('Save')}
+ </AppButton.BlueButton>,
+ <AppButton.BlueButton key="cancel" onClick={props.hide}>
+ {messages.gettext('Cancel')}
+ </AppButton.BlueButton>,
+ ]}
+ close={props.hide}>
+ <StyledModalMessage>
+ {messages.pgettext('select-location-view', 'Edit list name')}
+ </StyledModalMessage>
+ <SimpleInput
+ value={newName}
+ onChangeValue={onChange}
+ onSubmitValue={save}
+ maxLength={30}
+ autoFocus
+ />
+ {error && (
+ <StyledInputErrorText>
+ {messages.pgettext('select-location-view', 'Name is already taken.')}
+ </StyledInputErrorText>
+ )}
+ </ModalAlert>
+ );
+}
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..b87de1fe46
--- /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.some((list) => list.visible)) {
+ 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..07c7d0003b 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,
@@ -122,10 +150,17 @@ interface IProps<C extends LocationSpecification> {
// Renders the rows and its children for countries, cities and relays
function LocationRow<C extends LocationSpecification>(props: IProps<C>) {
- const hasChildren = React.Children.count(props.children) > 0;
+ const hasChildren = getLocationChildren(props.source).some((child) => child.visible);
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,48 @@ 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]);
+
+ if (!props.source.visible) {
+ return null;
+ }
+
// The selectedRef should only be used if the element is selected
const selectedRef = props.source.selected ? props.selectedElementRef : undefined;
return (
@@ -162,26 +239,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 +299,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 +319,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 +344,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)
);
}
@@ -221,6 +357,7 @@ function compareLocation(
nextLocation: LocationSpecification,
): boolean {
return (
+ oldLocation.visible === nextLocation.visible &&
oldLocation.label === nextLocation.label &&
oldLocation.active === nextLocation.active &&
oldLocation.disabled === nextLocation.disabled &&
@@ -235,16 +372,16 @@ function compareChildren(
oldLocation: LocationSpecification,
nextLocation: LocationSpecification,
): boolean {
- const oldChildren = getLocationChildren(oldLocation);
- const nextChildren = getLocationChildren(nextLocation);
+ const oldVisibleChildren = getLocationChildren(oldLocation).filter((child) => child.visible);
+ const nextVisibleChildren = getLocationChildren(nextLocation).filter((child) => child.visible);
// Children shouldn't be checked if the row is collapsed
const nextExpanded = 'expanded' in nextLocation && nextLocation.expanded;
return (
- !nextExpanded ||
- (oldChildren.length === nextChildren.length &&
- oldChildren.every((oldChild, i) => compareLocation(oldChild, nextChildren[i])))
+ (!nextExpanded && oldVisibleChildren.length > 0 && nextVisibleChildren.length > 0) ||
+ (oldVisibleChildren.length === nextVisibleChildren.length &&
+ oldVisibleChildren.every((oldChild, i) => compareLocation(oldChild, nextVisibleChildren[i])))
);
}
diff --git a/gui/src/renderer/components/select-location/RelayListContext.tsx b/gui/src/renderer/components/select-location/RelayListContext.tsx
index 0c99125605..d0ee5d6e85 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,19 @@ import {
isSelected,
} from './select-location-helpers';
import {
+ CustomListSpecification,
DisabledReason,
- LocationList,
- LocationSelectionType,
+ GeographicalRelayList,
LocationType,
+ RelayLocationCountryWithVisibility,
} 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 +48,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 +97,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 +128,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<RelayLocationCountryWithVisibility>,
expandedLocations?: Array<RelayLocation>,
-): LocationList<never> {
+): GeographicalRelayList {
const locale = useSelector((state) => state.userInterface.locale);
const selectedLocation = useSelectedLocation();
const disabledLocation = useDisabledLocation();
@@ -123,46 +139,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 +197,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 +279,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 +306,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 +319,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..1ea7aeb5aa 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.some((country) => country.visible) &&
+ (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.some((country) => country.visible) ||
+ customLists.some((list) => list.visible) ||
+ props.specialLocationsLength > 0
+ ) {
+ return null;
+ }
+
+ 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>
+ );
+}
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..40d77ba33d
--- /dev/null
+++ b/gui/src/renderer/components/select-location/custom-list-helpers.ts
@@ -0,0 +1,175 @@
+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,
+ searchTerm,
+ selectedLocation,
+ disabledLocation,
+ expandedLocations,
+ ),
+ ),
+ [customLists, relayList, selectedLocation, disabledLocation, expandedLocations],
+ );
+}
+
+// Creates a CustomListSpecification from a ICustomList.
+function prepareCustomList(
+ list: ICustomList,
+ fullRelayList: GeographicalRelayList,
+ searchTerm: string,
+ 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 {
+ label: list.name,
+ list,
+ location,
+ active: disabledReason !== DisabledReason.inactive,
+ disabled: disabledReason !== undefined,
+ disabledReason,
+ expanded: isExpanded(location, expandedLocations),
+ selected: isSelected(location, selectedLocation),
+ visible: searchMatch(searchTerm, list.name),
+ 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,
+ visible: true,
+ 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,
+ visible: true,
+ 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,
+ visible: true,
+ };
+}
+
+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 46225645cc..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,19 +2,24 @@ import { sprintf } from 'sprintf-js';
import {
compareRelayLocation,
+ compareRelayLocationCount,
compareRelayLocationLoose,
LiftedConstraint,
RelayLocation,
+ RelayLocationCity,
+ RelayLocationCountry,
+ RelayLocationCustomList,
+ RelayLocationRelay,
} from '../../../shared/daemon-rpc-types';
import { messages, relayLocations } from '../../../shared/gettext';
import {
IRelayLocationCityRedux,
- IRelayLocationRedux,
+ IRelayLocationCountryRedux,
IRelayLocationRelayRedux,
NormalBridgeSettingsRedux,
NormalRelaySettingsRedux,
} from '../../redux/settings/reducers';
-import { DisabledReason, LocationType } from './select-location-types';
+import { DisabledReason, LocationSpecification, LocationType } from './select-location-types';
export function isSelected(
relayLocation: RelayLocation,
@@ -23,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
);
}
@@ -58,13 +67,10 @@ export function defaultExpandedLocations(
// Expands a relay location and its parents
function expandRelayLocation(location: RelayLocation): RelayLocation[] {
- if ('city' in location) {
- return [{ country: location.city[0] }];
- } else if ('hostname' in location) {
- return [
- { country: location.hostname[0] },
- { city: [location.hostname[0], location.hostname[1]] },
- ];
+ if ('hostname' in location) {
+ return [{ country: location.country }, { country: location.country, city: location.city }];
+ } else if ('city' in location) {
+ return [{ country: location.country }];
} else {
return [];
}
@@ -104,15 +110,12 @@ export function formatRowName(
export function isRelayDisabled(
relay: IRelayLocationRelayRedux,
- location: [string, string, string],
+ location: RelayLocationRelay,
disabledLocation?: { location: RelayLocation; reason: DisabledReason },
): DisabledReason | undefined {
if (!relay.active) {
return DisabledReason.inactive;
- } else if (
- disabledLocation &&
- compareRelayLocation({ hostname: location }, disabledLocation.location)
- ) {
+ } else if (disabledLocation && compareRelayLocation(location, disabledLocation.location)) {
return disabledLocation.reason;
} else {
return undefined;
@@ -121,11 +124,11 @@ export function isRelayDisabled(
export function isCityDisabled(
city: IRelayLocationCityRedux,
- location: [string, string],
+ location: RelayLocationCity,
disabledLocation?: { location: RelayLocation; reason: DisabledReason },
): DisabledReason | undefined {
const relaysDisabled = city.relays.map((relay) =>
- isRelayDisabled(relay, [...location, relay.hostname]),
+ isRelayDisabled(relay, { ...location, hostname: relay.hostname }),
);
if (relaysDisabled.every((status) => status === DisabledReason.inactive)) {
return DisabledReason.inactive;
@@ -144,7 +147,7 @@ export function isCityDisabled(
if (
disabledLocation &&
- compareRelayLocation({ city: location }, disabledLocation.location) &&
+ compareRelayLocation(location, disabledLocation.location) &&
city.relays.filter((relay) => relay.active).length <= 1
) {
return disabledLocation.reason;
@@ -154,11 +157,13 @@ export function isCityDisabled(
}
export function isCountryDisabled(
- country: IRelayLocationRedux,
- location: string,
+ country: IRelayLocationCountryRedux,
+ location: RelayLocationCountry,
disabledLocation?: { location: RelayLocation; reason: DisabledReason },
): DisabledReason | undefined {
- const citiesDisabled = country.cities.map((city) => isCityDisabled(city, [location, city.code]));
+ const citiesDisabled = country.cities.map((city) =>
+ isCityDisabled(city, { ...location, city: city.code }),
+ );
if (citiesDisabled.every((status) => status === DisabledReason.inactive)) {
return DisabledReason.inactive;
}
@@ -175,7 +180,7 @@ export function isCountryDisabled(
if (
disabledLocation &&
- compareRelayLocation({ country: location }, disabledLocation.location) &&
+ compareRelayLocation(location, disabledLocation.location) &&
country.cities.flatMap((city) => city.relays).filter((relay) => relay.active).length <= 1
) {
return disabledLocation.reason;
@@ -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..c42cc45f6b 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,79 @@ export enum SpecialLocationIcon {
geoLocation = 'icon-nearest',
}
-export interface SpecialLocation<T> {
- type: LocationSelectionType.special;
+export interface LocationVisibility {
+ visible: boolean;
+}
+
+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;
+
+export interface RelayLocationCountryWithVisibility
+ extends IRelayLocationCountryRedux,
+ LocationVisibility {
+ cities: Array<RelayLocationCityWithVisibility>;
+}
+
+export interface RelayLocationCityWithVisibility
+ extends IRelayLocationCityRedux,
+ LocationVisibility {
+ relays: Array<RelayLocationRelayWithVisibility>;
+}
+
+export type RelayLocationRelayWithVisibility = IRelayLocationRelayRedux & LocationVisibility;
+
+interface CommonNormalLocationSpecification
+ extends CommonLocationSpecification,
+ LocationVisibility {
location: RelayLocation;
- active: boolean;
disabled: boolean;
+ active: boolean;
+}
+
+export interface CustomListSpecification extends CommonNormalLocationSpecification {
+ location: RelayLocationCustomList;
+ list: ICustomList;
+ expanded: boolean;
+ locations: Array<GeographicalLocationSpecification>;
+}
+
+export interface CountrySpecification
+ extends Pick<IRelayLocationCountryRedux, 'name' | 'code'>,
+ CommonNormalLocationSpecification {
+ location: RelayLocationCountry;
expanded: boolean;
- selected: boolean;
cities: Array<CitySpecification>;
}
-export interface CitySpecification extends Omit<IRelayLocationCityRedux, 'relays'> {
- label: string;
- location: RelayLocation;
- active: boolean;
- disabled: boolean;
+export interface CitySpecification
+ extends Pick<IRelayLocationCityRedux, 'name' | 'code'>,
+ 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 Omit<IRelayLocationRelayRedux, 'ipv4AddrIn' | 'includeInCountry' | 'weight'>,
+ CommonNormalLocationSpecification {
+ location: RelayLocationRelay;
}
export enum DisabledReason {
@@ -77,7 +110,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;