diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2023-10-04 12:24:19 +0200 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2023-10-09 10:16:53 +0200 |
| commit | 39d32b0bd8cdc904ccce5f9623981848b8850602 (patch) | |
| tree | d5f25df11278d1ce3ec1bbf77b8a68b03d868fc7 /gui/src | |
| parent | 14325fe08cd20f9efa70cdace8cd7b066f31a0da (diff) | |
| download | mullvadvpn-39d32b0bd8cdc904ccce5f9623981848b8850602.tar.xz mullvadvpn-39d32b0bd8cdc904ccce5f9623981848b8850602.zip | |
Add custom lists list in select location view
Diffstat (limited to 'gui/src')
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; |
