diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2019-09-17 14:26:37 +0200 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2019-09-18 16:11:57 +0200 |
| commit | 7e46c618e39339544500db6f9fcf0920988ba03b (patch) | |
| tree | 5f891d6a3d244f36d7dbc1811229545cbd3b627a /gui/src | |
| parent | 0fbbfb9201ab841992c3cb021ed6a808db4ef6ef (diff) | |
| download | mullvadvpn-7e46c618e39339544500db6f9fcf0920988ba03b.tar.xz mullvadvpn-7e46c618e39339544500db6f9fcf0920988ba03b.zip | |
Refactor LocationList
Diffstat (limited to 'gui/src')
| -rw-r--r-- | gui/src/renderer/components/BridgeLocations.tsx | 57 | ||||
| -rw-r--r-- | gui/src/renderer/components/ExitLocations.tsx | 38 | ||||
| -rw-r--r-- | gui/src/renderer/components/LocationList.tsx | 309 | ||||
| -rw-r--r-- | gui/src/renderer/components/SelectLocation.tsx | 154 |
4 files changed, 437 insertions, 121 deletions
diff --git a/gui/src/renderer/components/BridgeLocations.tsx b/gui/src/renderer/components/BridgeLocations.tsx new file mode 100644 index 0000000000..7d464e0926 --- /dev/null +++ b/gui/src/renderer/components/BridgeLocations.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; +import { LiftedConstraint, RelayLocation } from '../../shared/daemon-rpc-types'; +import { messages } from '../../shared/gettext'; +import { IRelayLocationRedux } from '../redux/settings/reducers'; +import LocationList, { + LocationSelection, + LocationSelectionType, + RelayLocations, + SpecialLocation, + SpecialLocationIcon, + SpecialLocations, +} from './LocationList'; + +export enum SpecialBridgeLocationType { + closestToExit = 0, +} + +interface IBridgeLocationsProps { + source: IRelayLocationRedux[]; + defaultExpandedLocations?: RelayLocation[]; + selectedValue?: LiftedConstraint<RelayLocation>; + selectedElementRef?: React.Ref<React.ReactInstance>; + onSelect?: (value: LocationSelection<SpecialBridgeLocationType>) => 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}> + <SpecialLocations> + <SpecialLocation + icon={SpecialLocationIcon.geoLocation} + value={SpecialBridgeLocationType.closestToExit}> + {messages.pgettext('select-location-view', 'Closest to exit server')} + </SpecialLocation> + </SpecialLocations> + <RelayLocations source={props.source} /> + </LocationList> + ); +}); + +export default BridgeLocations; diff --git a/gui/src/renderer/components/ExitLocations.tsx b/gui/src/renderer/components/ExitLocations.tsx new file mode 100644 index 0000000000..9fcc1e8ad4 --- /dev/null +++ b/gui/src/renderer/components/ExitLocations.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import { RelayLocation } from '../../shared/daemon-rpc-types'; +import { IRelayLocationRedux } from '../redux/settings/reducers'; +import LocationList, { + LocationSelection, + LocationSelectionType, + RelayLocations, +} from './LocationList'; + +interface IExitLocationsProps { + source: IRelayLocationRedux[]; + defaultExpandedLocations?: RelayLocation[]; + selectedValue?: RelayLocation; + selectedElementRef?: React.Ref<React.ReactInstance>; + onSelect?: (value: LocationSelection<never>) => void; +} + +const ExitLocations = React.forwardRef(function ExitLocationsT( + props: IExitLocationsProps, + 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} /> + </LocationList> + ); +}); + +export default ExitLocations; diff --git a/gui/src/renderer/components/LocationList.tsx b/gui/src/renderer/components/LocationList.tsx index 72a3b64f75..99adcd65b4 100644 --- a/gui/src/renderer/components/LocationList.tsx +++ b/gui/src/renderer/components/LocationList.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; -import { Component, View } from 'reactxp'; +import { Component, Styles, View } from 'reactxp'; +import { colors } from '../../config.json'; import { compareRelayLocation, compareRelayLocationLoose, @@ -8,49 +9,266 @@ import { } from '../../shared/daemon-rpc-types'; import { countries, relayLocations } from '../../shared/gettext'; import { IRelayLocationRedux } from '../redux/settings/reducers'; +import * as Cell from './Cell'; import CityRow from './CityRow'; import CountryRow from './CountryRow'; import RelayRow from './RelayRow'; -interface IProps { - relayLocations: IRelayLocationRedux[]; - selectedLocation?: RelayLocation; - onSelect: (location: RelayLocation) => void; +const styles = { + selectedCell: Styles.createViewStyle({ + backgroundColor: colors.green, + }), +}; + +export enum LocationSelectionType { + relay = 'relay', + special = 'special', } -interface IState { - selectedLocation?: RelayLocation; - expandedItems: RelayLocation[]; +export type LocationSelection<SpecialValueType> = + | { type: LocationSelectionType.special; value: SpecialValueType } + | { type: LocationSelectionType.relay; value: RelayLocation }; + +interface ILocationListState<SpecialValueType> { + selectedValue?: LocationSelection<SpecialValueType>; + expandedLocations: RelayLocation[]; } -interface ICommonCellProps<T> { - location: RelayLocation; - selected: boolean; - ref?: React.RefObject<T>; +interface ILocationListProps<SpecialValueType> { + defaultExpandedLocations?: RelayLocation[]; + selectedValue?: LocationSelection<SpecialValueType>; + selectedElementRef?: React.Ref<React.ReactInstance>; + onSelect?: (value: LocationSelection<SpecialValueType>) => void; } -export default class LocationList extends Component<IProps, IState> { - public selectedCell = React.createRef<React.ReactNode>(); +export default class LocationList<SpecialValueType> extends Component< + ILocationListProps<SpecialValueType>, + ILocationListState<SpecialValueType> +> { + public state: ILocationListState<SpecialValueType> = { + expandedLocations: [], + }; - constructor(props: IProps) { + public selectedRelayLocationRef: React.ReactInstance | null = null; + public selectedSpecialLocationRef: React.ReactInstance | null = null; + + constructor(props: ILocationListProps<SpecialValueType>) { super(props); - this.state = { - expandedItems: props.selectedLocation ? expandRelayLocation(props.selectedLocation) : [], - selectedLocation: props.selectedLocation, - }; + 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: IProps, _prevState: IState) { - if (this.props.selectedLocation !== prevProps.selectedLocation) { - this.setState({ selectedLocation: this.props.selectedLocation }); + 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 ( + <View> + {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; + })} + </View> + ); + } + + 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 { + // @ts-ignore + this.props.selectedElementRef.current = value; + } + } + } + + private onSelectRelayLocation = (value: RelayLocation) => { + const selectedValue: LocationSelection<SpecialValueType> = { + type: LocationSelectionType.relay, + value, + }; + + this.setState({ selectedValue }, () => { + this.notifySelection(selectedValue); + }); + }; + + private onSelectSpecialLocation = (value: SpecialValueType) => { + const selectedValue: LocationSelection<SpecialValueType> = { + type: LocationSelectionType.special, + value, + }; + + this.setState({ selectedValue }, () => { + this.notifySelection(selectedValue); + }); + }; + + private notifySelection(value: LocationSelection<SpecialValueType>) { + if (this.props.onSelect) { + this.props.onSelect(value); + } + } + + private onExpandRelayLocation = (location: RelayLocation, expand: boolean) => { + this.setState((state) => { + const expandedLocations = state.expandedLocations.filter( + (item) => !compareRelayLocation(item, location), + ); + + if (expand) { + expandedLocations.push(location); + } + + return { + ...state, + expandedLocations, + }; + }); + }; +} + +export enum SpecialLocationIcon { + geoLocation = 'icon-nearest', +} + +interface ISpecialLocationsProps<T> { + children: React.ReactNode; + selectedValue?: T; + selectedElementRef?: React.Ref<SpecialLocation<T>>; + onSelect?: (value: T) => void; +} + +export function SpecialLocations<T>(props: ISpecialLocationsProps<T>) { + return ( + <View> + {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, + ref: isSelected ? props.selectedElementRef : undefined, + onSelect: props.onSelect, + isSelected, + }); + } else { + return undefined; + } + })} + </View> + ); +} + +interface ISpecialLocationProps<T> { + icon: SpecialLocationIcon; + value: T; + isSelected?: boolean; + onSelect?: (value: T) => void; +} + +export class SpecialLocation<T> extends Component<ISpecialLocationProps<T>> { + public render() { + return ( + <Cell.CellButton + style={this.props.isSelected ? styles.selectedCell : undefined} + cellHoverStyle={this.props.isSelected ? styles.selectedCell : undefined} + onPress={this.onSelect}> + <Cell.Icon + source={this.props.isSelected ? 'icon-tick' : this.props.icon} + tintColor={colors.white} + height={24} + width={24} + /> + <Cell.Label>{this.props.children}</Cell.Label> + </Cell.CellButton> + ); + } + + private onSelect = () => { + if (!this.props.isSelected && this.props.onSelect) { + this.props.onSelect(this.props.value); + } + }; +} + +interface IRelayLocationsProps { + source: IRelayLocationRedux[]; + selectedLocation?: RelayLocation; + selectedElementRef?: React.Ref<React.ReactInstance>; + expandedItems?: RelayLocation[]; + onSelect?: (location: RelayLocation) => void; + onExpand?: (location: RelayLocation, expand: boolean) => void; +} + +interface ICommonCellProps { + location: RelayLocation; + selected: boolean; + ref?: React.Ref<any>; +} + +export class RelayLocations extends Component<IRelayLocationsProps> { + public render() { return ( <View> - {this.props.relayLocations.map((relayCountry) => { + {this.props.source.map((relayCountry) => { const countryLocation: RelayLocation = { country: relayCountry.code }; return ( @@ -102,43 +320,33 @@ export default class LocationList extends Component<IProps, IState> { } private isExpanded(relayLocation: RelayLocation) { - return this.state.expandedItems.some((location) => + return (this.props.expandedItems || []).some((location) => compareRelayLocation(location, relayLocation), ); } private isSelected(relayLocation: RelayLocation) { - return compareRelayLocationLoose(this.state.selectedLocation, relayLocation); + return compareRelayLocationLoose(this.props.selectedLocation, relayLocation); } private handleSelection = (location: RelayLocation) => { - if (!compareRelayLocationLoose(this.state.selectedLocation, location)) { - this.setState({ selectedLocation: location }, () => { + if (!compareRelayLocationLoose(this.props.selectedLocation, location)) { + if (this.props.onSelect) { this.props.onSelect(location); - }); + } } }; private handleExpand = (location: RelayLocation, expand: boolean) => { - this.setState((state) => { - const expandedItems = state.expandedItems.filter( - (item) => !compareRelayLocation(item, location), - ); - - if (expand) { - expandedItems.push(location); - } - - return { - ...state, - expandedItems, - }; - }); + if (this.props.onExpand) { + this.props.onExpand(location, expand); + } }; - private getCommonCellProps<T>(location: RelayLocation): ICommonCellProps<T> { + private getCommonCellProps(location: RelayLocation): ICommonCellProps { const selected = this.isSelected(location); - const ref = selected ? (this.selectedCell as React.RefObject<T>) : undefined; + const ref = + selected && this.props.selectedElementRef ? this.props.selectedElementRef : undefined; return { ref, selected, location }; } @@ -160,3 +368,16 @@ function expandRelayLocation(location: RelayLocation): RelayLocation[] { function getLocationKey(location: RelayLocation): string { return relayLocationComponents(location).join('-'); } + +function compareLocationSelectionLoose<SpecialValueType>( + lhs?: LocationSelection<SpecialValueType>, + rhs?: LocationSelection<SpecialValueType>, +) { + if (!lhs || !rhs) { + return lhs === rhs; + } else if (lhs.type === LocationSelectionType.relay && rhs.type === LocationSelectionType.relay) { + return compareRelayLocation(lhs.value, rhs.value); + } else { + return lhs.value === rhs.value; + } +} diff --git a/gui/src/renderer/components/SelectLocation.tsx b/gui/src/renderer/components/SelectLocation.tsx index 0018446291..68ca5db8db 100644 --- a/gui/src/renderer/components/SelectLocation.tsx +++ b/gui/src/renderer/components/SelectLocation.tsx @@ -1,15 +1,15 @@ import * as React from 'react'; import ReactDOM from 'react-dom'; import { Component, View } from 'reactxp'; -import { colors } from '../../config.json'; import { LiftedConstraint, RelayLocation } from '../../shared/daemon-rpc-types'; import { messages } from '../../shared/gettext'; import { IRelayLocationRedux } from '../redux/settings/reducers'; import { LocationScope } from '../redux/userinterface/reducers'; -import * as Cell from './Cell'; +import BridgeLocations, { SpecialBridgeLocationType } from './BridgeLocations'; import CustomScrollbars from './CustomScrollbars'; +import ExitLocations from './ExitLocations'; import { Container, Layout } from './Layout'; -import LocationList from './LocationList'; +import LocationList, { LocationSelection, LocationSelectionType } from './LocationList'; import { CloseBarItem, NavigationBar, @@ -37,32 +37,47 @@ interface IProps { onSelectClosestToExit: () => void; } +interface ISelectLocationSnapshot { + scrollPosition: [number, number]; + expandedLocations: RelayLocation[]; +} + export default class SelectLocation extends Component<IProps> { private scrollView = React.createRef<CustomScrollbars>(); - private exitLocationList = React.createRef<LocationList>(); - private bridgeLocationList = React.createRef<LocationList>(); + private selectedExitLocationRef = React.createRef<React.ReactInstance>(); + private selectedBridgeLocationRef = React.createRef<React.ReactInstance>(); + + private exitLocationList = React.createRef<LocationList<never>>(); + private bridgeLocationList = React.createRef<LocationList<SpecialBridgeLocationType>>(); - private scrollPositionByScope: { [index: number]: [number, number] } = {}; + private snapshotByScope: { [index: number]: ISelectLocationSnapshot } = {}; public componentDidMount() { this.scrollToSelectedCell(); } - public componentDidUpdate(prevProps: IProps, _prevState: {}, snapshot?: [number, number]) { + public componentDidUpdate(prevProps: IProps, _prevState: {}, snapshot?: ISelectLocationSnapshot) { if (this.props.locationScope !== prevProps.locationScope) { this.restoreScrollPosition(this.props.locationScope); if (snapshot) { - this.saveScrollPosition(prevProps.locationScope, snapshot); + this.snapshotByScope[prevProps.locationScope] = snapshot; } } } - public getSnapshotBeforeUpdate(_prevProps: IProps) { + public getSnapshotBeforeUpdate(prevProps: IProps): ISelectLocationSnapshot | undefined { const scrollView = this.scrollView.current; + const locationList = + prevProps.locationScope === LocationScope.relay + ? this.exitLocationList.current + : this.bridgeLocationList.current; - if (scrollView) { - return scrollView.getScrollPosition(); + if (scrollView && locationList) { + return { + scrollPosition: scrollView.getScrollPosition(), + expandedLocations: locationList.getExpandedLocations(), + }; } else { return undefined; } @@ -113,33 +128,23 @@ export default class SelectLocation extends Component<IProps> { <NavigationScrollbars ref={this.scrollView}> <View style={styles.content}> {this.props.locationScope === LocationScope.relay ? ( - <LocationList - key={'exit-locations'} + <ExitLocations ref={this.exitLocationList} - selectedLocation={this.props.selectedExitLocation} - relayLocations={this.props.relayLocations} - onSelect={this.props.onSelectExitLocation} + source={this.props.relayLocations} + defaultExpandedLocations={this.getExpandedLocationsFromSnapshot()} + selectedValue={this.props.selectedExitLocation} + selectedElementRef={this.selectedExitLocationRef} + onSelect={this.onSelectExitLocation} /> ) : ( - <React.Fragment> - <View> - <ClosestToExitCell - onSelect={this.props.onSelectClosestToExit} - isSelected={this.props.selectedBridgeLocation === 'any'} - /> - </View> - <LocationList - key={'bridge-locations'} - ref={this.bridgeLocationList} - selectedLocation={ - this.props.selectedBridgeLocation !== 'any' - ? this.props.selectedBridgeLocation - : undefined - } - relayLocations={this.props.bridgeLocations} - onSelect={this.props.onSelectBridgeLocation} - /> - </React.Fragment> + <BridgeLocations + ref={this.bridgeLocationList} + source={this.props.bridgeLocations} + defaultExpandedLocations={this.getExpandedLocationsFromSnapshot()} + selectedValue={this.props.selectedBridgeLocation} + selectedElementRef={this.selectedBridgeLocationRef} + onSelect={this.onSelectBridgeLocation} + /> )} </View> </NavigationScrollbars> @@ -151,20 +156,25 @@ export default class SelectLocation extends Component<IProps> { ); } - public saveScrollPosition(scope: LocationScope, position: [number, number]) { - this.scrollPositionByScope[scope] = position; - } - public restoreScrollPosition(scope: LocationScope) { - const prevScrollPos = this.scrollPositionByScope[scope]; + const snapshot = this.snapshotByScope[scope]; - if (prevScrollPos) { - this.scrollToPosition(...prevScrollPos); + if (snapshot) { + this.scrollToPosition(...snapshot.scrollPosition); } else { this.scrollToSelectedCell(); } } + private getExpandedLocationsFromSnapshot(): RelayLocation[] | undefined { + const snapshot = this.snapshotByScope[this.props.locationScope]; + if (snapshot) { + return snapshot.expandedLocations; + } else { + return undefined; + } + } + private scrollToPosition(x: number, y: number) { const scrollView = this.scrollView.current; if (scrollView) { @@ -175,46 +185,36 @@ export default class SelectLocation extends Component<IProps> { private scrollToSelectedCell() { const ref = this.props.locationScope === LocationScope.relay - ? this.exitLocationList - : this.bridgeLocationList; - const locationList = ref.current; - - if (locationList) { - const cell = locationList.selectedCell.current; - const scrollView = this.scrollView.current; + ? this.selectedExitLocationRef.current + : this.selectedBridgeLocationRef.current; + const scrollView = this.scrollView.current; - if (scrollView) { - if (cell) { - const cellDOMNode = ReactDOM.findDOMNode(cell as Element); - if (cellDOMNode instanceof HTMLElement) { - scrollView.scrollToElement(cellDOMNode, 'middle'); - } - } else { - scrollView.scrollToTop(); + if (scrollView) { + if (ref) { + const cellDOMNode = ReactDOM.findDOMNode(ref); + if (cellDOMNode instanceof HTMLElement) { + scrollView.scrollToElement(cellDOMNode, 'middle'); } + } else { + scrollView.scrollToTop(); } } } -} -interface IClosestToExitCellProps { - isSelected: boolean; - onSelect: () => void; -} + private onSelectExitLocation = (location: LocationSelection<never>) => { + if (location.type === LocationSelectionType.relay) { + this.props.onSelectExitLocation(location.value); + } + }; -function ClosestToExitCell(props: IClosestToExitCellProps) { - return ( - <Cell.CellButton - style={props.isSelected ? styles.selectedCell : undefined} - cellHoverStyle={props.isSelected ? styles.selectedCell : undefined} - onPress={props.onSelect}> - <Cell.Icon - source={props.isSelected ? 'icon-tick' : 'icon-nearest'} - tintColor={colors.white} - height={24} - width={24} - /> - <Cell.Label>{messages.pgettext('select-location-view', 'Closest to exit server')}</Cell.Label> - </Cell.CellButton> - ); + 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(); + } + }; } |
