diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2019-01-11 14:45:59 +0100 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2019-01-11 14:45:59 +0100 |
| commit | 9ce9e23b5d3335e29e2f13c3f673c72e7d28c0a4 (patch) | |
| tree | 4bd487421c4f19144687c20952768abbdf8531b2 | |
| parent | e0ca3662e1ea57ef09001ab1b8a4b529dcdd72cf (diff) | |
| parent | bc0a8defa9a04a750672b7370a75a25d9dcb3788 (diff) | |
| download | mullvadvpn-9ce9e23b5d3335e29e2f13c3f673c72e7d28c0a4.tar.xz mullvadvpn-9ce9e23b5d3335e29e2f13c3f673c72e7d28c0a4.zip | |
Merge branch 'optimize-select-location'
14 files changed, 585 insertions, 516 deletions
diff --git a/gui/packages/components/src/Accordion.tsx b/gui/packages/components/src/Accordion.tsx index 370cce7e28..7019c9cb9c 100644 --- a/gui/packages/components/src/Accordion.tsx +++ b/gui/packages/components/src/Accordion.tsx @@ -2,41 +2,46 @@ import * as React from 'react'; import { Animated, Component, Styles, Types, UserInterface, View } from 'reactxp'; interface IProps { - height: number | 'auto'; - animationDuration?: number; + expanded: boolean; + animationDuration: number; style?: Types.AnimatedViewStyleRuleSet; children?: React.ReactNode; } interface IState { - animatedValue: Animated.Value | null; + applyAnimatedStyle: boolean; + mountChildren: boolean; } const containerOverflowStyle = Styles.createViewStyle({ overflow: 'hidden' }); export default class Accordion extends Component<IProps, IState> { public static defaultProps = { - height: 'auto', + expanded: true, animationDuration: 350, }; public state: IState = { - animatedValue: null, + applyAnimatedStyle: false, + mountChildren: false, }; + private heightValue = Animated.createValue(0); + private animatedStyle = Styles.createAnimatedViewStyle({ + height: this.heightValue, + }); + private containerRef = React.createRef<Animated.View>(); - private contentHeight = 0; - private animation: Types.Animated.CompositeAnimation | null = null; + private contentRef = React.createRef<View>(); + private animation?: Types.Animated.CompositeAnimation = undefined; constructor(props: IProps) { super(props); - // set the initial height if it's known - if (typeof props.height === 'number') { - this.state = { - animatedValue: Animated.createValue(props.height), - }; - } + this.state = { + applyAnimatedStyle: !props.expanded, + mountChildren: props.expanded, + }; } public componentWillUnmount() { @@ -45,95 +50,89 @@ export default class Accordion extends Component<IProps, IState> { } } - public shouldComponentUpdate(nextProps: IProps, nextState: IState) { - return ( - nextState.animatedValue !== this.state.animatedValue || - nextProps.height !== this.props.height || - nextProps.children !== this.props.children - ); - } - - public componentDidUpdate(prevProps: IProps, prevState: IState) { - if (prevProps.height !== this.props.height) { - this.animateHeightChanges(); + public componentDidUpdate(oldProps: IProps, oldState: IState) { + if (this.props.expanded !== oldProps.expanded) { + // make sure the children are mounted first before expanding the accordion + if (this.props.expanded && !this.state.mountChildren) { + this.setState({ mountChildren: true }); + } else { + this.animate(this.props.expanded); + } + } else if (this.state.mountChildren && !oldState.mountChildren) { + // run animations once the children are mounted + this.animate(this.props.expanded); } } public render() { - const { style, height, children, animationDuration, ...otherProps } = this.props; + const { style, children, expanded, animationDuration, ...otherProps } = this.props; const containerStyles = [style]; - if (this.state.animatedValue !== null) { - const animatedStyle = Styles.createAnimatedViewStyle({ - height: this.state.animatedValue, - }); - - containerStyles.push(containerOverflowStyle, animatedStyle); + if (this.state.applyAnimatedStyle) { + containerStyles.push(containerOverflowStyle, this.animatedStyle); } return ( - <Animated.View - {...otherProps} - style={containerStyles} - ref={ - /* Fix: cast to any because reactxp has out of date annotations - See: https://github.com/Microsoft/reactxp/issues/784 - */ - this.containerRef as any - }> - <View onLayout={this.contentLayoutDidChange}>{children}</View> + <Animated.View {...otherProps} style={containerStyles} ref={this.containerRef}> + <View ref={this.contentRef}>{this.state.mountChildren && children}</View> </Animated.View> ); } - private async animateHeightChanges() { + private async animate(expand: boolean) { const containerView = this.containerRef.current; - if (!containerView) { + const contentView = this.contentRef.current; + if (!containerView || !contentView) { return; } if (this.animation) { this.animation.stop(); - this.animation = null; + this.animation = undefined; } - try { - const layout = await UserInterface.measureLayoutRelativeToWindow(containerView); - const fromValue = this.state.animatedValue || Animated.createValue(layout.height); - const toValue = this.props.height === 'auto' ? this.contentHeight : this.props.height; + const containerLayout = await UserInterface.measureLayoutRelativeToWindow(containerView); + const contentLayout = await UserInterface.measureLayoutRelativeToAncestor( + contentView, + containerView, + ); - // calculate the animation duration based on travel distance - const multiplier = Math.abs(toValue - layout.height) / Math.max(1, this.contentHeight); - const duration = Math.ceil(this.props.animationDuration! * multiplier); + // the content is expanded when the animated style is not applied, + // so reset the initial animated value to the current layout's height. + if (!this.state.applyAnimatedStyle) { + this.heightValue.setValue(containerLayout.height); + } - const animation = Animated.timing(fromValue, { - toValue, - easing: Animated.Easing.InOut(), - duration, - useNativeDriver: true, - }); + const toValue = expand ? contentLayout.height : 0; - this.animation = animation; - this.setState({ animatedValue: fromValue }, () => { - animation.start(this.onAnimationEnd); - }); - } catch (error) { - // TODO: log error - } - } + // calculate the animation duration based on travel distance + const multiplier = + Math.abs(toValue - containerLayout.height) / Math.max(1, contentLayout.height); + const duration = Math.ceil(this.props.animationDuration * multiplier); + + const animation = Animated.timing(this.heightValue, { + toValue, + easing: Animated.Easing.InOut(), + duration, + useNativeDriver: true, + }); - private onAnimationEnd = ({ finished }: Types.Animated.EndResult) => { - if (finished) { - this.animation = null; + this.animation = animation; - // reset height after transition to let element layout naturally - // if animation finished without interruption - if (this.props.height === 'auto') { - this.setState({ animatedValue: null }); + const onAnimationEnd = ({ finished }: Types.Animated.EndResult) => { + if (finished) { + this.animation = undefined; + + // reset the height after transition to let element layout naturally + // if animation finished without interruption + if (expand) { + this.setState({ applyAnimatedStyle: false }); + } } - } - }; + }; - private contentLayoutDidChange = ({ height }: Types.ViewOnLayoutEvent) => - (this.contentHeight = height); + this.setState({ applyAnimatedStyle: true }, () => { + animation.start(onAnimationEnd); + }); + } } diff --git a/gui/packages/components/src/HeaderBar.tsx b/gui/packages/components/src/HeaderBar.tsx index 57e8373651..2e2c7bcf30 100644 --- a/gui/packages/components/src/HeaderBar.tsx +++ b/gui/packages/components/src/HeaderBar.tsx @@ -142,6 +142,7 @@ export class SettingsBarButton extends Component<ISettingsButtonProps> { width={24} source="icon-settings" tintColor={'rgba(255, 255, 255, 0.6)'} + tintHoverColor={'rgba(255, 255, 255, 0.8)'} /> </Button> ); diff --git a/gui/packages/desktop/src/renderer/components/AdvancedSettings.js b/gui/packages/desktop/src/renderer/components/AdvancedSettings.js index a885849cac..6be9d38037 100644 --- a/gui/packages/desktop/src/renderer/components/AdvancedSettings.js +++ b/gui/packages/desktop/src/renderer/components/AdvancedSettings.js @@ -101,7 +101,9 @@ export class AdvancedSettings extends Component<Props, State> { <Cell.Footer>Enable IPv6 communication through the tunnel.</Cell.Footer> <Cell.Container> - <Cell.Label>Block when disconnected</Cell.Label> + <Cell.Label textStyle={styles.advanced_settings__block_when_disconnected_label}> + Block when disconnected + </Cell.Label> <Switch isOn={this.props.blockWhenDisconnected} onChange={this.props.setBlockWhenDisconnected} diff --git a/gui/packages/desktop/src/renderer/components/AdvancedSettingsStyles.js b/gui/packages/desktop/src/renderer/components/AdvancedSettingsStyles.js index ffd2e9f64f..f051e95bf7 100644 --- a/gui/packages/desktop/src/renderer/components/AdvancedSettingsStyles.js +++ b/gui/packages/desktop/src/renderer/components/AdvancedSettingsStyles.js @@ -95,4 +95,7 @@ export default { advanced_settings__mssfix_invalid_value: Styles.createTextStyle({ color: colors.red, }), + advanced_settings__block_when_disconnected_label: Styles.createTextStyle({ + letterSpacing: -0.5, + }), }; diff --git a/gui/packages/desktop/src/renderer/components/Cell.js b/gui/packages/desktop/src/renderer/components/Cell.js index 82b7aab8a9..7e6159cf9f 100644 --- a/gui/packages/desktop/src/renderer/components/Cell.js +++ b/gui/packages/desktop/src/renderer/components/Cell.js @@ -49,7 +49,7 @@ const styles = { marginLeft: 8, marginTop: 14, marginBottom: 14, - flexGrow: 1, + flex: 1, }), text: Styles.createTextStyle({ fontFamily: 'DINPro', diff --git a/gui/packages/desktop/src/renderer/components/ChevronButton.js b/gui/packages/desktop/src/renderer/components/ChevronButton.js new file mode 100644 index 0000000000..d594c381cb --- /dev/null +++ b/gui/packages/desktop/src/renderer/components/ChevronButton.js @@ -0,0 +1,35 @@ +// @flow + +import * as React from 'react'; +import { Component, Styles } from 'reactxp'; +import * as Cell from './Cell'; +import { colors } from '../../config'; + +type Props = { + up: boolean, + onPress: () => void, +}; + +const style = Styles.createViewStyle({ + flex: 0, + alignSelf: 'stretch', + justifyContent: 'center', + paddingRight: 16, + paddingLeft: 16, +}); + +export default class ChevronButton extends Component<Props> { + render() { + return ( + <Cell.Icon + style={[style, this.props.style]} + tintColor={colors.white80} + tintHoverColor={colors.white} + onPress={this.props.onPress} + source={this.props.up ? 'icon-chevron-up' : 'icon-chevron-down'} + height={24} + width={24} + /> + ); + } +} diff --git a/gui/packages/desktop/src/renderer/components/CityRow.js b/gui/packages/desktop/src/renderer/components/CityRow.js new file mode 100644 index 0000000000..f375969dcf --- /dev/null +++ b/gui/packages/desktop/src/renderer/components/CityRow.js @@ -0,0 +1,107 @@ +// @flow + +import * as React from 'react'; +import { Component, Styles, View } from 'reactxp'; +import { Accordion } from '@mullvad/components'; +import * as Cell from './Cell'; +import RelayRow from './RelayRow'; +import RelayStatusIndicator from './RelayStatusIndicator'; +import ChevronButton from './ChevronButton'; +import { colors } from '../../config'; + +type Props = { + name: string, + hasActiveRelays: boolean, + selected: boolean, + expanded: boolean, + selected: boolean, + onSelect?: () => void, + onExpand?: () => void, + children?: React.Element<typeof RelayRow>, +}; + +const styles = { + base: Styles.createViewStyle({ + paddingTop: 0, + paddingBottom: 0, + paddingRight: 0, + paddingLeft: 40, + backgroundColor: colors.blue40, + }), + selected: Styles.createViewStyle({ + backgroundColor: colors.green, + }), +}; + +export default class CityRow extends Component<Props> { + shouldComponentUpdate(nextProps: Props) { + return !CityRow.compareProps(this.props, nextProps); + } + + static compareProps(oldProps: Props, nextProps: Props) { + if (React.Children.count(oldProps.children) !== React.Children.count(nextProps.children)) { + return false; + } + + if ( + oldProps.name !== nextProps.name || + oldProps.hasActiveRelays !== nextProps.hasActiveRelays || + oldProps.selected !== nextProps.selected || + oldProps.expanded !== nextProps.expanded + ) { + return false; + } + + const currChildren = React.Children.toArray(oldProps.children); + const nextChildren = React.Children.toArray(nextProps.children); + + for (let i = 0; i < currChildren.length; i++) { + const currChild = currChildren[i]; + const nextChild = nextChildren[i]; + + if (!RelayRow.compareProps(currChild.props, nextChild.props)) { + return false; + } + } + + return true; + } + + render() { + const hasChildren = React.Children.count(this.props.children) > 1; + + return ( + <View> + <Cell.CellButton + onPress={this._handlePress} + disabled={!this.props.hasActiveRelays} + cellHoverStyle={this.props.selected ? styles.selected : null} + style={[styles.base, this.props.selected ? styles.selected : null]} + testName="city"> + <RelayStatusIndicator + isActive={this.props.hasActiveRelays} + isSelected={this.props.selected} + /> + <Cell.Label>{this.props.name}</Cell.Label> + + {hasChildren && <ChevronButton onPress={this._toggleCollapse} up={this.props.expanded} />} + </Cell.CellButton> + + {hasChildren && <Accordion expanded={this.props.expanded}>{this.props.children}</Accordion>} + </View> + ); + } + + _toggleCollapse = (event: Event) => { + if (this.props.onExpand) { + this.props.onExpand(!this.props.expanded); + } + event.stopPropagation(); + }; + + _handlePress = () => { + if (this.props.onSelect) { + this.props.onSelect(); + } + }; +} diff --git a/gui/packages/desktop/src/renderer/components/CountryRow.js b/gui/packages/desktop/src/renderer/components/CountryRow.js new file mode 100644 index 0000000000..c504ad397c --- /dev/null +++ b/gui/packages/desktop/src/renderer/components/CountryRow.js @@ -0,0 +1,113 @@ +// @flow + +import * as React from 'react'; +import { Component, Styles, View } from 'reactxp'; +import { Accordion } from '@mullvad/components'; +import * as Cell from './Cell'; +import CityRow from './CityRow'; +import RelayStatusIndicator from './RelayStatusIndicator'; +import ChevronButton from './ChevronButton'; +import { colors } from '../../config'; + +type Props = { + name: string, + hasActiveRelays: boolean, + selected: boolean, + expanded: boolean, + onSelect?: () => void, + onExpand?: (boolean) => void, + children?: React.Element<typeof CityRow>, +}; + +const styles = { + container: Styles.createViewStyle({ + flexDirection: 'column', + flex: 0, + }), + base: Styles.createViewStyle({ + paddingTop: 0, + paddingBottom: 0, + paddingLeft: 20, + paddingRight: 0, + }), + selected: Styles.createViewStyle({ + backgroundColor: colors.green, + }), +}; + +export default class CountryRow extends Component<Props> { + shouldComponentUpdate(nextProps: Props) { + return !CountryRow.compareProps(this.props, nextProps); + } + + static compareProps(oldProps: Props, nextProps: Props) { + if (React.Children.count(oldProps.children) !== React.Children.count(nextProps.children)) { + return false; + } + + if ( + oldProps.name !== nextProps.name || + oldProps.hasActiveRelays !== nextProps.hasActiveRelays || + oldProps.selected !== nextProps.selected || + oldProps.expanded !== nextProps.expanded + ) { + return false; + } + + const currChildren = React.Children.toArray(oldProps.children); + const nextChildren = React.Children.toArray(nextProps.children); + + for (let i = 0; i < currChildren.length; i++) { + const currChild = currChildren[i]; + const nextChild = nextChildren[i]; + + if (!CityRow.compareProps(currChild.props, nextChild.props)) { + return false; + } + } + + return true; + } + + render() { + const numChildren = React.Children.count(this.props.children); + const onlyChild = numChildren === 1 ? this.props.children[0] : undefined; + const numOnlyChildChildren = onlyChild ? React.Children.count(onlyChild.props.children) : 0; + const hasChildren = numChildren > 1 || numOnlyChildChildren > 1; + + return ( + <View style={styles.container}> + <Cell.CellButton + cellHoverStyle={this.props.selected ? styles.selected : null} + style={[styles.base, this.props.selected ? styles.selected : null]} + onPress={this._handlePress} + disabled={!this.props.hasActiveRelays} + testName="country"> + <RelayStatusIndicator + isActive={this.props.hasActiveRelays} + isSelected={this.props.selected} + /> + <Cell.Label>{this.props.name}</Cell.Label> + {hasChildren ? ( + <ChevronButton onPress={this._toggleCollapse} up={this.props.expanded} /> + ) : null} + </Cell.CellButton> + + {hasChildren && <Accordion expanded={this.props.expanded}>{this.props.children}</Accordion>} + </View> + ); + } + + _toggleCollapse = (event: Event) => { + if (this.props.onExpand) { + this.props.onExpand(!this.props.expanded); + } + event.stopPropagation(); + }; + + _handlePress = () => { + if (this.props.onSelect) { + this.props.onSelect(); + } + }; +} diff --git a/gui/packages/desktop/src/renderer/components/Login.js b/gui/packages/desktop/src/renderer/components/Login.js index 972b0e3f18..e0491141a7 100644 --- a/gui/packages/desktop/src/renderer/components/Login.js +++ b/gui/packages/desktop/src/renderer/components/Login.js @@ -377,7 +377,7 @@ export default class Login extends Component<Props, State> { /> </Animated.View> </View> - <Accordion height={this._shouldShowAccountHistory() ? 'auto' : 0}> + <Accordion expanded={this._shouldShowAccountHistory()}> { <AccountDropdown items={this.props.accountHistory.slice().reverse()} diff --git a/gui/packages/desktop/src/renderer/components/RelayRow.js b/gui/packages/desktop/src/renderer/components/RelayRow.js new file mode 100644 index 0000000000..04aadf3fd5 --- /dev/null +++ b/gui/packages/desktop/src/renderer/components/RelayRow.js @@ -0,0 +1,56 @@ +// @flow + +import * as React from 'react'; +import { Component, Styles } from 'reactxp'; +import * as Cell from './Cell'; +import RelayStatusIndicator from './RelayStatusIndicator'; +import { colors } from '../../config'; + +type Props = { + hostname: string, + selected: boolean, + onSelect?: () => void, +}; + +const styles = { + base: Styles.createViewStyle({ + paddingTop: 0, + paddingBottom: 0, + paddingRight: 0, + paddingLeft: 60, + backgroundColor: colors.blue20, + }), + selected: Styles.createViewStyle({ + backgroundColor: colors.green, + }), +}; + +export default class RelayRow extends Component<Props> { + shouldComponentUpdate(nextProps: Props) { + return !RelayRow.compareProps(this.props, nextProps); + } + + static compareProps(oldProps: Props, nextProps: Props) { + return oldProps.hostname === nextProps.hostname && oldProps.selected === nextProps.selected; + } + + render() { + return ( + <Cell.CellButton + onPress={this._handlePress} + cellHoverStyle={this.props.selected ? styles.selected : null} + style={[styles.base, this.props.selected ? styles.selected : null]} + testName="relay"> + <RelayStatusIndicator isActive={true} isSelected={this.props.selected} /> + + <Cell.Label>{this.props.hostname}</Cell.Label> + </Cell.CellButton> + ); + } + + _handlePress = () => { + if (this.props.onSelect) { + this.props.onSelect(); + } + }; +} diff --git a/gui/packages/desktop/src/renderer/components/RelayStatusIndicator.js b/gui/packages/desktop/src/renderer/components/RelayStatusIndicator.js new file mode 100644 index 0000000000..d6b3fa1469 --- /dev/null +++ b/gui/packages/desktop/src/renderer/components/RelayStatusIndicator.js @@ -0,0 +1,53 @@ +// @flow + +import * as React from 'react'; +import { Component, Styles, View } from 'reactxp'; +import * as Cell from './Cell'; +import { colors } from '../../config'; + +const styles = { + relay_status: Styles.createViewStyle({ + width: 16, + height: 16, + borderRadius: 8, + marginLeft: 4, + marginRight: 4, + }), + relay_status__inactive: Styles.createViewStyle({ + backgroundColor: colors.red95, + }), + relay_status__active: Styles.createViewStyle({ + backgroundColor: colors.green90, + }), + tick_icon: Styles.createViewStyle({ + color: colors.white, + marginLeft: 0, + marginRight: 0, + }), +}; + +type Props = { + isActive: boolean, + isSelected: boolean, +}; + +export default class RelayStatusIndicator extends Component<Props> { + render() { + return this.props.isSelected ? ( + <Cell.Icon + style={styles.tick_icon} + tintColor={colors.white} + source="icon-tick" + height={24} + width={24} + /> + ) : ( + <View + style={[ + styles.relay_status, + this.props.isActive ? styles.relay_status__active : styles.relay_status__inactive, + ]} + /> + ); + } +} diff --git a/gui/packages/desktop/src/renderer/components/SelectLocation.js b/gui/packages/desktop/src/renderer/components/SelectLocation.js index 153984529c..0f45133da0 100644 --- a/gui/packages/desktop/src/renderer/components/SelectLocation.js +++ b/gui/packages/desktop/src/renderer/components/SelectLocation.js @@ -3,7 +3,7 @@ import * as React from 'react'; import ReactDOM from 'react-dom'; import { View, Component } from 'reactxp'; -import { Accordion, SettingsHeader, HeaderTitle, HeaderSubTitle } from '@mullvad/components'; +import { SettingsHeader, HeaderTitle, HeaderSubTitle } from '@mullvad/components'; import { Layout, Container } from './Layout'; import { NavigationContainer, @@ -12,17 +12,14 @@ import { CloseBarItem, TitleBarItem, } from './NavigationBar'; -import * as Cell from './Cell'; import styles from './SelectLocationStyles'; -import type { - RelaySettingsRedux, - RelayLocationRedux, - RelayLocationCityRedux, - RelayLocationRelayRedux, -} from '../redux/settings/reducers'; +import CountryRow from './CountryRow'; +import CityRow from './CityRow'; +import RelayRow from './RelayRow'; + +import type { RelaySettingsRedux, RelayLocationRedux } from '../redux/settings/reducers'; import type { RelayLocation } from '../lib/daemon-rpc-proxy'; -import { colors } from '../../config'; type Props = { relaySettings: RelaySettingsRedux, @@ -32,7 +29,8 @@ type Props = { }; type State = { - expanded: Array<string>, + selectedLocation?: RelayLocation, + expandedItems: Array<RelayLocation>, }; export default class SelectLocation extends Component<Props, State> { @@ -40,43 +38,62 @@ export default class SelectLocation extends Component<Props, State> { _scrollViewRef = React.createRef(); state = { - expanded: [], + selectedLocation: undefined, + expandedItems: [], }; constructor(props: Props) { super(props); - // set initially expanded country based on relaySettings - const relaySettings = this.props.relaySettings; - if (relaySettings.normal) { - const { location } = relaySettings.normal; - if (location === 'any') { - // no-op - } else if (location.country) { - this.state.expanded.push(location.country); - } else if (location.city) { - const countryCode = location.city[0]; + if (this.props.relaySettings.normal) { + const expandedItems = []; + const location = this.props.relaySettings.normal.location; + + if (location.city) { + expandedItems.push({ country: location.city[0] }); + } - this.state.expanded.push(countryCode); - } else if (location.hostname) { - const countryCode = location.hostname[0]; - const cityCode = location.hostname[1]; + if (location.hostname) { + expandedItems.push({ country: location.hostname[0] }); + expandedItems.push({ city: [location.hostname[0], location.hostname[1]] }); + } - this.state.expanded.push(countryCode); - this.state.expanded.push(`${countryCode}_${cityCode}`); + if (location !== 'any') { + this.state.selectedLocation = location; } + + this.state.expandedItems = expandedItems; + } + } + + componentDidUpdate(oldProps: Props) { + const currentLocation = this.state.selectedLocation; + let newLocation = (this.props.relaySettings.normal || {}).location; + let oldLocation = (oldProps.relaySettings.normal || {}).location; + + if (newLocation === 'any') { + newLocation = undefined; + } + + if (oldLocation === 'any') { + oldLocation = undefined; + } + + if ( + !compareLocationLoose(oldLocation, newLocation) && + !compareLocationLoose(currentLocation, newLocation) + ) { + this.setState({ selectedLocation: newLocation }); } } componentDidMount() { - // restore scroll to selected cell + // restore scroll to the selected cell const cell = this._selectedCellRef.current; const scrollView = this._scrollViewRef.current; - if (scrollView && cell) { // eslint-disable-next-line react/no-find-dom-node const cellDOMNode = ReactDOM.findDOMNode(cell); - if (cellDOMNode instanceof HTMLElement) { scrollView.scrollToElement(cellDOMNode, 'middle'); } @@ -105,7 +122,48 @@ export default class SelectLocation extends Component<Props, State> { </SettingsHeader> {this.props.relayLocations.map((relayCountry) => { - return this._renderCountry(relayCountry); + const location = { country: relayCountry.code }; + + return ( + <CountryRow + key={getLocationKey(location)} + name={relayCountry.name} + hasActiveRelays={relayCountry.hasActiveRelays} + expanded={this._isExpanded(location)} + onSelect={() => this._handleSelection(location)} + onExpand={(expand) => this._handleExpand(location, expand)} + {...this._getCommonCellProps(location)}> + {relayCountry.cities.map((relayCity) => { + const location = { city: [relayCountry.code, relayCity.code] }; + + return ( + <CityRow + key={getLocationKey(location)} + name={relayCity.name} + hasActiveRelays={relayCity.hasActiveRelays} + expanded={this._isExpanded(location)} + onSelect={() => this._handleSelection(location)} + onExpand={(expand) => this._handleExpand(location, expand)} + {...this._getCommonCellProps(location)}> + {relayCity.relays.map((relay) => { + const location = { + hostname: [relayCountry.code, relayCity.code, relay.hostname], + }; + + return ( + <RelayRow + key={getLocationKey(location)} + hostname={relay.hostname} + onSelect={() => this._handleSelection(location)} + {...this._getCommonCellProps(location)} + /> + ); + })} + </CityRow> + ); + })} + </CountryRow> + ); })} </View> </NavigationScrollbars> @@ -117,213 +175,71 @@ export default class SelectLocation extends Component<Props, State> { ); } - _isSelected(selectedLocation: RelayLocation) { - const relaySettings = this.props.relaySettings; - if (relaySettings.normal) { - const otherLocation = relaySettings.normal.location; - - if ( - selectedLocation.country && - otherLocation.country && - selectedLocation.country === otherLocation.country - ) { - return true; - } - - if (Array.isArray(selectedLocation.city) && Array.isArray(otherLocation.city)) { - const selectedCity = selectedLocation.city; - const otherCity = otherLocation.city; - - return ( - selectedCity.length === otherCity.length && - selectedCity.every((v, i) => v === otherCity[i]) - ); - } + _isExpanded(relayLocation: RelayLocation) { + return this.state.expandedItems.some((location) => compareLocation(location, relayLocation)); + } - if (Array.isArray(selectedLocation.hostname) && Array.isArray(otherLocation.hostname)) { - const selectedRelay = selectedLocation.hostname; - const otherRelay = otherLocation.hostname; + _isSelected(relayLocation: RelayLocation) { + return compareLocationLoose(this.state.selectedLocation, relayLocation); + } - return ( - selectedRelay.length === otherRelay.length && - selectedRelay.every((v, i) => v === otherRelay[i]) - ); - } + _handleSelection = (location: RelayLocation) => { + if (!compareLocationLoose(this.state.selectedLocation, location)) { + this.setState({ selectedLocation: location }, () => { + this.props.onSelect(location); + }); } - return false; - } + }; - _toggleCollapse = (countryCode: string) => { + _handleExpand = (location: RelayLocation, expand: boolean) => { this.setState((state) => { - const expanded = state.expanded.slice(); - const index = expanded.indexOf(countryCode); - if (index === -1) { - expanded.push(countryCode); - } else { - expanded.splice(index, 1); + const expandedItems = state.expandedItems.filter((item) => !compareLocation(item, location)); + + if (expand) { + expandedItems.push(location); } - return { expanded }; + + return { + ...state, + expandedItems, + }; }); }; - _relayStatusIndicator(active: boolean, isSelected: boolean) { - const statusClass = active ? styles.relay_status__active : styles.relay_status__inactive; + _getCommonCellProps(location: RelayLocation) { + const selected = this._isSelected(location); + const ref = selected ? this._selectedCellRef : undefined; - return isSelected ? ( - <Cell.Icon - style={styles.tick_icon} - tintColor={colors.white} - source="icon-tick" - height={24} - width={24} - /> - ) : ( - <View style={[styles.relay_status, statusClass]} /> - ); + return { ref, selected }; } +} - _renderCountry(relayCountry: RelayLocationRedux) { - const isSelected = this._isSelected({ country: relayCountry.code }); - - const cellRef = isSelected ? this._selectedCellRef : undefined; - - // either expanded by user or when the city selected within the country - const isExpanded = this.state.expanded.includes(relayCountry.code); - - const hasChildren = - relayCountry.cities.length > 1 || - (relayCountry.cities.length == 1 && relayCountry.cities[0].relays.length > 1); - - const handleSelect = - relayCountry.hasActiveRelays && !isSelected - ? () => { - this.props.onSelect({ country: relayCountry.code }); - } - : undefined; - - const handleCollapse = (e) => { - this._toggleCollapse(relayCountry.code); - e.stopPropagation(); - }; - - return ( - <View key={relayCountry.code} style={styles.country}> - <Cell.CellButton - cellHoverStyle={isSelected ? styles.cell_selected : null} - style={isSelected ? styles.cell_selected : styles.cell} - onPress={handleSelect} - disabled={!relayCountry.hasActiveRelays} - testName="country" - ref={cellRef}> - {this._relayStatusIndicator(relayCountry.hasActiveRelays, isSelected)} - - <Cell.Label>{relayCountry.name}</Cell.Label> - - {hasChildren ? ( - <Cell.Icon - style={styles.collapse_button} - tintColor={colors.white80} - tintHoverColor={colors.white} - onPress={handleCollapse} - source={isExpanded ? 'icon-chevron-up' : 'icon-chevron-down'} - height={24} - width={24} - /> - ) : null} - </Cell.CellButton> - - {hasChildren && ( - <Accordion height={isExpanded ? 'auto' : 0}> - {relayCountry.cities.map((relayCity) => this._renderCity(relayCountry.code, relayCity))} - </Accordion> - )} - </View> - ); - } - - _renderCity(countryCode: string, relayCity: RelayLocationCityRedux) { - const expandedCode = `${countryCode}_${relayCity.code}`; - const relayLocation: RelayLocation = { city: [countryCode, relayCity.code] }; - - const isSelected = this._isSelected(relayLocation); - - const cellRef = isSelected ? this._selectedCellRef : undefined; - - // either expanded by user or when the city or a relay from the city is selected - const isExpanded = this.state.expanded.includes(expandedCode); - - const handleSelect = - relayCity.hasActiveRelays && !isSelected - ? () => { - this.props.onSelect(relayLocation); - } - : undefined; +function getLocationKey(location: RelayLocation) { + const components = location.city || location.country || location.hostname || []; - const handleCollapse = (e) => { - this._toggleCollapse(expandedCode); - e.stopPropagation(); - }; + return [].concat(components).join('-'); +} +function compareLocation(lhs: RelayLocation, rhs: RelayLocation) { + if (lhs.country && rhs.country) { + return lhs.country === rhs.country; + } else if (lhs.city && rhs.city) { + return lhs.city[0] === rhs.city[0] && lhs.city[1] === rhs.city[1]; + } else if (lhs.hostname && rhs.hostname) { return ( - <View key={expandedCode}> - <Cell.CellButton - onPress={handleSelect} - disabled={!relayCity.hasActiveRelays} - cellHoverStyle={isSelected ? styles.sub_cell__selected : null} - style={isSelected ? styles.sub_cell__selected : styles.sub_cell} - testName="city" - ref={cellRef}> - {this._relayStatusIndicator(relayCity.hasActiveRelays, isSelected)} - - <Cell.Label>{relayCity.name}</Cell.Label> - - {relayCity.relays.length > 1 ? ( - <Cell.Icon - style={styles.collapse_button} - tintColor={colors.white80} - tintHoverColor={colors.white} - onPress={handleCollapse} - source={isExpanded ? 'icon-chevron-up' : 'icon-chevron-down'} - height={24} - width={24} - /> - ) : null} - </Cell.CellButton> - - {relayCity.relays.length > 1 && ( - <Accordion height={isExpanded ? 'auto' : 0}> - {relayCity.relays.map((relay) => this._renderRelay(countryCode, relayCity.code, relay))} - </Accordion> - )} - </View> + lhs.hostname[0] === rhs.hostname[0] && + lhs.hostname[1] === rhs.hostname[1] && + lhs.hostname[2] === rhs.hostname[2] ); + } else { + return false; } +} - _renderRelay(countryCode: string, cityCode: string, relay: RelayLocationRelayRedux) { - const relayLocation: RelayLocation = { hostname: [countryCode, cityCode, relay.hostname] }; - - const isSelected = this._isSelected(relayLocation); - - const cellRef = isSelected ? this._selectedCellRef : undefined; - - const handleSelect = !isSelected - ? () => { - this.props.onSelect(relayLocation); - } - : undefined; - - return ( - <Cell.CellButton - key={`${countryCode}_${cityCode}_${relay.hostname}`} - onPress={handleSelect} - cellHoverStyle={isSelected ? styles.sub_sub_cell__selected : null} - style={isSelected ? styles.sub_sub_cell__selected : styles.sub_sub_cell} - testName="relay" - ref={cellRef}> - {this._relayStatusIndicator(true, isSelected)} - - <Cell.Label>{relay.hostname}</Cell.Label> - </Cell.CellButton> - ); +function compareLocationLoose(lhs: ?RelayLocation, rhs: ?RelayLocation) { + if (lhs && rhs) { + return compareLocation(lhs, rhs); + } else { + return lhs === rhs; } } diff --git a/gui/packages/desktop/src/renderer/components/SelectLocationStyles.js b/gui/packages/desktop/src/renderer/components/SelectLocationStyles.js index 202d2c53fe..c2db5a7718 100644 --- a/gui/packages/desktop/src/renderer/components/SelectLocationStyles.js +++ b/gui/packages/desktop/src/renderer/components/SelectLocationStyles.js @@ -21,78 +21,4 @@ export default { content: Styles.createViewStyle({ overflow: 'visible', }), - relay_status: Styles.createViewStyle({ - width: 16, - height: 16, - borderRadius: 8, - marginLeft: 4, - marginRight: 4, - marginTop: 20, - marginBottom: 20, - }), - relay_status__inactive: Styles.createViewStyle({ - backgroundColor: colors.red95, - }), - relay_status__active: Styles.createViewStyle({ - backgroundColor: colors.green90, - }), - tick_icon: Styles.createViewStyle({ - color: colors.white, - marginLeft: 0, - marginRight: 0, - marginTop: 15.5, - marginBottom: 15.5, - }), - country: Styles.createViewStyle({ - flexDirection: 'column', - flex: 0, - }), - collapse_button: Styles.createViewStyle({ - flex: 0, - alignSelf: 'stretch', - justifyContent: 'center', - paddingRight: 16, - paddingLeft: 16, - }), - cell: Styles.createViewStyle({ - paddingTop: 0, - paddingBottom: 0, - paddingLeft: 20, - paddingRight: 0, - }), - cell_selected: Styles.createViewStyle({ - paddingTop: 0, - paddingBottom: 0, - paddingLeft: 20, - paddingRight: 0, - backgroundColor: colors.green, - }), - sub_cell: Styles.createViewStyle({ - paddingTop: 0, - paddingBottom: 0, - paddingRight: 0, - paddingLeft: 40, - backgroundColor: colors.blue40, - }), - sub_cell__selected: Styles.createViewStyle({ - paddingTop: 0, - paddingBottom: 0, - paddingRight: 0, - paddingLeft: 40, - backgroundColor: colors.green, - }), - sub_sub_cell: Styles.createViewStyle({ - paddingTop: 0, - paddingBottom: 0, - paddingRight: 0, - paddingLeft: 60, - backgroundColor: colors.blue20, - }), - sub_sub_cell__selected: Styles.createViewStyle({ - paddingTop: 0, - paddingBottom: 0, - paddingRight: 0, - paddingLeft: 60, - backgroundColor: colors.green, - }), }; diff --git a/gui/packages/desktop/test/components/SelectLocation.spec.js b/gui/packages/desktop/test/components/SelectLocation.spec.js deleted file mode 100644 index b8bb8991c8..0000000000 --- a/gui/packages/desktop/test/components/SelectLocation.spec.js +++ /dev/null @@ -1,142 +0,0 @@ -// @flow - -import * as React from 'react'; -import { shallow } from 'enzyme'; -import { CloseBarItem } from '../../src/renderer/components/NavigationBar'; -import SelectLocation from '../../src/renderer/components/SelectLocation'; - -describe('components/SelectLocation', () => { - const defaultProps = { - relaySettings: { - normal: { - location: 'any', - protocol: 'any', - port: 'any', - }, - }, - relayLocations: [ - { - name: 'Sweden', - code: 'se', - hasActiveRelays: true, - cities: [ - { - name: 'Malmö', - code: 'mma', - latitude: 0, - longitude: 0, - hasActiveRelays: true, - relays: [ - { - hostname: 'fake1.mullvad.net', - ipv4AddrIn: '192.168.0.100', - includeInCountry: true, - weight: 1, - }, - { - hostname: 'fake2.mullvad.net', - ipv4AddrIn: '192.168.0.101', - includeInCountry: true, - weight: 1, - }, - ], - }, - { - name: 'Stockholm', - code: 'sto', - latitude: 0, - longitude: 0, - hasActiveRelays: true, - relays: [ - { - hostname: 'fake2.mullvad.net', - ipv4AddrIn: '192.168.0.101', - includeInCountry: true, - weight: 1, - }, - ], - }, - ], - }, - ], - }; - - it('should call close callback', (done) => { - const props = { - ...defaultProps, - onClose: () => done(), - onSelect: () => {}, - }; - const component = shallow(<SelectLocation {...props} />) - .find(CloseBarItem) - .dive(); - component.simulate('press'); - }); - - it('should call select callback for country', (done) => { - const props = { - ...defaultProps, - onClose: () => {}, - onSelect: (location) => { - try { - expect(location).to.deep.equal({ - country: 'se', - }); - done(); - } catch (e) { - done(e); - } - }, - }; - const component = shallow(<SelectLocation {...props} />); - const elements = getComponent(component, 'country'); - expect(elements).to.have.length(1); - elements.at(0).simulate('press'); - }); - - it('should call select callback for city', (done) => { - const props = { - ...defaultProps, - onClose: () => {}, - onSelect: (location) => { - try { - expect(location).to.deep.equal({ - city: ['se', 'mma'], - }); - done(); - } catch (e) { - done(e); - } - }, - }; - const component = shallow(<SelectLocation {...props} />); - const elements = getComponent(component, 'city'); - expect(elements).to.have.length(2); - elements.at(0).simulate('press'); - }); - - it('should call select callback for relay', (done) => { - const props = { - ...defaultProps, - onClose: () => {}, - onSelect: (location) => { - try { - expect(location).to.deep.equal({ - hostname: ['se', 'mma', 'fake1.mullvad.net'], - }); - done(); - } catch (e) { - done(e); - } - }, - }; - const component = shallow(<SelectLocation {...props} />); - const elements = getComponent(component, 'relay'); - expect(elements).to.have.length(2); - elements.at(0).simulate('press'); - }); -}); - -function getComponent(container, testName) { - return container.findWhere((n) => n.prop('testName') === testName); -} |
