diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2022-11-15 15:35:36 +0100 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2022-11-24 16:26:28 +0100 |
| commit | 27a09008679cd99cf4e78a8b9b630527f0e0f7f1 (patch) | |
| tree | 4cae27cd42d3f33a4bf3f958334d81c57cc28b11 | |
| parent | ac580446338e20571c2089e9bede1b22fb8c0d73 (diff) | |
| download | mullvadvpn-27a09008679cd99cf4e78a8b9b630527f0e0f7f1.tar.xz mullvadvpn-27a09008679cd99cf4e78a8b9b630527f0e0f7f1.zip | |
Refactor SelectLocation and its subcomponents
24 files changed, 1268 insertions, 1504 deletions
diff --git a/gui/src/renderer/components/AppRouter.tsx b/gui/src/renderer/components/AppRouter.tsx index cab40d9db9..bd2ae6e53f 100644 --- a/gui/src/renderer/components/AppRouter.tsx +++ b/gui/src/renderer/components/AppRouter.tsx @@ -1,8 +1,8 @@ import { createRef, useCallback, useEffect, useState } from 'react'; import { Route, Switch } from 'react-router'; +import SelectLocation from '../components/select-location/SelectLocationContainer'; import LoginPage from '../containers/LoginPage'; -import SelectLocationPage from '../containers/SelectLocationPage'; import { useAppContext } from '../context'; import { ITransitionSpecification, transitions, useHistory } from '../lib/history'; import { RoutePath } from '../lib/routes'; @@ -81,7 +81,7 @@ export default function AppRouter() { <Route exact path={RoutePath.support} component={Support} /> <Route exact path={RoutePath.problemReport} component={ProblemReport} /> <Route exact path={RoutePath.debug} component={Debug} /> - <Route exact path={RoutePath.selectLocation} component={SelectLocationPage} /> + <Route exact path={RoutePath.selectLocation} component={SelectLocation} /> <Route exact path={RoutePath.filter} component={Filter} /> </Switch> </TransitionView> diff --git a/gui/src/renderer/components/Filter.tsx b/gui/src/renderer/components/Filter.tsx index 1bb208cd7f..4c0184bc24 100644 --- a/gui/src/renderer/components/Filter.tsx +++ b/gui/src/renderer/components/Filter.tsx @@ -5,7 +5,7 @@ import { colors } from '../../config.json'; import { Ownership } from '../../shared/daemon-rpc-types'; import { messages } from '../../shared/gettext'; import { useAppContext } from '../context'; -import filterLocations from '../lib/filter-locations'; +import { filterLocations } from '../lib/filter-locations'; import { useHistory } from '../lib/history'; import { useBoolean } from '../lib/utilityHooks'; import { IRelayLocationRedux } from '../redux/settings/reducers'; diff --git a/gui/src/renderer/components/select-location/BridgeLocations.tsx b/gui/src/renderer/components/select-location/BridgeLocations.tsx deleted file mode 100644 index 4ab227de7f..0000000000 --- a/gui/src/renderer/components/select-location/BridgeLocations.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import * as React from 'react'; - -import { LiftedConstraint, RelayLocation } from '../../../shared/daemon-rpc-types'; -import { messages } from '../../../shared/gettext'; -import { IRelayLocationRedux } from '../../redux/settings/reducers'; -import LocationList, { LocationSelection, LocationSelectionType } from './LocationList'; -import { RelayLocations } from './RelayLocations'; -import { SpecialLocation, SpecialLocationIcon } from './SpecialLocation'; -import { SpecialLocations } from './SpecialLocations'; - -export enum SpecialBridgeLocationType { - closestToExit = 0, -} - -interface IBridgeLocationsProps { - source: IRelayLocationRedux[]; - filter: string; - locale: string; - defaultExpandedLocations?: RelayLocation[]; - selectedValue?: LiftedConstraint<RelayLocation>; - selectedElementRef?: React.Ref<React.ReactInstance>; - onSelect?: (value: LocationSelection<SpecialBridgeLocationType>) => void; - onWillExpand?: (locationRect: DOMRect, expandedContentHeight: number) => void; - onTransitionEnd?: () => void; -} - -const BridgeLocations = React.forwardRef(function BridgeLocationsT( - props: IBridgeLocationsProps, - ref: React.Ref<LocationList<SpecialBridgeLocationType>>, -) { - const selectedValue: - | LocationSelection<SpecialBridgeLocationType> - | undefined = props.selectedValue - ? props.selectedValue === 'any' - ? { type: LocationSelectionType.special, value: SpecialBridgeLocationType.closestToExit } - : { type: LocationSelectionType.relay, value: props.selectedValue } - : undefined; - - return ( - <LocationList - ref={ref} - defaultExpandedLocations={props.defaultExpandedLocations} - selectedValue={selectedValue} - selectedElementRef={props.selectedElementRef} - onSelect={props.onSelect}> - {!props.filter && ( - <SpecialLocations> - <SpecialLocation - icon={SpecialLocationIcon.geoLocation} - value={SpecialBridgeLocationType.closestToExit} - info={messages.pgettext( - 'select-location-view', - 'The app selects a random bridge server, but servers have a higher probability the closer they are to you.', - )}> - {messages.gettext('Automatic')} - </SpecialLocation> - </SpecialLocations> - )} - <RelayLocations - source={props.source} - filter={props.filter} - locale={props.locale} - onWillExpand={props.onWillExpand} - onTransitionEnd={props.onTransitionEnd} - /> - </LocationList> - ); -}); - -export default BridgeLocations; diff --git a/gui/src/renderer/components/select-location/LocationList.tsx b/gui/src/renderer/components/select-location/LocationList.tsx index 7ba3b76740..d28780abcc 100644 --- a/gui/src/renderer/components/select-location/LocationList.tsx +++ b/gui/src/renderer/components/select-location/LocationList.tsx @@ -1,198 +1,46 @@ -import * as React from 'react'; +import React from 'react'; -import { compareRelayLocation, RelayLocation } from '../../../shared/daemon-rpc-types'; -import { RelayLocations } from './RelayLocations'; -import { SpecialLocations } from './SpecialLocations'; +import { RelayLocation } from '../../../shared/daemon-rpc-types'; +import RelayLocationList from './RelayLocationList'; +import { + CountrySpecification, + LocationList, + LocationSelection, + LocationSelectionType, + SpecialLocation, +} from './select-location-types'; +import SpecialLocationList from './SpecialLocationList'; -export enum LocationSelectionType { - relay = 'relay', - special = 'special', +interface LocationListProps<T> { + source: LocationList<T>; + selectedElementRef: React.Ref<HTMLDivElement>; + onSelect: (value: LocationSelection<T>) => void; + onExpand: (location: RelayLocation) => void; + onCollapse: (location: RelayLocation) => void; + onWillExpand: (locationRect: DOMRect, expandedContentHeight: number) => void; + onTransitionEnd: () => void; } -export type LocationSelection<SpecialValueType> = - | { type: LocationSelectionType.special; value: SpecialValueType } - | { type: LocationSelectionType.relay; value: RelayLocation }; +export default function LocationsList<T>(props: LocationListProps<T>) { + const specialLocations = props.source.filter(isSpecialLocation); + const relayLocations = props.source.filter(isRelayLocation); -interface ILocationListState<SpecialValueType> { - selectedValue?: LocationSelection<SpecialValueType>; - expandedLocations: RelayLocation[]; + return ( + <> + <SpecialLocationList {...props} source={specialLocations} /> + <RelayLocationList {...props} source={relayLocations} /> + </> + ); } -interface ILocationListProps<SpecialValueType> { - defaultExpandedLocations?: RelayLocation[]; - selectedValue?: LocationSelection<SpecialValueType>; - selectedElementRef?: React.Ref<React.ReactInstance>; - onSelect?: (value: LocationSelection<SpecialValueType>) => void; - children?: React.ReactNode; +function isSpecialLocation<T>( + location: CountrySpecification | SpecialLocation<T>, +): location is SpecialLocation<T> { + return location.type === LocationSelectionType.special; } -export default class LocationList<SpecialValueType> extends React.Component< - ILocationListProps<SpecialValueType>, - ILocationListState<SpecialValueType> -> { - public state: ILocationListState<SpecialValueType> = { - expandedLocations: [], - }; - - public selectedRelayLocationRef: React.ReactInstance | null = null; - public selectedSpecialLocationRef: React.ReactInstance | null = null; - - constructor(props: ILocationListProps<SpecialValueType>) { - super(props); - - if (props.selectedValue) { - const expandedLocations = - props.defaultExpandedLocations || - (props.selectedValue.type === LocationSelectionType.relay - ? expandRelayLocation(props.selectedValue.value) - : []); - - this.state = { - selectedValue: props.selectedValue, - expandedLocations, - }; - } - } - - public getExpandedLocations(): RelayLocation[] { - return this.state.expandedLocations; - } - - public componentDidUpdate(prevProps: ILocationListProps<SpecialValueType>) { - if (!compareLocationSelectionLoose(prevProps.selectedValue, this.props.selectedValue)) { - this.setState({ selectedValue: this.props.selectedValue }); - } - } - - public render() { - const selection = this.state.selectedValue; - const specialSelection = - selection && selection.type === LocationSelectionType.special ? selection.value : undefined; - const relaySelection = - selection && selection.type === LocationSelectionType.relay ? selection.value : undefined; - - return ( - <> - {React.Children.map(this.props.children, (child) => { - if (React.isValidElement(child)) { - if (child.type === SpecialLocations) { - return React.cloneElement(child, { - ...child.props, - selectedElementRef: this.onSpecialLocationRef, - selectedValue: specialSelection, - onSelect: this.onSelectSpecialLocation, - }); - } else if (child.type === RelayLocations) { - return React.cloneElement(child, { - ...child.props, - selectedLocation: relaySelection, - selectedElementRef: this.onRelayLocationRef, - expandedItems: this.state.expandedLocations, - onSelect: this.onSelectRelayLocation, - onExpand: this.onExpandRelayLocation, - }); - } - } - return child; - })} - </> - ); - } - - private onSpecialLocationRef = (ref: React.ReactInstance | null) => { - this.selectedSpecialLocationRef = ref; - - this.updateExternalRef(); - }; - - private onRelayLocationRef = (ref: React.ReactInstance | null) => { - this.selectedRelayLocationRef = ref; - - this.updateExternalRef(); - }; - - private updateExternalRef() { - if (this.props.selectedElementRef) { - const value = this.selectedRelayLocationRef || this.selectedSpecialLocationRef; - - if (typeof this.props.selectedElementRef === 'function') { - this.props.selectedElementRef(value); - } else { - const ref = this.props - .selectedElementRef as React.MutableRefObject<React.ReactInstance | null>; - ref.current = value; - } - } - } - - private onSelectRelayLocation = (value: RelayLocation) => { - const selectedValue: LocationSelection<SpecialValueType> = { - type: LocationSelectionType.relay, - value, - }; - - this.setState({ selectedValue }, () => { - this.notifySelection(selectedValue); - }); - }; - - private onSelectSpecialLocation = (value: SpecialValueType) => { - const selectedValue: LocationSelection<SpecialValueType> = { - type: LocationSelectionType.special, - value, - }; - - this.setState({ selectedValue }, () => { - this.notifySelection(selectedValue); - }); - }; - - private notifySelection(value: LocationSelection<SpecialValueType>) { - if (this.props.onSelect) { - this.props.onSelect(value); - } - } - - private onExpandRelayLocation = (location: RelayLocation, expand: boolean) => { - this.setState((state) => { - const expandedLocations = state.expandedLocations.filter( - (item) => !compareRelayLocation(item, location), - ); - - if (expand) { - expandedLocations.push(location); - } - - return { - ...state, - expandedLocations, - }; - }); - }; -} - -function expandRelayLocation(location: RelayLocation): RelayLocation[] { - const expandedItems: RelayLocation[] = []; - - if ('city' in location) { - expandedItems.push({ country: location.city[0] }); - } else if ('hostname' in location) { - expandedItems.push({ country: location.hostname[0] }); - expandedItems.push({ city: [location.hostname[0], location.hostname[1]] }); - } - - return expandedItems; -} - -function compareLocationSelectionLoose<SpecialValueType>( - lhs?: LocationSelection<SpecialValueType>, - rhs?: LocationSelection<SpecialValueType>, -) { - if (!lhs || !rhs) { - return lhs === rhs; - } else if (lhs.type === LocationSelectionType.relay && rhs.type === LocationSelectionType.relay) { - return compareRelayLocation(lhs.value, rhs.value); - } else { - return lhs.value === rhs.value; - } +function isRelayLocation<T>( + location: CountrySpecification | SpecialLocation<T>, +): location is CountrySpecification { + return location.type === LocationSelectionType.relay; } diff --git a/gui/src/renderer/components/select-location/LocationRow.tsx b/gui/src/renderer/components/select-location/LocationRow.tsx index 10a55a17ce..17261d79e4 100644 --- a/gui/src/renderer/components/select-location/LocationRow.tsx +++ b/gui/src/renderer/components/select-location/LocationRow.tsx @@ -10,6 +10,15 @@ import * as Cell from '../cell'; import ChevronButton from '../ChevronButton'; import { measurements, normalText } from '../common-styles'; import RelayStatusIndicator from '../RelayStatusIndicator'; +import { + CitySpecification, + CountrySpecification, + getLocationChildren, + LocationSelection, + LocationSelectionType, + LocationSpecification, + RelaySpecification, +} from './select-location-types'; interface IButtonColorProps { selected: boolean; @@ -92,70 +101,76 @@ export const StyledLocationRowLabel = styled(Cell.Label)(normalText, { fontWeight: 400, }); -interface IProps { - name: string; - active: boolean; - disabled: boolean; - location: RelayLocation; - selected: boolean; - expanded?: boolean; - expandable: boolean; - onSelect?: (location: RelayLocation) => void; - onExpand?: (location: RelayLocation, value: boolean) => void; - onWillExpand?: (locationRect: DOMRect, expandedContentHeight: number) => void; - onTransitionEnd?: () => void; - children?: React.ReactElement<IProps>[]; +interface IProps<C extends LocationSpecification> { + source: C; + selectedElementRef: React.Ref<HTMLDivElement>; + onSelect: (value: LocationSelection<never>) => void; + onExpand: (location: RelayLocation) => void; + onCollapse: (location: RelayLocation) => void; + onWillExpand: (locationRect: DOMRect, expandedContentHeight: number) => void; + onTransitionEnd: () => void; + children?: C extends RelaySpecification + ? never + : React.ReactElement< + IProps<C extends CountrySpecification ? CitySpecification : RelaySpecification> + >[]; } -function LocationRow(props: IProps, ref: React.Ref<HTMLDivElement>) { +function LocationRow<C extends LocationSpecification>(props: IProps<C>) { const hasChildren = React.Children.count(props.children) > 0; const buttonRef = useRef<HTMLButtonElement>() as React.RefObject<HTMLButtonElement>; + const expanded = 'expanded' in props.source ? props.source.expanded : undefined; const toggleCollapse = useCallback(() => { - props.onExpand?.(props.location, !props.expanded); - }, [props.onExpand, props.expanded, props.location]); + if (expanded !== undefined) { + const callback = expanded ? props.onCollapse : props.onExpand; + callback(props.source.location); + } + }, [props.onExpand, props.onCollapse, props.source.location, expanded]); - const handleClick = useCallback(() => props.onSelect?.(props.location), [ - props.onSelect, - props.location, - ]); + const handleClick = useCallback(() => { + if (!props.source.selected) { + props.onSelect({ type: LocationSelectionType.relay, value: props.source.location }); + } + }, [props.onSelect, props.source.location, props.source.selected]); const onWillExpand = useCallback( (nextHeight: number) => { const buttonRect = buttonRef.current?.getBoundingClientRect(); - if (buttonRect) { - props.onWillExpand?.(buttonRect, nextHeight); + if (expanded !== undefined && buttonRect) { + props.onWillExpand(buttonRect, nextHeight); } }, [props.onWillExpand], ); + const selectedRef = props.source.selected ? props.selectedElementRef : undefined; return ( <> - <StyledLocationRowContainer ref={ref} disabled={props.disabled}> + <StyledLocationRowContainer ref={selectedRef} disabled={props.source.disabled}> <StyledLocationRowButton as="button" ref={buttonRef} onClick={handleClick} - selected={props.selected} - location={props.location} - disabled={props.disabled}> - <RelayStatusIndicator active={props.active} selected={props.selected} /> - <StyledLocationRowLabel>{props.name}</StyledLocationRowLabel> + selected={props.source.selected} + location={props.source.location} + disabled={props.source.disabled}> + <RelayStatusIndicator active={props.source.active} selected={props.source.selected} /> + <StyledLocationRowLabel>{props.source.label}</StyledLocationRowLabel> </StyledLocationRowButton> - {hasChildren && props.expandable ? ( + {hasChildren ? ( <StyledLocationRowIcon as={ChevronButton} onClick={toggleCollapse} - up={props.expanded ?? false} - selected={props.selected} - disabled={props.disabled} - location={props.location} + up={expanded ?? false} + selected={props.source.selected} + disabled={props.source.disabled} + location={props.source.location} aria-label={sprintf( - props.expanded + expanded === true ? messages.pgettext('accessibility', 'Collapse %(location)s') : messages.pgettext('accessibility', 'Expand %(location)s'), - { location: props.name }, + { location: props.source.label }, )} /> ) : null} @@ -163,8 +178,8 @@ function LocationRow(props: IProps, ref: React.Ref<HTMLDivElement>) { {hasChildren && ( <Accordion - expanded={props.expanded} - onWillExpand={props.expandable ? onWillExpand : undefined} + expanded={expanded} + onWillExpand={onWillExpand} onTransitionEnd={props.onTransitionEnd} animationDuration={150}> <Cell.Group noMarginBottom>{props.children}</Cell.Group> @@ -174,35 +189,58 @@ function LocationRow(props: IProps, ref: React.Ref<HTMLDivElement>) { ); } -export default React.memo(React.forwardRef(LocationRow), compareProps); +export default React.memo(LocationRow, compareProps); -function compareProps(oldProps: IProps, nextProps: IProps): boolean { +function compareProps<C extends LocationSpecification>( + oldProps: IProps<C>, + nextProps: IProps<C>, +): boolean { return ( - React.Children.count(oldProps.children) === React.Children.count(nextProps.children) && - oldProps.name === nextProps.name && - oldProps.active === nextProps.active && - oldProps.disabled === nextProps.disabled && - oldProps.selected === nextProps.selected && - oldProps.expanded === nextProps.expanded && oldProps.onSelect === nextProps.onSelect && oldProps.onExpand === nextProps.onExpand && oldProps.onWillExpand === nextProps.onWillExpand && oldProps.onTransitionEnd === nextProps.onTransitionEnd && - compareRelayLocation(oldProps.location, nextProps.location) && - compareChildren(oldProps.children, nextProps.children) + compareLocation(oldProps.source, nextProps.source) + ); +} + +function compareLocation( + oldLocation: LocationSpecification, + nextLocation: LocationSpecification, +): boolean { + return ( + oldLocation.label === nextLocation.label && + oldLocation.active === nextLocation.active && + oldLocation.disabled === nextLocation.disabled && + oldLocation.selected === nextLocation.selected && + compareRelayLocation(oldLocation.location, nextLocation.location) && + compareExpanded(oldLocation, nextLocation) && + compareChildren(oldLocation, nextLocation) ); } function compareChildren( - oldChildren?: React.ReactElement<IProps>[], - nextChildren?: React.ReactElement<IProps>[], -) { - if (oldChildren === undefined || nextChildren === undefined) { - return oldChildren === nextChildren; - } + oldLocation: LocationSpecification, + nextLocation: LocationSpecification, +): boolean { + const oldChildren = getLocationChildren(oldLocation); + const nextChildren = getLocationChildren(nextLocation); + + // Children shouldn't be checked if the row is collapsed + const nextExpanded = 'expanded' in nextLocation && nextLocation.expanded; return ( - oldChildren.length === nextChildren.length && - oldChildren.every((oldChild, i) => compareProps(oldChild.props, nextChildren[i].props)) + !nextExpanded || + (oldChildren.length === nextChildren.length && + oldChildren.every((oldChild, i) => compareLocation(oldChild, nextChildren[i]))) ); } + +function compareExpanded( + oldLocation: LocationSpecification, + nextLocation: LocationSpecification, +): boolean { + const oldExpanded = 'expanded' in oldLocation && oldLocation.expanded; + const nextExpanded = 'expanded' in nextLocation && nextLocation.expanded; + return oldExpanded === nextExpanded; +} diff --git a/gui/src/renderer/components/select-location/Locations.tsx b/gui/src/renderer/components/select-location/Locations.tsx deleted file mode 100644 index acfe21a8f6..0000000000 --- a/gui/src/renderer/components/select-location/Locations.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import React from 'react'; - -import { RelayLocation } from '../../../shared/daemon-rpc-types'; -import { IRelayLocationRedux } from '../../redux/settings/reducers'; -import LocationList, { LocationSelection, LocationSelectionType } from './LocationList'; -import { DisabledReason, RelayLocations } from './RelayLocations'; - -interface ILocationsProps { - source: IRelayLocationRedux[]; - filter: string; - locale: string; - defaultExpandedLocations?: RelayLocation[]; - selectedValue?: RelayLocation; - disabledLocation?: { location: RelayLocation; reason: DisabledReason }; - selectedElementRef?: React.Ref<React.ReactInstance>; - onSelect?: (value: LocationSelection<never>) => void; - onWillExpand?: (locationRect: DOMRect, expandedContentHeight: number) => void; - onTransitionEnd?: () => void; -} - -function Locations(props: ILocationsProps, ref: React.Ref<LocationList<never>>) { - const selectedValue: LocationSelection<never> | undefined = props.selectedValue - ? { type: LocationSelectionType.relay, value: props.selectedValue } - : undefined; - - return ( - <LocationList - ref={ref} - defaultExpandedLocations={props.defaultExpandedLocations} - selectedValue={selectedValue} - selectedElementRef={props.selectedElementRef} - onSelect={props.onSelect}> - <RelayLocations - source={props.source} - filter={props.filter} - locale={props.locale} - disabledLocation={props.disabledLocation} - onWillExpand={props.onWillExpand} - onTransitionEnd={props.onTransitionEnd} - /> - </LocationList> - ); -} - -export const ExitLocations = React.forwardRef(Locations); -export const EntryLocations = React.forwardRef(Locations); diff --git a/gui/src/renderer/components/select-location/RelayLocationList.tsx b/gui/src/renderer/components/select-location/RelayLocationList.tsx new file mode 100644 index 0000000000..7ac2974cbb --- /dev/null +++ b/gui/src/renderer/components/select-location/RelayLocationList.tsx @@ -0,0 +1,54 @@ +import React from 'react'; + +import { RelayLocation, relayLocationComponents } from '../../../shared/daemon-rpc-types'; +import * as Cell from '../cell'; +import LocationRow from './LocationRow'; +import { + getLocationChildren, + LocationSelection, + LocationSpecification, + RelayList, +} from './select-location-types'; + +interface CommonProps { + selectedElementRef: React.Ref<HTMLDivElement>; + onSelect: (value: LocationSelection<never>) => void; + onExpand: (location: RelayLocation) => void; + onCollapse: (location: RelayLocation) => void; + onWillExpand: (locationRect: DOMRect, expandedContentHeight: number) => void; + onTransitionEnd: () => void; +} + +interface RelayLocationsProps extends CommonProps { + source: RelayList; +} + +export default function RelayLocationList({ source, ...props }: RelayLocationsProps) { + return ( + <Cell.Group noMarginBottom> + {source.map((country) => ( + <RelayLocation key={getLocationKey(country.location)} source={country} {...props} /> + ))} + </Cell.Group> + ); +} + +interface RelayLocationProps extends CommonProps { + source: LocationSpecification; +} + +function RelayLocation(props: RelayLocationProps) { + const children = getLocationChildren(props.source); + + return ( + <LocationRow {...props}> + {children.map((child) => ( + <RelayLocation key={getLocationKey(child.location)} {...props} source={child} /> + ))} + </LocationRow> + ); +} + +function getLocationKey(location: RelayLocation): string { + return relayLocationComponents(location).join('-'); +} diff --git a/gui/src/renderer/components/select-location/RelayLocations.tsx b/gui/src/renderer/components/select-location/RelayLocations.tsx deleted file mode 100644 index 120c7c548c..0000000000 --- a/gui/src/renderer/components/select-location/RelayLocations.tsx +++ /dev/null @@ -1,365 +0,0 @@ -import React from 'react'; -import { sprintf } from 'sprintf-js'; - -import { - compareRelayLocation, - compareRelayLocationLoose, - RelayLocation, - relayLocationComponents, -} from '../../../shared/daemon-rpc-types'; -import { messages, relayLocations } from '../../../shared/gettext'; -import { - IRelayLocationCityRedux, - IRelayLocationRedux, - IRelayLocationRelayRedux, -} from '../../redux/settings/reducers'; -import * as Cell from '../cell'; -import LocationRow from './LocationRow'; -import { City, Country, Relay } from './types'; - -export enum DisabledReason { - entry, - exit, - inactive, -} - -interface IRelayLocationsProps { - source: IRelayLocationRedux[]; - filter: string; - locale: string; - selectedLocation?: RelayLocation; - selectedElementRef?: React.Ref<React.ReactInstance>; - expandedItems?: RelayLocation[]; - disabledLocation?: { location: RelayLocation; reason: DisabledReason }; - onSelect?: (location: RelayLocation) => void; - onExpand?: (location: RelayLocation, expand: boolean) => void; - onWillExpand?: (locationRect: DOMRect, expandedContentHeight: number) => void; - onTransitionEnd?: () => void; -} - -interface IRelayLocationsState { - countries: Array<Country>; -} - -interface ICommonCellProps { - location: RelayLocation; - selected: boolean; - ref?: React.Ref<HTMLDivElement>; -} - -export class RelayLocations extends React.PureComponent< - IRelayLocationsProps, - IRelayLocationsState -> { - public state = { - countries: this.applyFilter(this.prepareRelaysForPresentation(this.props.source)), - }; - - public componentDidUpdate(prevProps: IRelayLocationsProps) { - if ( - this.props.source !== prevProps.source || - this.props.filter !== prevProps.filter || - this.props.expandedItems !== prevProps.expandedItems - ) { - this.setState({ - countries: this.applyFilter(this.prepareRelaysForPresentation(this.props.source)), - }); - } - } - - public render() { - return ( - <Cell.Group noMarginBottom> - {this.state.countries.map((relayCountry) => { - return ( - <LocationRow - key={getLocationKey(relayCountry.location)} - name={relayCountry.label} - active={relayCountry.active} - disabled={relayCountry.disabled} - expanded={relayCountry.expanded} - expandable={!this.props.filter} - onSelect={this.handleSelection} - onExpand={this.handleExpand} - onWillExpand={this.props.onWillExpand} - onTransitionEnd={this.props.onTransitionEnd} - {...this.getCommonCellProps(relayCountry.location)}> - {relayCountry.cities.map((relayCity) => { - return ( - <LocationRow - key={getLocationKey(relayCity.location)} - name={relayCity.label} - active={relayCity.active} - disabled={relayCity.disabled} - expanded={relayCity.expanded} - expandable={!this.props.filter} - onSelect={this.handleSelection} - onExpand={this.handleExpand} - onWillExpand={this.props.onWillExpand} - onTransitionEnd={this.props.onTransitionEnd} - {...this.getCommonCellProps(relayCity.location)}> - {relayCity.relays.map((relay) => { - return ( - <LocationRow - key={getLocationKey(relay.location)} - name={relay.label} - active={relay.active} - disabled={relay.disabled} - expandable={false} - onSelect={this.handleSelection} - {...this.getCommonCellProps(relay.location)} - /> - ); - })} - </LocationRow> - ); - })} - </LocationRow> - ); - })} - </Cell.Group> - ); - } - - private prepareRelaysForPresentation(relayList: IRelayLocationRedux[]): Array<Country> { - return relayList - .map((country) => { - const countryDisabled = this.isCountryDisabled(country, country.code); - const countryLocation = { country: country.code }; - - return { - ...country, - label: this.formatRowName(country.name, countryLocation, countryDisabled), - location: countryLocation, - active: countryDisabled !== DisabledReason.inactive, - disabled: countryDisabled !== undefined, - expanded: this.isExpanded(countryLocation), - cities: country.cities - .map((city) => { - const cityDisabled = - countryDisabled ?? this.isCityDisabled(city, [country.code, city.code]); - const cityLocation: RelayLocation = { city: [country.code, city.code] }; - - return { - ...city, - label: this.formatRowName(city.name, cityLocation, cityDisabled), - location: cityLocation, - active: cityDisabled !== DisabledReason.inactive, - disabled: cityDisabled !== undefined, - expanded: this.isExpanded(cityLocation), - relays: city.relays - .map((relay) => { - const relayDisabled = - countryDisabled ?? - cityDisabled ?? - this.isRelayDisabled(relay, [country.code, city.code, relay.hostname]); - const relayLocation: RelayLocation = { - hostname: [country.code, city.code, relay.hostname], - }; - - return { - ...relay, - label: this.formatRowName(relay.hostname, relayLocation, relayDisabled), - location: relayLocation, - disabled: relayDisabled !== undefined, - }; - }) - .sort((a, b) => - a.hostname.localeCompare(b.hostname, this.props.locale, { numeric: true }), - ), - }; - }) - .sort((a, b) => a.label.localeCompare(b.label, this.props.locale)), - }; - }) - .sort((a, b) => a.label.localeCompare(b.label, this.props.locale)); - } - - private applyFilter(countries: Array<Country>): Array<Country> { - if (!this.props.filter) { - return countries; - } - - const filter = this.props.filter.toLowerCase(); - return countries.reduce((countries, country) => { - const cities = RelayLocations.filterCities(country.cities, filter); - const match = - cities.length > 0 || - country.code.toLowerCase().includes(filter) || - country.name.toLowerCase().includes(filter); - return match - ? [...countries, { ...country, expanded: cities.length > 0, cities }] - : countries; - }, [] as Array<Country>); - } - - private static filterCities(cities: Array<City>, filter: string): Array<City> { - return cities.reduce((cities, city) => { - const relays = RelayLocations.filterRelays(city.relays, filter); - const match = - relays.length > 0 || - city.code.toLowerCase().includes(filter) || - city.name.toLowerCase().includes(filter); - return match ? [...cities, { ...city, expanded: relays.length > 0, relays }] : cities; - }, [] as Array<City>); - } - - private static filterRelays(relays: Array<Relay>, filter: string): Array<Relay> { - return relays.filter((relay) => relay.hostname.toLowerCase().includes(filter)); - } - - private formatRowName( - name: string, - location: RelayLocation, - disabledReason?: DisabledReason, - ): string { - const translatedName = 'hostname' in location ? name : relayLocations.gettext(name); - const disabledLocation = this.props.disabledLocation; - const matchDisabledLocation = compareRelayLocationLoose(location, disabledLocation?.location); - - let info: string | undefined; - if ( - disabledReason === DisabledReason.entry || - (matchDisabledLocation && disabledLocation?.reason === DisabledReason.entry) - ) { - info = messages.pgettext('select-location-view', 'Entry'); - } else if ( - disabledReason === DisabledReason.exit || - (matchDisabledLocation && disabledLocation?.reason === DisabledReason.exit) - ) { - info = messages.pgettext('select-location-view', 'Exit'); - } - - return info !== undefined - ? sprintf( - // TRANSLATORS: This is used for appending information about a location. - // TRANSLATORS: E.g. "Gothenburg (Entry)" if Gothenburg has been selected as the entrypoint. - // TRANSLATORS: Available placeholders: - // TRANSLATORS: %(location)s - Translated location name - // TRANSLATORS: %(info)s - Information about the location - messages.pgettext('select-location-view', '%(location)s (%(info)s)'), - { - location: translatedName, - info, - }, - ) - : translatedName; - } - - private isRelayDisabled( - relay: IRelayLocationRelayRedux, - location: [string, string, string], - ): DisabledReason | undefined { - if (!relay.active) { - return DisabledReason.inactive; - } else if ( - this.props.disabledLocation && - compareRelayLocation({ hostname: location }, this.props.disabledLocation.location) - ) { - return this.props.disabledLocation.reason; - } else { - return undefined; - } - } - - private isCityDisabled( - city: IRelayLocationCityRedux, - location: [string, string], - ): DisabledReason | undefined { - const relaysDisabled = city.relays.map((relay) => - this.isRelayDisabled(relay, [...location, relay.hostname]), - ); - if (relaysDisabled.every((status) => status === DisabledReason.inactive)) { - return DisabledReason.inactive; - } - - const disabledDueToSelection = relaysDisabled.find( - (status) => status === DisabledReason.entry || status === DisabledReason.exit, - ); - - if ( - relaysDisabled.every((status) => status !== undefined) && - disabledDueToSelection !== undefined - ) { - return disabledDueToSelection; - } - - if ( - this.props.disabledLocation && - compareRelayLocation({ city: location }, this.props.disabledLocation.location) && - city.relays.filter((relay) => relay.active).length <= 1 - ) { - return this.props.disabledLocation.reason; - } - - return undefined; - } - - private isCountryDisabled( - country: IRelayLocationRedux, - location: string, - ): DisabledReason | undefined { - const citiesDisabled = country.cities.map((city) => - this.isCityDisabled(city, [location, city.code]), - ); - if (citiesDisabled.every((status) => status === DisabledReason.inactive)) { - return DisabledReason.inactive; - } - - const disabledDueToSelection = citiesDisabled.find( - (status) => status === DisabledReason.entry || status === DisabledReason.exit, - ); - if ( - citiesDisabled.every((status) => status !== undefined) && - disabledDueToSelection !== undefined - ) { - return disabledDueToSelection; - } - - if ( - this.props.disabledLocation && - compareRelayLocation({ country: location }, this.props.disabledLocation.location) && - country.cities.flatMap((city) => city.relays).filter((relay) => relay.active).length <= 1 - ) { - return this.props.disabledLocation.reason; - } - - return undefined; - } - - private isExpanded(relayLocation: RelayLocation) { - return (this.props.expandedItems || []).some((location) => - compareRelayLocation(location, relayLocation), - ); - } - - private isSelected(relayLocation: RelayLocation) { - return compareRelayLocationLoose(this.props.selectedLocation, relayLocation); - } - - private handleSelection = (location: RelayLocation) => { - if (!compareRelayLocationLoose(this.props.selectedLocation, location)) { - if (this.props.onSelect) { - this.props.onSelect(location); - } - } - }; - - private handleExpand = (location: RelayLocation, expand: boolean) => { - if (this.props.onExpand) { - this.props.onExpand(location, expand); - } - }; - - private getCommonCellProps(location: RelayLocation): ICommonCellProps { - const selected = this.isSelected(location); - const ref = - selected && this.props.selectedElementRef ? this.props.selectedElementRef : undefined; - - return { ref: ref as React.Ref<HTMLDivElement>, selected, location }; - } -} - -function getLocationKey(location: RelayLocation): string { - return relayLocationComponents(location).join('-'); -} diff --git a/gui/src/renderer/components/ScopeBar.tsx b/gui/src/renderer/components/select-location/ScopeBar.tsx index 10b177c2c3..94c80dea7c 100644 --- a/gui/src/renderer/components/ScopeBar.tsx +++ b/gui/src/renderer/components/select-location/ScopeBar.tsx @@ -1,8 +1,8 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback } from 'react'; import styled from 'styled-components'; -import { colors } from '../../config.json'; -import { smallText } from './common-styles'; +import { colors } from '../../../config.json'; +import { smallText } from '../common-styles'; const StyledScopeBar = styled.div({ display: 'flex', @@ -13,25 +13,18 @@ const StyledScopeBar = styled.div({ }); interface IScopeBarProps { - defaultSelectedIndex?: number; + selectedIndex: number; onChange?: (selectedIndex: number) => void; className?: string; children: React.ReactElement<IScopeBarItemProps>[]; } export function ScopeBar(props: IScopeBarProps) { - const [selectedIndex, setSelectedIndex] = useState(props.defaultSelectedIndex ?? 0); - - const onClick = useCallback((index: number) => setSelectedIndex(index), []); - useEffect(() => { - props.onChange?.(selectedIndex); - }, [selectedIndex]); - const children = React.Children.map(props.children, (child, index) => { if (React.isValidElement(child)) { return React.cloneElement(child, { - selected: index === selectedIndex, - onClick, + selected: index === props.selectedIndex, + onClick: props.onChange, index, }); } else { diff --git a/gui/src/renderer/components/select-location/SelectLocation.tsx b/gui/src/renderer/components/select-location/SelectLocation.tsx index 0af4a0d8d5..66d70bc174 100644 --- a/gui/src/renderer/components/select-location/SelectLocation.tsx +++ b/gui/src/renderer/components/select-location/SelectLocation.tsx @@ -1,15 +1,14 @@ -import React from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import { sprintf } from 'sprintf-js'; import { colors } from '../../../config.json'; -import { - LiftedConstraint, - Ownership, - RelayLocation, - TunnelProtocol, -} from '../../../shared/daemon-rpc-types'; +import { Ownership } from '../../../shared/daemon-rpc-types'; import { messages } from '../../../shared/gettext'; -import { IRelayLocationRedux } from '../../redux/settings/reducers'; +import { useAppContext } from '../../context'; +import { useHistory } from '../../lib/history'; +import { RoutePath } from '../../lib/routes'; +import { useNormalBridgeSettings, useNormalRelaySettings } from '../../lib/utilityHooks'; +import { useSelector } from '../../redux/store'; import { CustomScrollbarsRef } from '../CustomScrollbars'; import ImageView from '../ImageView'; import { BackAction } from '../KeyboardNavigation'; @@ -21,12 +20,22 @@ import { NavigationScrollbars, TitleBarItem, } from '../NavigationBar'; -import { ScopeBarItem } from '../ScopeBar'; -import { HeaderSubTitle, HeaderTitle } from '../SettingsHeader'; -import BridgeLocations, { SpecialBridgeLocationType } from './BridgeLocations'; -import LocationList, { LocationSelection, LocationSelectionType } from './LocationList'; -import { EntryLocations, ExitLocations } from './Locations'; -import { DisabledReason } from './RelayLocations'; +import LocationList from './LocationList'; +import { ScopeBar, ScopeBarItem } from './ScopeBar'; +import { + useExpandedLocations, + useOnSelectBridgeLocation, + useOnSelectLocation, + useRelayList, +} from './select-location-hooks'; +import { + LocationSelectionType, + LocationType, + SpecialBridgeLocationType, + SpecialLocation, + SpecialLocationIcon, +} from './select-location-types'; +import { useSelectLocationContext } from './SelectLocationContainer'; import { StyledClearFilterButton, StyledContent, @@ -34,420 +43,282 @@ import { StyledFilterIconButton, StyledFilterRow, StyledNavigationBarAttachment, - StyledScopeBar, - StyledSearchBar, - StyledSettingsHeader, } from './SelectLocationStyles'; import { SpacePreAllocationView } from './SpacePreAllocationView'; -interface IProps { - locale: string; - selectedExitLocation?: RelayLocation; - selectedEntryLocation?: RelayLocation; - selectedBridgeLocation?: LiftedConstraint<RelayLocation>; - relayLocations: IRelayLocationRedux[]; - bridgeLocations: IRelayLocationRedux[]; - allowEntrySelection: boolean; - tunnelProtocol: LiftedConstraint<TunnelProtocol>; - providers: string[]; - ownership: Ownership; - onClose: () => void; - onViewFilter: () => void; - onSelectExitLocation: (location: RelayLocation) => void; - onSelectEntryLocation: (location: RelayLocation) => void; - onSelectBridgeLocation: (location: RelayLocation) => void; - onSelectClosestToExit: () => void; - onClearProviders: () => void; - onClearOwnership: () => void; -} - -enum LocationScope { - entry = 0, - exit, -} - -interface IState { - headingHeight: number; - locationScope: LocationScope; - filter: string; -} +export default function SelectLocation() { + const history = useHistory(); + const { updateRelaySettings } = useAppContext(); + const { scrollViewRef, saveScrollPosition, resetScrollPositions } = useScrollPosition(); + const { resetExpandedLocations } = useExpandedLocations(); + const spacePreAllocationViewRef = useRef() as React.RefObject<SpacePreAllocationView>; + const { locationType, setLocationType } = useSelectLocationContext(); -interface ISelectLocationSnapshot { - scrollPosition: [number, number]; - expandedLocations: RelayLocation[]; -} + const relaySettings = useNormalRelaySettings(); + const ownership = relaySettings?.ownership ?? Ownership.any; + const providers = relaySettings?.providers ?? []; -export default class SelectLocation extends React.Component<IProps, IState> { - public state = { headingHeight: 0, locationScope: LocationScope.exit, filter: '' }; + const onClose = useCallback(() => history.dismiss(), [history]); + const onViewFilter = useCallback(() => history.push(RoutePath.filter), [history]); - private scrollView = React.createRef<CustomScrollbarsRef>(); - private spacePreAllocationViewRef = React.createRef<SpacePreAllocationView>(); - private selectedExitLocationRef = React.createRef<React.ReactInstance>(); - private selectedEntryLocationRef = React.createRef<React.ReactInstance>(); - private selectedBridgeLocationRef = React.createRef<React.ReactInstance>(); + const tunnelProtocol = relaySettings?.tunnelProtocol ?? 'any'; + const bridgeState = useSelector((state) => state.settings.bridgeState); + const allowEntrySelection = + (tunnelProtocol === 'openvpn' && bridgeState === 'on') || + (tunnelProtocol !== 'openvpn' && relaySettings?.wireguard.useMultihop); - private exitLocationList = React.createRef<LocationList<never>>(); - private entryLocationList = React.createRef<LocationList<never>>(); - private bridgeLocationList = React.createRef<LocationList<SpecialBridgeLocationType>>(); + const onClearProviders = useCallback(async () => { + resetScrollPositions(); + resetExpandedLocations(); + await updateRelaySettings({ normal: { providers: [] } }); + }, []); - private snapshotByScope: Partial<Record<LocationScope, ISelectLocationSnapshot>> = {}; + const onClearOwnership = useCallback(async () => { + resetScrollPositions(); + resetExpandedLocations(); + await updateRelaySettings({ normal: { ownership: Ownership.any } }); + }, []); - private headerRef = React.createRef<HTMLHeadingElement>(); + const changeLocationType = useCallback( + (locationType: LocationType) => { + saveScrollPosition(); + setLocationType(locationType); + }, + [saveScrollPosition], + ); - public componentDidMount() { - this.scrollToSelectedCell(); - this.setState((state) => ({ - headingHeight: this.headerRef.current?.offsetHeight ?? state.headingHeight, - })); - } + const showOwnershipFilter = ownership !== Ownership.any; + const showProvidersFilter = providers.length > 0; + const showFilters = showOwnershipFilter || showProvidersFilter; + return ( + <BackAction icon="close" action={onClose}> + <Layout> + <SettingsContainer> + <NavigationContainer> + <NavigationBar alwaysDisplayBarTitle> + <NavigationItems> + <TitleBarItem> + { + // TRANSLATORS: Title label in navigation bar + messages.pgettext('select-location-nav', 'Select location') + } + </TitleBarItem> - public componentDidUpdate( - _prevProps: IProps, - prevState: IState, - snapshot?: ISelectLocationSnapshot, - ) { - if (this.state.locationScope !== prevState.locationScope) { - this.restoreScrollPosition(this.state.locationScope); + <StyledFilterIconButton + onClick={onViewFilter} + aria-label={messages.gettext('Filter')}> + <ImageView + source="icon-filter-round" + tintColor={colors.white40} + tintHoverColor={colors.white60} + height={24} + width={24} + /> + </StyledFilterIconButton> + </NavigationItems> + </NavigationBar> - if (snapshot) { - this.snapshotByScope[prevState.locationScope] = snapshot; - } - } - } + <StyledNavigationBarAttachment> + {allowEntrySelection && ( + <ScopeBar selectedIndex={locationType} onChange={changeLocationType}> + <ScopeBarItem>{messages.pgettext('select-location-view', 'Entry')}</ScopeBarItem> + <ScopeBarItem>{messages.pgettext('select-location-view', 'Exit')}</ScopeBarItem> + </ScopeBar> + )} - public getSnapshotBeforeUpdate( - prevProps: IProps, - prevState: IState, - ): ISelectLocationSnapshot | undefined { - const scrollView = this.scrollView.current; - const locationList = this.getLocationListRef(prevProps, prevState); + {showFilters && ( + <StyledFilterRow> + {messages.pgettext('select-location-view', 'Filtered:')} - if (scrollView && locationList) { - return { - scrollPosition: scrollView.getScrollPosition(), - expandedLocations: locationList.getExpandedLocations(), - }; - } else { - return undefined; - } - } + {showOwnershipFilter && ( + <StyledFilter> + {ownershipFilterLabel(ownership)} + <StyledClearFilterButton + aria-label={messages.gettext('Clear')} + onClick={onClearOwnership}> + <ImageView + height={16} + width={16} + source="icon-close" + tintColor={colors.white60} + tintHoverColor={colors.white80} + /> + </StyledClearFilterButton> + </StyledFilter> + )} - public render() { - const showOwnershipFilter = this.props.ownership !== Ownership.any; - const showProvidersFilter = this.props.providers.length > 0; - const showFilters = showOwnershipFilter || showProvidersFilter; - return ( - <BackAction icon="close" action={this.props.onClose}> - <Layout> - <SettingsContainer> - <NavigationContainer> - <NavigationBar> - <NavigationItems> - <TitleBarItem> - { - // TRANSLATORS: Title label in navigation bar - messages.pgettext('select-location-nav', 'Select location') - } - </TitleBarItem> + {showProvidersFilter && ( + <StyledFilter> + {sprintf( + messages.pgettext( + 'select-location-view', + 'Providers: %(numberOfProviders)d', + ), + { numberOfProviders: providers.length }, + )} + <StyledClearFilterButton + aria-label={messages.gettext('Clear')} + onClick={onClearProviders}> + <ImageView + height={16} + width={16} + source="icon-close" + tintColor={colors.white60} + tintHoverColor={colors.white80} + /> + </StyledClearFilterButton> + </StyledFilter> + )} + </StyledFilterRow> + )} + </StyledNavigationBarAttachment> - <StyledFilterIconButton - onClick={this.props.onViewFilter} - aria-label={messages.gettext('Filter')}> - <ImageView - source="icon-filter-round" - tintColor={colors.white40} - tintHoverColor={colors.white60} - height={24} - width={24} - /> - </StyledFilterIconButton> - </NavigationItems> - </NavigationBar> - <NavigationScrollbars ref={this.scrollView}> - <SpacePreAllocationView ref={this.spacePreAllocationViewRef}> - <StyledNavigationBarAttachment top={-this.state.headingHeight}> - <StyledSettingsHeader ref={this.headerRef}> - <HeaderTitle> - { - // TRANSLATORS: Heading in select location view - messages.pgettext('select-location-view', 'Select location') - } - </HeaderTitle> - </StyledSettingsHeader> + <NavigationScrollbars ref={scrollViewRef}> + <SpacePreAllocationView ref={spacePreAllocationViewRef}> + <StyledContent> + <SelectLocationContent + spacePreAllocationViewRef={spacePreAllocationViewRef} + scrollViewRef={scrollViewRef} + /> + </StyledContent> + </SpacePreAllocationView> + </NavigationScrollbars> + </NavigationContainer> + </SettingsContainer> + </Layout> + </BackAction> + ); +} - {this.props.allowEntrySelection && ( - <StyledScopeBar - defaultSelectedIndex={this.state.locationScope} - onChange={this.onChangeLocationScope}> - <ScopeBarItem> - {messages.pgettext('select-location-view', 'Entry')} - </ScopeBarItem> - <ScopeBarItem> - {messages.pgettext('select-location-view', 'Exit')} - </ScopeBarItem> - </StyledScopeBar> - )} +function ownershipFilterLabel(ownership: Ownership): string { + switch (ownership) { + case Ownership.mullvadOwned: + return messages.pgettext('filter-view', 'Owned'); + case Ownership.rented: + return messages.pgettext('filter-view', 'Rented'); + default: + throw new Error('Only owned and rented should make label visible'); + } +} - {this.renderHeaderSubtitle()} +interface SelectLocationContentProps { + spacePreAllocationViewRef: React.RefObject<SpacePreAllocationView>; + scrollViewRef: React.RefObject<CustomScrollbarsRef>; +} - {showFilters && ( - <StyledFilterRow> - {messages.pgettext('select-location-view', 'Filtered:')} +function SelectLocationContent(props: SelectLocationContentProps) { + const { locationType, selectedLocationRef } = useSelectLocationContext(); + const relayList = useRelayList(); + const { expandLocation, collapseLocation } = useExpandedLocations(); + const onSelectLocation = useOnSelectLocation(); + const onSelectBridgeLocation = useOnSelectBridgeLocation(); - {showOwnershipFilter && ( - <StyledFilter> - {this.ownershipFilterLabel()} - <StyledClearFilterButton - aria-label={messages.gettext('Clear')} - onClick={this.props.onClearOwnership}> - <ImageView - height={16} - width={16} - source="icon-close" - tintColor={colors.white60} - tintHoverColor={colors.white80} - /> - </StyledClearFilterButton> - </StyledFilter> - )} + const relaySettings = useNormalRelaySettings(); + const bridgeSettings = useNormalBridgeSettings(); - {showProvidersFilter && ( - <StyledFilter> - {sprintf( - messages.pgettext( - 'select-location-view', - 'Providers: %(numberOfProviders)d', - ), - { - numberOfProviders: this.props.providers.length, - }, - )} - <StyledClearFilterButton - aria-label={messages.gettext('Clear')} - onClick={this.props.onClearProviders}> - <ImageView - height={16} - width={16} - source="icon-close" - tintColor={colors.white60} - tintHoverColor={colors.white80} - /> - </StyledClearFilterButton> - </StyledFilter> - )} - </StyledFilterRow> - )} + const onWillExpand = useCallback((locationRect: DOMRect, expandedContentHeight: number) => { + locationRect.height += expandedContentHeight; + props.spacePreAllocationViewRef.current?.allocate(expandedContentHeight); + props.scrollViewRef.current?.scrollIntoView(locationRect); + }, []); - <StyledSearchBar searchTerm={this.state.filter} onSearch={this.updateFilter} /> - </StyledNavigationBarAttachment> + const resetHeight = useCallback(() => { + props.spacePreAllocationViewRef.current?.reset(); + }, []); - <StyledContent>{this.renderLocationList()}</StyledContent> - </SpacePreAllocationView> - </NavigationScrollbars> - </NavigationContainer> - </SettingsContainer> - </Layout> - </BackAction> + if (locationType === LocationType.exit) { + return ( + <LocationList + key={locationType} + source={relayList} + selectedElementRef={selectedLocationRef} + onSelect={onSelectLocation} + onExpand={expandLocation} + onCollapse={collapseLocation} + onWillExpand={onWillExpand} + onTransitionEnd={resetHeight} + /> ); - } - - public restoreScrollPosition(scope: LocationScope) { - const snapshot = this.snapshotByScope[scope]; - - if (snapshot) { - this.scrollToPosition(...snapshot.scrollPosition); - } else { - this.scrollToSelectedCell(); - } - } + } else if (relaySettings?.tunnelProtocol !== 'openvpn') { + return ( + <LocationList + key={locationType} + source={relayList} + selectedElementRef={selectedLocationRef} + onSelect={onSelectLocation} + onExpand={expandLocation} + onCollapse={collapseLocation} + onWillExpand={onWillExpand} + onTransitionEnd={resetHeight} + /> + ); + } else { + const automaticItem: SpecialLocation<SpecialBridgeLocationType> = { + type: LocationSelectionType.special, + label: messages.gettext('Automatic'), + icon: SpecialLocationIcon.geoLocation, + info: messages.pgettext( + 'select-location-view', + 'The app selects a random bridge server, but servers have a higher probability the closer they are to you.', + ), + value: SpecialBridgeLocationType.closestToExit, + selected: bridgeSettings?.location === 'any', + disabled: false, + }; - private ownershipFilterLabel(): string { - switch (this.props.ownership) { - case Ownership.mullvadOwned: - return messages.pgettext('filter-view', 'Owned'); - case Ownership.rented: - return messages.pgettext('filter-view', 'Rented'); - default: - throw new Error('Only owned and rented should make label visible'); - } + const bridgeRelayList = [automaticItem, ...relayList]; + return ( + <LocationList + key={locationType} + source={bridgeRelayList} + selectedElementRef={selectedLocationRef} + onSelect={onSelectBridgeLocation} + onExpand={expandLocation} + onCollapse={collapseLocation} + onWillExpand={onWillExpand} + onTransitionEnd={resetHeight} + /> + ); } +} - private getLocationListRef(prevProps: IProps, prevState: IState) { - if (prevState.locationScope === LocationScope.exit) { - return this.exitLocationList.current; - } else if (prevProps.tunnelProtocol === 'wireguard') { - return this.entryLocationList.current; - } else { - return this.bridgeLocationList.current; - } - } +function useScrollPosition() { + const { + activeFilter, + locationType, + scrollPositions, + selectedLocationRef, + } = useSelectLocationContext(); + const scrollViewRef = useRef<CustomScrollbarsRef>(null); - private getSelectedLocationRef() { - if (this.state.locationScope === LocationScope.exit) { - return this.selectedExitLocationRef.current; - } else if (this.props.tunnelProtocol === 'wireguard') { - return this.selectedEntryLocationRef.current; - } else { - return this.selectedBridgeLocationRef.current; + const saveScrollPosition = useCallback(() => { + const scrollPosition = scrollViewRef.current?.getScrollPosition(); + if (scrollPositions.current) { + scrollPositions.current[locationType] = scrollPosition; } - } + }, [locationType]); - private renderHeaderSubtitle() { - if (this.props.allowEntrySelection) { - if (this.props.tunnelProtocol === 'openvpn') { - return ( - <HeaderSubTitle> - {messages.pgettext( - 'select-location-view', - 'While connected, your traffic will be routed through two secure locations, the entry point (a bridge server) and the exit point (a VPN server).', - )} - </HeaderSubTitle> - ); - } else { - return ( - <HeaderSubTitle> - {messages.pgettext( - 'select-location-view', - 'While connected, your traffic will be routed through two secure locations, the entry point and the exit point (needs to be two different VPN servers).', - )} - </HeaderSubTitle> - ); + const resetScrollPositions = useCallback(() => { + for (const locationTypeVariant of [LocationType.entry, LocationType.exit]) { + if ( + scrollPositions.current && + (scrollPositions.current[locationTypeVariant] || locationTypeVariant === locationType) + ) { + scrollPositions.current[locationTypeVariant] = [0, 0]; } - } else { - return null; - } - } - - private renderLocationList() { - if (this.state.locationScope === LocationScope.exit) { - const disabledLocation = this.props.selectedEntryLocation - ? { - location: this.props.selectedEntryLocation, - reason: DisabledReason.entry, - } - : undefined; - return ( - <ExitLocations - ref={this.exitLocationList} - filter={this.state.filter} - source={this.props.relayLocations} - locale={this.props.locale} - defaultExpandedLocations={this.getExpandedLocationsFromSnapshot()} - selectedValue={this.props.selectedExitLocation} - selectedElementRef={this.selectedExitLocationRef} - disabledLocation={disabledLocation} - onSelect={this.onSelectExitLocation} - onWillExpand={this.onWillExpand} - onTransitionEnd={this.resetHeight} - /> - ); - } else if (this.props.tunnelProtocol === 'any' || this.props.tunnelProtocol === 'wireguard') { - const disabledLocation = this.props.selectedExitLocation - ? { - location: this.props.selectedExitLocation, - reason: DisabledReason.exit, - } - : undefined; - return ( - <EntryLocations - ref={this.entryLocationList} - filter={this.state.filter} - source={this.props.relayLocations} - locale={this.props.locale} - defaultExpandedLocations={this.getExpandedLocationsFromSnapshot()} - selectedValue={this.props.selectedEntryLocation} - selectedElementRef={this.selectedEntryLocationRef} - disabledLocation={disabledLocation} - onSelect={this.onSelectEntryLocation} - onWillExpand={this.onWillExpand} - onTransitionEnd={this.resetHeight} - /> - ); - } else { - return ( - <BridgeLocations - ref={this.bridgeLocationList} - filter={this.state.filter} - source={this.props.bridgeLocations} - locale={this.props.locale} - defaultExpandedLocations={this.getExpandedLocationsFromSnapshot()} - selectedValue={this.props.selectedBridgeLocation} - selectedElementRef={this.selectedBridgeLocationRef} - onSelect={this.onSelectBridgeLocation} - onWillExpand={this.onWillExpand} - onTransitionEnd={this.resetHeight} - /> - ); } - } + }, [locationType]); - private resetHeight = () => { - this.spacePreAllocationViewRef.current?.reset(); - }; - - private getExpandedLocationsFromSnapshot(): RelayLocation[] | undefined { - const snapshot = this.snapshotByScope[this.state.locationScope]; - if (snapshot) { - return snapshot.expandedLocations; + useEffect(() => { + const scrollPosition = scrollPositions.current?.[locationType]; + if (scrollPosition) { + scrollViewRef.current?.scrollTo(...scrollPosition); + } else if (selectedLocationRef.current) { + scrollViewRef.current?.scrollToElement(selectedLocationRef.current, 'middle'); } else { - return undefined; - } - } - - private scrollToPosition(x: number, y: number) { - const scrollView = this.scrollView.current; - if (scrollView) { - scrollView.scrollTo(x, y); - } - } - - private scrollToSelectedCell() { - const ref = this.getSelectedLocationRef(); - const scrollView = this.scrollView.current; - - if (scrollView) { - if (ref) { - if (ref instanceof HTMLElement) { - scrollView.scrollToElement(ref, 'middle'); - } - } else { - scrollView.scrollToTop(); - } - } - } - - private onChangeLocationScope = (locationScope: LocationScope) => { - this.setState({ locationScope }); - }; - - private onSelectExitLocation = (location: LocationSelection<never>) => { - if (location.type === LocationSelectionType.relay) { - this.props.onSelectExitLocation(location.value); - } - }; - - private onSelectEntryLocation = (location: LocationSelection<never>) => { - this.props.onSelectEntryLocation(location.value); - }; - - private onSelectBridgeLocation = (location: LocationSelection<SpecialBridgeLocationType>) => { - if (location.type === LocationSelectionType.relay) { - this.props.onSelectBridgeLocation(location.value); - } else if ( - location.type === LocationSelectionType.special && - location.value === SpecialBridgeLocationType.closestToExit - ) { - this.props.onSelectClosestToExit(); + scrollViewRef.current?.scrollToTop(); } - }; - - private onWillExpand = (locationRect: DOMRect, expandedContentHeight: number) => { - locationRect.height += expandedContentHeight; - this.spacePreAllocationViewRef.current?.allocate(expandedContentHeight); - this.scrollView.current?.scrollIntoView(locationRect); - }; + }, [locationType, activeFilter]); - private updateFilter = (filter: string) => { - this.setState({ filter }); - }; + return { scrollViewRef, saveScrollPosition, resetScrollPositions }; } diff --git a/gui/src/renderer/components/select-location/SelectLocationContainer.tsx b/gui/src/renderer/components/select-location/SelectLocationContainer.tsx new file mode 100644 index 0000000000..71e898a229 --- /dev/null +++ b/gui/src/renderer/components/select-location/SelectLocationContainer.tsx @@ -0,0 +1,63 @@ +import React, { useContext, useMemo, useRef, useState } from 'react'; + +import { Ownership, RelayLocation } from '../../../shared/daemon-rpc-types'; +import { useNormalBridgeSettings, useNormalRelaySettings } from '../../lib/utilityHooks'; +import { defaultExpandedLocations } from './select-location-helpers'; +import { LocationType } from './select-location-types'; +import SelectLocation from './SelectLocation'; + +type ExpandedLocations = Partial<Record<LocationType, Array<RelayLocation>>>; +type ScrollPosition = [number, number]; + +interface SelectLocationContext { + locationType: LocationType; + setLocationType: (locationType: LocationType) => void; + activeFilter: boolean; + expandedLocations: ExpandedLocations; + setExpandedLocations: ( + arg: ExpandedLocations | ((prev: ExpandedLocations) => ExpandedLocations), + ) => void; + scrollPositions: React.RefObject<Partial<Record<LocationType, ScrollPosition>>>; + selectedLocationRef: React.RefObject<HTMLDivElement>; +} + +const selectLocationContext = React.createContext<SelectLocationContext | undefined>(undefined); + +export function useSelectLocationContext() { + return useContext(selectLocationContext)!; +} + +export default function SelectLocationContainer() { + const relaySettings = useNormalRelaySettings(); + const bridgeSettings = useNormalBridgeSettings(); + const [locationType, setLocationType] = useState(LocationType.exit); + + const selectedLocationRef = useRef<HTMLDivElement>(null); + + const [expandedLocations, setExpandedLocations] = useState< + Partial<Record<LocationType, Array<RelayLocation>>> + >(() => defaultExpandedLocations(relaySettings, bridgeSettings)); + const scrollPositions = useRef<Partial<Record<LocationType, ScrollPosition>>>({}); + + const ownershipActive = relaySettings !== undefined && relaySettings.ownership !== Ownership.any; + const providersActive = relaySettings !== undefined && relaySettings.providers.length > 0; + + const value = useMemo( + () => ({ + locationType, + setLocationType, + activeFilter: ownershipActive || providersActive, + expandedLocations, + setExpandedLocations, + scrollPositions, + selectedLocationRef, + }), + [locationType, relaySettings?.ownership, relaySettings?.providers, expandedLocations], + ); + + return ( + <selectLocationContext.Provider value={value}> + <SelectLocation /> + </selectLocationContext.Provider> + ); +} diff --git a/gui/src/renderer/components/select-location/SelectLocationStyles.tsx b/gui/src/renderer/components/select-location/SelectLocationStyles.tsx index 00c8d02d97..ba5c0c5f24 100644 --- a/gui/src/renderer/components/select-location/SelectLocationStyles.tsx +++ b/gui/src/renderer/components/select-location/SelectLocationStyles.tsx @@ -2,13 +2,7 @@ import styled from 'styled-components'; import { colors } from '../../../config.json'; import { tinyText } from '../common-styles'; -import { ScopeBar } from '../ScopeBar'; import SearchBar from '../SearchBar'; -import SettingsHeader from '../SettingsHeader'; - -export const StyledScopeBar = styled(ScopeBar)({ - marginTop: '8px', -}); export const StyledContent = styled.div({ display: 'flex', @@ -17,13 +11,9 @@ export const StyledContent = styled.div({ overflow: 'visible', }); -export const StyledNavigationBarAttachment = styled.div({}, (props: { top: number }) => ({ - position: 'sticky', - top: `${props.top}px`, - padding: '8px 18px 8px 16px', - backgroundColor: colors.darkBlue, - zIndex: 1, -})); +export const StyledNavigationBarAttachment = styled.div({ + padding: '0px 16px 8px', +}); export const StyledFilterIconButton = styled.button({ justifySelf: 'end', @@ -34,16 +24,10 @@ export const StyledFilterIconButton = styled.button({ backgroundColor: 'transparent', }); -export const StyledSettingsHeader = styled(SettingsHeader)({ - paddingLeft: '6px', - paddingBottom: '11px', -}); - export const StyledFilterRow = styled.div({ ...tinyText, color: colors.white, - marginLeft: '6px', - marginBottom: '8px', + margin: '10px 6px 2px', }); export const StyledFilter = styled.div({ diff --git a/gui/src/renderer/components/select-location/SpecialLocation.tsx b/gui/src/renderer/components/select-location/SpecialLocation.tsx deleted file mode 100644 index 06d3a8b408..0000000000 --- a/gui/src/renderer/components/select-location/SpecialLocation.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import React from 'react'; -import styled from 'styled-components'; - -import { colors } from '../../../config.json'; -import { messages } from '../../../shared/gettext'; -import * as Cell from '../cell'; -import InfoButton from '../InfoButton'; -import { - StyledLocationRowButton, - StyledLocationRowContainer, - StyledLocationRowIcon, - StyledLocationRowLabel, -} from './LocationRow'; - -const StyledLocationRowContainerWithMargin = styled(StyledLocationRowContainer)({ - marginBottom: 1, -}); - -const StyledSpecialLocationIcon = styled(Cell.Icon)({ - flex: 0, - marginLeft: '2px', - marginRight: '8px', -}); - -const StyledSpecialLocationInfoButton = styled(InfoButton)({ - margin: 0, - padding: '0 25px', -}); - -export enum SpecialLocationIcon { - geoLocation = 'icon-nearest', -} - -interface ISpecialLocationProps<T> { - icon: SpecialLocationIcon; - value: T; - isSelected?: boolean; - onSelect?: (value: T) => void; - info?: string; - forwardedRef?: React.Ref<HTMLButtonElement>; - children?: React.ReactNode; -} - -export class SpecialLocation<T> extends React.Component<ISpecialLocationProps<T>> { - public render() { - return ( - <StyledLocationRowContainerWithMargin> - <StyledLocationRowButton onClick={this.onSelect} selected={this.props.isSelected ?? false}> - <StyledSpecialLocationIcon - source={this.props.isSelected ? 'icon-tick' : this.props.icon} - tintColor={colors.white} - height={22} - width={22} - /> - <StyledLocationRowLabel>{this.props.children}</StyledLocationRowLabel> - </StyledLocationRowButton> - <StyledLocationRowIcon - as={StyledSpecialLocationInfoButton} - message={this.props.info} - selected={this.props.isSelected ?? false} - aria-label={messages.pgettext('accessibility', 'info')} - /> - </StyledLocationRowContainerWithMargin> - ); - } - - private onSelect = () => { - if (!this.props.isSelected && this.props.onSelect) { - this.props.onSelect(this.props.value); - } - }; -} diff --git a/gui/src/renderer/components/select-location/SpecialLocationList.tsx b/gui/src/renderer/components/select-location/SpecialLocationList.tsx new file mode 100644 index 0000000000..6667f14fa9 --- /dev/null +++ b/gui/src/renderer/components/select-location/SpecialLocationList.tsx @@ -0,0 +1,84 @@ +import React, { useCallback } from 'react'; +import styled from 'styled-components'; + +import { colors } from '../../../config.json'; +import { messages } from '../../../shared/gettext'; +import * as Cell from '../cell'; +import InfoButton from '../InfoButton'; +import { + StyledLocationRowButton, + StyledLocationRowContainer, + StyledLocationRowIcon, + StyledLocationRowLabel, +} from './LocationRow'; +import { LocationSelection, LocationSelectionType, SpecialLocation } from './select-location-types'; + +interface SpecialLocationsProps<T> { + source: Array<SpecialLocation<T>>; + selectedElementRef: React.Ref<HTMLDivElement>; + onSelect: (value: LocationSelection<T>) => void; +} + +export default function SpecialLocationList<T>({ source, ...props }: SpecialLocationsProps<T>) { + return ( + <> + {source.map((location) => ( + <SpecialLocationRow key={location.label} source={location} {...props} /> + ))} + </> + ); +} + +const StyledLocationRowContainerWithMargin = styled(StyledLocationRowContainer)({ + marginBottom: 1, +}); + +const StyledSpecialLocationIcon = styled(Cell.Icon)({ + flex: 0, + marginLeft: '2px', + marginRight: '8px', +}); + +const StyledSpecialLocationInfoButton = styled(InfoButton)({ + margin: 0, + padding: '0 25px', + backgroundColor: colors.blue, +}); + +interface SpecialLocationRowProps<T> { + source: SpecialLocation<T>; + selectedElementRef: React.Ref<HTMLDivElement>; + onSelect: (value: LocationSelection<T>) => void; +} + +function SpecialLocationRow<T>(props: SpecialLocationRowProps<T>) { + const onSelect = useCallback(() => { + if (!props.source.selected) { + props.onSelect({ + type: LocationSelectionType.special, + value: props.source.value, + }); + } + }, []); + + const selectedRef = props.source.selected ? props.selectedElementRef : undefined; + return ( + <StyledLocationRowContainerWithMargin ref={selectedRef}> + <StyledLocationRowButton onClick={onSelect} selected={props.source.selected}> + <StyledSpecialLocationIcon + source={props.source.selected ? 'icon-tick' : props.source.icon} + tintColor={colors.white} + height={22} + width={22} + /> + <StyledLocationRowLabel>{props.source.label}</StyledLocationRowLabel> + </StyledLocationRowButton> + <StyledLocationRowIcon + as={StyledSpecialLocationInfoButton} + message={props.source.info} + selected={props.source.selected} + aria-label={messages.pgettext('accessibility', 'info')} + /> + </StyledLocationRowContainerWithMargin> + ); +} diff --git a/gui/src/renderer/components/select-location/SpecialLocations.tsx b/gui/src/renderer/components/select-location/SpecialLocations.tsx deleted file mode 100644 index fb65f9c6ae..0000000000 --- a/gui/src/renderer/components/select-location/SpecialLocations.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react'; - -import { SpecialLocation } from './SpecialLocation'; - -interface ISpecialLocationsProps<T> { - children: React.ReactNode; - selectedValue?: T; - selectedElementRef?: React.Ref<SpecialLocation<T>>; - onSelect?: (value: T) => void; -} - -export function SpecialLocations<T>(props: ISpecialLocationsProps<T>) { - return ( - <> - {React.Children.map(props.children, (child) => { - if (React.isValidElement(child) && child.type === SpecialLocation) { - const isSelected = props.selectedValue === child.props.value; - - return React.cloneElement(child, { - ...child.props, - forwardedRef: isSelected ? props.selectedElementRef : undefined, - onSelect: props.onSelect, - isSelected, - }); - } else { - return undefined; - } - })} - </> - ); -} diff --git a/gui/src/renderer/components/select-location/select-location-helpers.ts b/gui/src/renderer/components/select-location/select-location-helpers.ts new file mode 100644 index 0000000000..380023af06 --- /dev/null +++ b/gui/src/renderer/components/select-location/select-location-helpers.ts @@ -0,0 +1,183 @@ +import { sprintf } from 'sprintf-js'; + +import { + compareRelayLocation, + compareRelayLocationLoose, + LiftedConstraint, + RelayLocation, +} from '../../../shared/daemon-rpc-types'; +import { messages, relayLocations } from '../../../shared/gettext'; +import { + IRelayLocationCityRedux, + IRelayLocationRedux, + IRelayLocationRelayRedux, + NormalBridgeSettingsRedux, + NormalRelaySettingsRedux, +} from '../../redux/settings/reducers'; +import { DisabledReason, LocationType } from './select-location-types'; + +export function isSelected( + relayLocation: RelayLocation, + selected?: LiftedConstraint<RelayLocation>, +) { + return selected !== 'any' && compareRelayLocationLoose(selected, relayLocation); +} + +export function isExpanded(relayLocation: RelayLocation, expandedLocations?: Array<RelayLocation>) { + return ( + expandedLocations?.some((location) => compareRelayLocation(location, relayLocation)) ?? false + ); +} + +// Calculates which locations should be expanded based on selected location +export function defaultExpandedLocations( + relaySettings?: NormalRelaySettingsRedux, + bridgeSettings?: NormalBridgeSettingsRedux, +) { + const expandedLocations: Partial<Record<LocationType, Array<RelayLocation>>> = {}; + + const exitLocation = relaySettings?.location; + if (exitLocation && exitLocation !== 'any') { + expandedLocations[LocationType.exit] = expandRelayLocation(exitLocation); + } + + if (relaySettings?.tunnelProtocol === 'openvpn') { + const bridgeLocation = bridgeSettings?.location; + if (bridgeLocation && bridgeLocation !== 'any') { + expandedLocations[LocationType.entry] = expandRelayLocation(bridgeLocation); + } + } else if (relaySettings?.wireguard.useMultihop) { + const entryLocation = relaySettings?.wireguard.entryLocation; + if (entryLocation && entryLocation !== 'any') { + expandedLocations[LocationType.entry] = expandRelayLocation(entryLocation); + } + } + + return expandedLocations; +} + +// Expands a relay location and its parents +function expandRelayLocation(location: RelayLocation): RelayLocation[] { + if ('city' in location) { + return [{ country: location.city[0] }]; + } else if ('hostname' in location) { + return [ + { country: location.hostname[0] }, + { city: [location.hostname[0], location.hostname[1]] }, + ]; + } else { + return []; + } +} + +export function formatRowName( + name: string, + location: RelayLocation, + disabledReason?: DisabledReason, +): string { + const translatedName = 'hostname' in location ? name : relayLocations.gettext(name); + + let info: string | undefined; + if (disabledReason === DisabledReason.entry) { + info = messages.pgettext('select-location-view', 'Entry'); + } else if (disabledReason === DisabledReason.exit) { + info = messages.pgettext('select-location-view', 'Exit'); + } + + return info !== undefined + ? sprintf( + // TRANSLATORS: This is used for appending information about a location. + // TRANSLATORS: E.g. "Gothenburg (Entry)" if Gothenburg has been selected as the entrypoint. + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(location)s - Translated location name + // TRANSLATORS: %(info)s - Information about the location + messages.pgettext('select-location-view', '%(location)s (%(info)s)'), + { + location: translatedName, + info, + }, + ) + : translatedName; +} + +export function isRelayDisabled( + relay: IRelayLocationRelayRedux, + location: [string, string, string], + disabledLocation?: { location: RelayLocation; reason: DisabledReason }, +): DisabledReason | undefined { + if (!relay.active) { + return DisabledReason.inactive; + } else if ( + disabledLocation && + compareRelayLocation({ hostname: location }, disabledLocation.location) + ) { + return disabledLocation.reason; + } else { + return undefined; + } +} + +export function isCityDisabled( + city: IRelayLocationCityRedux, + location: [string, string], + disabledLocation?: { location: RelayLocation; reason: DisabledReason }, +): DisabledReason | undefined { + const relaysDisabled = city.relays.map((relay) => + isRelayDisabled(relay, [...location, relay.hostname]), + ); + if (relaysDisabled.every((status) => status === DisabledReason.inactive)) { + return DisabledReason.inactive; + } + + const disabledDueToSelection = relaysDisabled.find( + (status) => status === DisabledReason.entry || status === DisabledReason.exit, + ); + + if ( + relaysDisabled.every((status) => status !== undefined) && + disabledDueToSelection !== undefined + ) { + return disabledDueToSelection; + } + + if ( + disabledLocation && + compareRelayLocation({ city: location }, disabledLocation.location) && + city.relays.filter((relay) => relay.active).length <= 1 + ) { + return disabledLocation.reason; + } + + return undefined; +} + +export function isCountryDisabled( + country: IRelayLocationRedux, + location: string, + disabledLocation?: { location: RelayLocation; reason: DisabledReason }, +): DisabledReason | undefined { + const citiesDisabled = country.cities.map((city) => isCityDisabled(city, [location, city.code])); + if (citiesDisabled.every((status) => status === DisabledReason.inactive)) { + return DisabledReason.inactive; + } + + const disabledDueToSelection = citiesDisabled.find( + (status) => status === DisabledReason.entry || status === DisabledReason.exit, + ); + if ( + citiesDisabled.every((status) => status !== undefined) && + disabledDueToSelection !== undefined + ) { + return disabledDueToSelection; + } + + if ( + disabledLocation && + compareRelayLocation({ country: location }, disabledLocation.location) && + country.cities.flatMap((city) => city.relays).filter((relay) => relay.active).length <= 1 + ) { + return disabledLocation.reason; + } + + return undefined; +} diff --git a/gui/src/renderer/components/select-location/select-location-hooks.ts b/gui/src/renderer/components/select-location/select-location-hooks.ts new file mode 100644 index 0000000000..747dfac9c3 --- /dev/null +++ b/gui/src/renderer/components/select-location/select-location-hooks.ts @@ -0,0 +1,279 @@ +import { useCallback, useMemo } from 'react'; + +import BridgeSettingsBuilder from '../../../shared/bridge-settings-builder'; +import { + compareRelayLocation, + RelayLocation, + RelaySettingsUpdate, +} from '../../../shared/daemon-rpc-types'; +import log from '../../../shared/logging'; +import RelaySettingsBuilder from '../../../shared/relay-settings-builder'; +import { useAppContext } from '../../context'; +import { createWireguardRelayUpdater } from '../../lib/constraint-updater'; +import { filterLocations } from '../../lib/filter-locations'; +import { useHistory } from '../../lib/history'; +import { useNormalBridgeSettings, useNormalRelaySettings } from '../../lib/utilityHooks'; +import { IRelayLocationRedux } from '../../redux/settings/reducers'; +import { useSelector } from '../../redux/store'; +import { + defaultExpandedLocations, + formatRowName, + isCityDisabled, + isCountryDisabled, + isExpanded, + isRelayDisabled, + isSelected, +} from './select-location-helpers'; +import { + DisabledReason, + LocationList, + LocationSelection, + LocationSelectionType, + LocationType, + SpecialBridgeLocationType, +} from './select-location-types'; +import { useSelectLocationContext } from './SelectLocationContainer'; + +function useFullRelayList(): Array<IRelayLocationRedux> { + const { locationType } = useSelectLocationContext(); + const relaySettings = useNormalRelaySettings(); + const relayLocations = useSelector((state) => state.settings.relayLocations); + const bridgeLocations = useSelector((state) => state.settings.bridgeLocations); + return locationType === LocationType.entry && relaySettings?.tunnelProtocol === 'openvpn' + ? bridgeLocations + : relayLocations; +} + +// Return all locations that matches both the set filters and the search term. +function useFilteredRelays(): Array<IRelayLocationRedux> { + const relayList = useFullRelayList(); + const relaySettings = useNormalRelaySettings(); + + const filteredRelayList = useMemo( + () => + relaySettings + ? filterLocations(relayList, relaySettings.providers, relaySettings.ownership) + : relayList, + [relaySettings, relayList, relaySettings?.providers, relaySettings?.ownership], + ); + + return filteredRelayList; +} + +// Return all RelayLocations that should be expanded +export function useExpandedLocations() { + const relaySettings = useNormalRelaySettings(); + const bridgeSettings = useNormalBridgeSettings(); + const { locationType, expandedLocations, setExpandedLocations } = useSelectLocationContext(); + + const expandedLocationsForType = useMemo(() => expandedLocations[locationType], [ + expandedLocations, + locationType, + ]); + + const expandLocation = useCallback( + (location: RelayLocation) => { + setExpandedLocations((expandedLocations) => ({ + ...expandedLocations, + [locationType]: [...(expandedLocations[locationType] ?? []), location], + })); + }, + [locationType], + ); + + const collapseLocation = useCallback( + (location: RelayLocation) => { + setExpandedLocations((expandedLocations) => ({ + ...expandedLocations, + [locationType]: expandedLocations[locationType]!.filter( + (item) => !compareRelayLocation(location, item), + ), + })); + }, + [locationType], + ); + + const resetExpandedLocations = useCallback(() => { + setExpandedLocations(defaultExpandedLocations(relaySettings, bridgeSettings)); + }, [relaySettings, bridgeSettings]); + + return { + expandedLocations: expandedLocationsForType, + expandLocation, + collapseLocation, + resetExpandedLocations, + }; +} + +// Return the final filtered and formatted relay list. This should be the only place in the app +// where processing of the relay list is performed. +export function useRelayList(): LocationList<never> { + const locale = useSelector((state) => state.userInterface.locale); + const { expandedLocations } = useExpandedLocations(); + const relayList = useFilteredRelays(); + const selectedLocation = useSelectedLocation(); + const disabledLocation = useDisabledLocation(); + + return relayList + .map((country) => { + const countryLocation = { country: country.code }; + const countryDisabled = isCountryDisabled(country, countryLocation.country, disabledLocation); + + return { + ...country, + type: LocationSelectionType.relay as const, + label: formatRowName(country.name, countryLocation, countryDisabled), + location: countryLocation, + active: countryDisabled !== DisabledReason.inactive, + disabled: countryDisabled !== undefined, + expanded: isExpanded(countryLocation, expandedLocations), + selected: isSelected(countryLocation, selectedLocation), + cities: country.cities + .map((city) => { + const cityLocation: RelayLocation = { city: [country.code, city.code] }; + const cityDisabled = + countryDisabled ?? isCityDisabled(city, cityLocation.city, disabledLocation); + + return { + ...city, + label: formatRowName(city.name, cityLocation, cityDisabled), + location: cityLocation, + active: cityDisabled !== DisabledReason.inactive, + disabled: cityDisabled !== undefined, + expanded: isExpanded(cityLocation, expandedLocations), + selected: isSelected(cityLocation, selectedLocation), + relays: city.relays + .map((relay) => { + const relayLocation: RelayLocation = { + hostname: [country.code, city.code, relay.hostname], + }; + const relayDisabled = + countryDisabled ?? + cityDisabled ?? + isRelayDisabled(relay, relayLocation.hostname, disabledLocation); + + return { + ...relay, + label: formatRowName(relay.hostname, relayLocation, relayDisabled), + location: relayLocation, + disabled: relayDisabled !== undefined, + selected: isSelected(relayLocation, selectedLocation), + }; + }) + .sort((a, b) => a.hostname.localeCompare(b.hostname, locale, { numeric: true })), + }; + }) + .sort((a, b) => a.label.localeCompare(b.label, locale)), + }; + }) + .sort((a, b) => a.label.localeCompare(b.label, locale)); +} + +function useDisabledLocation() { + const { locationType } = useSelectLocationContext(); + const relaySettings = useNormalRelaySettings(); + + if (relaySettings?.tunnelProtocol !== 'openvpn' && relaySettings?.wireguard.useMultihop) { + if (locationType === LocationType.exit && relaySettings?.wireguard.entryLocation !== 'any') { + return { + location: relaySettings?.wireguard.entryLocation, + reason: DisabledReason.entry, + }; + } else if (locationType === LocationType.entry && relaySettings?.location !== 'any') { + return { location: relaySettings?.location, reason: DisabledReason.exit }; + } + } + + return undefined; +} + +// Returns the selected location for the current tunnel protocol and location type +function useSelectedLocation() { + const { locationType } = useSelectLocationContext(); + const relaySettings = useNormalRelaySettings(); + const bridgeSettings = useNormalBridgeSettings(); + + if (locationType === LocationType.exit) { + return relaySettings?.location === 'any' ? undefined : relaySettings?.location; + } else if (relaySettings?.tunnelProtocol !== 'openvpn') { + return relaySettings?.wireguard.entryLocation === 'any' + ? undefined + : relaySettings?.wireguard.entryLocation; + } else { + return bridgeSettings?.location; + } +} + +export function useOnSelectLocation() { + const history = useHistory(); + const { updateRelaySettings } = useAppContext(); + const { locationType } = useSelectLocationContext(); + const baseRelaySettings = useSelector((state) => state.settings.relaySettings); + + const onSelectLocation = useCallback( + async (relayUpdate: RelaySettingsUpdate) => { + // dismiss the view first + history.dismiss(); + try { + await updateRelaySettings(relayUpdate); + } catch (e) { + const error = e as Error; + log.error(`Failed to select the exit location: ${error.message}`); + } + }, + [history], + ); + + const onSelectExitLocation = useCallback( + async (relayLocation: LocationSelection<never>) => { + const relayUpdate = RelaySettingsBuilder.normal() + .location.fromRaw(relayLocation.value) + .build(); + await onSelectLocation(relayUpdate); + }, + [onSelectLocation], + ); + const onSelectEntryLocation = useCallback( + async (entryLocation: LocationSelection<never>) => { + const relayUpdate = createWireguardRelayUpdater(baseRelaySettings) + .tunnel.wireguard((wireguard) => wireguard.entryLocation.exact(entryLocation.value)) + .build(); + await onSelectLocation(relayUpdate); + }, + [onSelectLocation], + ); + + return locationType === LocationType.exit ? onSelectExitLocation : onSelectEntryLocation; +} + +export function useOnSelectBridgeLocation() { + const history = useHistory(); + const { updateBridgeSettings } = useAppContext(); + + return useCallback( + async (location: LocationSelection<SpecialBridgeLocationType>) => { + // dismiss the view first + history.dismiss(); + + let bridgeUpdate; + if (location.type === LocationSelectionType.relay) { + bridgeUpdate = new BridgeSettingsBuilder().location.fromRaw(location.value).build(); + } else if ( + location.type === LocationSelectionType.special && + location.value === SpecialBridgeLocationType.closestToExit + ) { + bridgeUpdate = new BridgeSettingsBuilder().location.any().build(); + } + + if (bridgeUpdate) { + try { + await updateBridgeSettings(bridgeUpdate); + } catch (e) { + const error = e as Error; + log.error(`Failed to select the bridge location: ${error.message}`); + } + } + }, + [history, updateBridgeSettings], + ); +} diff --git a/gui/src/renderer/components/select-location/select-location-types.ts b/gui/src/renderer/components/select-location/select-location-types.ts new file mode 100644 index 0000000000..3fbd2ef0c2 --- /dev/null +++ b/gui/src/renderer/components/select-location/select-location-types.ts @@ -0,0 +1,87 @@ +import { RelayLocation } from '../../../shared/daemon-rpc-types'; +import { + IRelayLocationCityRedux, + IRelayLocationRedux, + IRelayLocationRelayRedux, +} from '../../redux/settings/reducers'; + +export enum LocationType { + entry = 0, + exit, +} + +export enum LocationSelectionType { + relay = 'relay', + special = 'special', +} + +export type LocationSelection<T> = + | { type: LocationSelectionType.special; value: T } + | { type: LocationSelectionType.relay; value: RelayLocation }; + +export type LocationList<T> = Array<CountrySpecification | SpecialLocation<T>>; +export type RelayList = Array<CountrySpecification>; + +export enum SpecialBridgeLocationType { + closestToExit = 0, +} + +export enum SpecialLocationIcon { + geoLocation = 'icon-nearest', +} + +export interface SpecialLocation<T> { + type: LocationSelectionType.special; + label: string; + icon: SpecialLocationIcon; + info: string; + value: T; + disabled: boolean; + selected: boolean; +} + +export type LocationSpecification = CountrySpecification | CitySpecification | RelaySpecification; + +export interface CountrySpecification extends Omit<IRelayLocationRedux, 'cities'> { + type: LocationSelectionType.relay; + label: string; + location: RelayLocation; + active: boolean; + disabled: boolean; + expanded: boolean; + selected: boolean; + cities: Array<CitySpecification>; +} + +export interface CitySpecification extends Omit<IRelayLocationCityRedux, 'relays'> { + label: string; + location: RelayLocation; + active: boolean; + disabled: boolean; + expanded: boolean; + selected: boolean; + relays: Array<RelaySpecification>; +} + +export interface RelaySpecification extends IRelayLocationRelayRedux { + label: string; + location: RelayLocation; + disabled: boolean; + selected: boolean; +} + +export enum DisabledReason { + entry, + exit, + inactive, +} + +export function getLocationChildren(location: LocationSpecification): Array<LocationSpecification> { + if ('cities' in location) { + return location.cities; + } else if ('relays' in location) { + return location.relays; + } else { + return []; + } +} diff --git a/gui/src/renderer/components/select-location/types.ts b/gui/src/renderer/components/select-location/types.ts deleted file mode 100644 index dd0d563401..0000000000 --- a/gui/src/renderer/components/select-location/types.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { RelayLocation } from '../../../shared/daemon-rpc-types'; -import { - IRelayLocationCityRedux, - IRelayLocationRedux, - IRelayLocationRelayRedux, -} from '../../redux/settings/reducers'; - -export interface Relay extends IRelayLocationRelayRedux { - label: string; - location: RelayLocation; - disabled: boolean; -} - -export interface City extends Omit<IRelayLocationCityRedux, 'relays'> { - label: string; - location: RelayLocation; - active: boolean; - disabled: boolean; - expanded: boolean; - relays: Array<Relay>; -} - -export interface Country extends Omit<IRelayLocationRedux, 'cities'> { - label: string; - location: RelayLocation; - active: boolean; - disabled: boolean; - expanded: boolean; - cities: Array<City>; -} diff --git a/gui/src/renderer/containers/SelectLocationPage.tsx b/gui/src/renderer/containers/SelectLocationPage.tsx deleted file mode 100644 index d459696ed4..0000000000 --- a/gui/src/renderer/containers/SelectLocationPage.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { useCallback, useMemo } from 'react'; - -import BridgeSettingsBuilder from '../../shared/bridge-settings-builder'; -import { LiftedConstraint, Ownership, RelayLocation } from '../../shared/daemon-rpc-types'; -import log from '../../shared/logging'; -import RelaySettingsBuilder from '../../shared/relay-settings-builder'; -import SelectLocation from '../components/select-location/SelectLocation'; -import { useAppContext } from '../context'; -import { createWireguardRelayUpdater } from '../lib/constraint-updater'; -import filterLocations from '../lib/filter-locations'; -import { useHistory } from '../lib/history'; -import { RoutePath } from '../lib/routes'; -import { useSelector } from '../redux/store'; - -export default function SelectLocationPage() { - const history = useHistory(); - - const { updateRelaySettings, connectTunnel, updateBridgeSettings } = useAppContext(); - - const locale = useSelector((state) => state.userInterface.locale); - const settings = useSelector((state) => state.settings); - const { relaySettings, bridgeSettings, bridgeState } = settings; - - const providers = useMemo( - () => ('normal' in relaySettings ? relaySettings.normal.providers : []), - [relaySettings], - ); - - const ownership = useMemo( - () => ('normal' in relaySettings ? relaySettings.normal.ownership : Ownership.any), - [relaySettings], - ); - - const tunnelProtocol = useMemo( - () => ('normal' in relaySettings ? relaySettings.normal.tunnelProtocol : 'any'), - [relaySettings], - ); - - const selectedExitLocation = useMemo<RelayLocation | undefined>(() => { - if ('normal' in relaySettings) { - const exitLocation = relaySettings.normal.location; - if (exitLocation !== 'any') { - return exitLocation; - } - } - return undefined; - }, [relaySettings]); - - const selectedBridgeLocation = useMemo<LiftedConstraint<RelayLocation> | undefined>(() => { - return tunnelProtocol === 'openvpn' && 'normal' in bridgeSettings - ? bridgeSettings.normal.location - : undefined; - }, [tunnelProtocol, bridgeSettings]); - - const multihopEnabled = useMemo(() => { - return ( - tunnelProtocol !== 'openvpn' && - 'normal' in relaySettings && - relaySettings.normal.wireguard.useMultihop - ); - }, [tunnelProtocol, relaySettings]); - - const selectedEntryLocation = useMemo<RelayLocation | undefined>(() => { - if (multihopEnabled && 'normal' in relaySettings) { - const entryLocation = relaySettings.normal.wireguard.entryLocation; - if (multihopEnabled && entryLocation !== 'any') { - return entryLocation; - } - } - return undefined; - }, [relaySettings, multihopEnabled]); - - const allowEntrySelection = useMemo(() => { - return ( - (tunnelProtocol === 'openvpn' && bridgeState === 'on') || - ((tunnelProtocol === 'any' || tunnelProtocol === 'wireguard') && multihopEnabled) - ); - }, [tunnelProtocol, bridgeState, multihopEnabled]); - - const relayLocations = filterLocations(settings.relayLocations, providers, ownership); - const bridgeLocations = filterLocations(settings.bridgeLocations, providers, ownership); - - const onClose = useCallback(() => history.dismiss(), [history]); - const onViewFilter = useCallback(() => history.push(RoutePath.filter), [history]); - const onSelectExitLocation = useCallback( - async (relayLocation: RelayLocation) => { - // dismiss the view first - history.dismiss(); - try { - const relayUpdate = RelaySettingsBuilder.normal().location.fromRaw(relayLocation).build(); - - await updateRelaySettings(relayUpdate); - await connectTunnel(); - } catch (e) { - const error = e as Error; - log.error(`Failed to select the exit location: ${error.message}`); - } - }, - [connectTunnel, updateRelaySettings, history], - ); - const onSelectEntryLocation = useCallback( - async (entryLocation: RelayLocation) => { - // dismiss the view first - history.dismiss(); - - const relayUpdate = createWireguardRelayUpdater(relaySettings) - .tunnel.wireguard((wireguard) => wireguard.entryLocation.exact(entryLocation)) - .build(); - - try { - await updateRelaySettings(relayUpdate); - } catch (e) { - const error = e as Error; - log.error('Failed to select the entry location', error.message); - } - }, - [history, relaySettings, updateRelaySettings], - ); - const onSelectBridgeLocation = useCallback( - async (bridgeLocation: RelayLocation) => { - // dismiss the view first - history.dismiss(); - - try { - await updateBridgeSettings( - new BridgeSettingsBuilder().location.fromRaw(bridgeLocation).build(), - ); - } catch (e) { - const error = e as Error; - log.error(`Failed to select the bridge location: ${error.message}`); - } - }, - [history, updateBridgeSettings], - ); - const onSelectClosestToExit = useCallback(async () => { - history.dismiss(); - - try { - await updateBridgeSettings(new BridgeSettingsBuilder().location.any().build()); - } catch (e) { - const error = e as Error; - log.error(`Failed to set the bridge location to closest to exit: ${error.message}`); - } - }, [updateBridgeSettings, history]); - - const onClearProviders = useCallback(async () => { - await updateRelaySettings({ normal: { providers: [] } }); - }, [updateRelaySettings]); - - const onClearOwnership = useCallback(async () => { - await updateRelaySettings({ normal: { ownership: Ownership.any } }); - }, [updateRelaySettings]); - - return ( - <SelectLocation - locale={locale} - selectedExitLocation={selectedExitLocation} - selectedEntryLocation={selectedEntryLocation} - selectedBridgeLocation={selectedBridgeLocation} - relayLocations={relayLocations} - bridgeLocations={bridgeLocations} - allowEntrySelection={allowEntrySelection} - tunnelProtocol={tunnelProtocol} - providers={providers} - ownership={ownership} - onClose={onClose} - onViewFilter={onViewFilter} - onSelectExitLocation={onSelectExitLocation} - onSelectEntryLocation={onSelectEntryLocation} - onSelectBridgeLocation={onSelectBridgeLocation} - onSelectClosestToExit={onSelectClosestToExit} - onClearProviders={onClearProviders} - onClearOwnership={onClearOwnership} - /> - ); -} diff --git a/gui/src/renderer/lib/filter-locations.ts b/gui/src/renderer/lib/filter-locations.ts index 9459c06530..f9e78e40b1 100644 --- a/gui/src/renderer/lib/filter-locations.ts +++ b/gui/src/renderer/lib/filter-locations.ts @@ -1,7 +1,11 @@ -import { Ownership } from '../../shared/daemon-rpc-types'; -import { IRelayLocationRedux } from '../redux/settings/reducers'; +import { Ownership, RelayLocation } from '../../shared/daemon-rpc-types'; +import { + IRelayLocationCityRedux, + IRelayLocationRedux, + IRelayLocationRelayRedux, +} from '../redux/settings/reducers'; -export default function filterLocations( +export function filterLocations( locations: IRelayLocationRedux[], providers: string[], ownership: Ownership, @@ -24,36 +28,86 @@ function filterLocationsByOwnership( } const expectOwned = ownership === Ownership.mullvadOwned; - return locations - .map((country) => ({ - ...country, - cities: country.cities - .map((city) => ({ - ...city, - relays: city.relays.filter((relay) => relay.owned === expectOwned), - })) - .filter((city) => city.relays.length > 0), - })) - .filter((country) => country.cities.length > 0); + return filterLocationsImpl(locations, (relay) => relay.owned === expectOwned); } function filterLocationsByProvider( locations: IRelayLocationRedux[], providers: string[], ): IRelayLocationRedux[] { - if (providers.length === 0) { - return locations; - } + return providers.length === 0 + ? locations + : filterLocationsImpl(locations, (relay) => providers.includes(relay.provider)); +} +function filterLocationsImpl( + locations: Array<IRelayLocationRedux>, + filter: (relay: IRelayLocationRelayRedux) => boolean, +): Array<IRelayLocationRedux> { return locations .map((country) => ({ ...country, cities: country.cities - .map((city) => ({ - ...city, - relays: city.relays.filter((relay) => providers.includes(relay.provider)), - })) + .map((city) => ({ ...city, relays: city.relays.filter(filter) })) .filter((city) => city.relays.length > 0), })) .filter((country) => country.cities.length > 0); } + +export function searchForLocations( + countries: Array<IRelayLocationRedux>, + searchTerm: string, +): Array<IRelayLocationRedux> { + return countries.reduce((countries, country) => { + const matchingCities = searchCities(country.cities, searchTerm); + const expanded = matchingCities.length > 0; + const match = search(country.code, searchTerm) || search(country.name, searchTerm); + const resultingCities = match ? country.cities : matchingCities; + return expanded || match ? [...countries, { ...country, cities: resultingCities }] : countries; + }, [] as Array<IRelayLocationRedux>); +} + +function searchCities( + cities: Array<IRelayLocationCityRedux>, + searchTerm: string, +): Array<IRelayLocationCityRedux> { + return cities.reduce((cities, city) => { + const matchingRelays = city.relays.filter((relay) => search(searchTerm, relay.hostname)); + const expanded = matchingRelays.length > 0; + const match = search(city.code, searchTerm) || search(city.name, searchTerm); + const resultingRelays = match ? city.relays : matchingRelays; + return expanded || match ? [...cities, { ...city, relays: resultingRelays }] : cities; + }, [] as Array<IRelayLocationCityRedux>); +} + +export function getLocationsExpandedBySearch( + countries: Array<IRelayLocationRedux>, + searchTerm: string, +): Array<RelayLocation> { + return countries.reduce((locations, country) => { + const cityLocations = getCityLocationsExpandecBySearch( + country.cities, + country.code, + searchTerm, + ); + const location = { country: country.code }; + const expanded = cityLocations.length > 0; + return expanded ? [...locations, ...cityLocations, location] : locations; + }, [] as Array<RelayLocation>); +} + +function getCityLocationsExpandecBySearch( + cities: Array<IRelayLocationCityRedux>, + countryCode: string, + searchTerm: string, +): Array<RelayLocation> { + return cities.reduce((locations, city) => { + const expanded = city.relays.filter((relay) => search(searchTerm, relay.hostname)).length > 0; + const location: RelayLocation = { city: [countryCode, city.code] }; + return expanded ? [...locations, location] : locations; + }, [] as Array<RelayLocation>); +} + +function search(searchTerm: string, value: string): boolean { + return value.toLowerCase().includes(searchTerm.toLowerCase()); +} diff --git a/gui/src/renderer/lib/utilityHooks.ts b/gui/src/renderer/lib/utilityHooks.ts index 59686f1d6d..378a6d5ae5 100644 --- a/gui/src/renderer/lib/utilityHooks.ts +++ b/gui/src/renderer/lib/utilityHooks.ts @@ -1,5 +1,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useSelector } from '../redux/store'; + export function useMounted() { const mountedRef = useRef(false); const isMounted = useCallback(() => mountedRef.current, []); @@ -53,3 +55,13 @@ export function useBoolean(initialValue = false) { return [value, setTrue, setFalse, toggle] as const; } + +export function useNormalRelaySettings() { + const relaySettings = useSelector((state) => state.settings.relaySettings); + return 'normal' in relaySettings ? relaySettings.normal : undefined; +} + +export function useNormalBridgeSettings() { + const bridgeSettings = useSelector((state) => state.settings.bridgeSettings); + return 'normal' in bridgeSettings ? bridgeSettings.normal : undefined; +} diff --git a/gui/src/renderer/redux/settings/reducers.ts b/gui/src/renderer/redux/settings/reducers.ts index e4437563ec..dfa68b97c8 100644 --- a/gui/src/renderer/redux/settings/reducers.ts +++ b/gui/src/renderer/redux/settings/reducers.ts @@ -16,24 +16,30 @@ import { import { IGuiSettingsState } from '../../../shared/gui-settings-state'; import { ReduxAction } from '../store'; +export type NormalRelaySettingsRedux = { + tunnelProtocol: LiftedConstraint<TunnelProtocol>; + location: LiftedConstraint<RelayLocation>; + providers: string[]; + ownership: Ownership; + openvpn: { + port: LiftedConstraint<number>; + protocol: LiftedConstraint<RelayProtocol>; + }; + wireguard: { + port: LiftedConstraint<number>; + ipVersion: LiftedConstraint<IpVersion>; + useMultihop: boolean; + entryLocation: LiftedConstraint<RelayLocation>; + }; +}; + +export type NormalBridgeSettingsRedux = { + location: LiftedConstraint<RelayLocation>; +}; + export type RelaySettingsRedux = | { - normal: { - tunnelProtocol: LiftedConstraint<TunnelProtocol>; - location: LiftedConstraint<RelayLocation>; - providers: string[]; - ownership: Ownership; - openvpn: { - port: LiftedConstraint<number>; - protocol: LiftedConstraint<RelayProtocol>; - }; - wireguard: { - port: LiftedConstraint<number>; - ipVersion: LiftedConstraint<IpVersion>; - useMultihop: boolean; - entryLocation: LiftedConstraint<RelayLocation>; - }; - }; + normal: NormalRelaySettingsRedux; } | { customTunnelEndpoint: { @@ -45,9 +51,7 @@ export type RelaySettingsRedux = export type BridgeSettingsRedux = | { - normal: { - location: LiftedConstraint<RelayLocation>; - }; + normal: NormalBridgeSettingsRedux; } | { custom: ProxySettings; diff --git a/gui/src/shared/daemon-rpc-types.ts b/gui/src/shared/daemon-rpc-types.ts index 6b75ee7d3c..735bb91224 100644 --- a/gui/src/shared/daemon-rpc-types.ts +++ b/gui/src/shared/daemon-rpc-types.ts @@ -162,7 +162,7 @@ export type TunnelProtocol = 'wireguard' | 'openvpn'; export type IpVersion = 'ipv4' | 'ipv6'; -interface IRelaySettingsNormal<OpenVpn, Wireguard> { +export interface IRelaySettingsNormal<OpenVpn, Wireguard> { location: Constraint<RelayLocation>; tunnelProtocol: Constraint<TunnelProtocol>; providers: string[]; |
