summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2019-01-11 14:45:59 +0100
committerAndrej Mihajlov <and@mullvad.net>2019-01-11 14:45:59 +0100
commit9ce9e23b5d3335e29e2f13c3f673c72e7d28c0a4 (patch)
tree4bd487421c4f19144687c20952768abbdf8531b2
parente0ca3662e1ea57ef09001ab1b8a4b529dcdd72cf (diff)
parentbc0a8defa9a04a750672b7370a75a25d9dcb3788 (diff)
downloadmullvadvpn-9ce9e23b5d3335e29e2f13c3f673c72e7d28c0a4.tar.xz
mullvadvpn-9ce9e23b5d3335e29e2f13c3f673c72e7d28c0a4.zip
Merge branch 'optimize-select-location'
-rw-r--r--gui/packages/components/src/Accordion.tsx151
-rw-r--r--gui/packages/components/src/HeaderBar.tsx1
-rw-r--r--gui/packages/desktop/src/renderer/components/AdvancedSettings.js4
-rw-r--r--gui/packages/desktop/src/renderer/components/AdvancedSettingsStyles.js3
-rw-r--r--gui/packages/desktop/src/renderer/components/Cell.js2
-rw-r--r--gui/packages/desktop/src/renderer/components/ChevronButton.js35
-rw-r--r--gui/packages/desktop/src/renderer/components/CityRow.js107
-rw-r--r--gui/packages/desktop/src/renderer/components/CountryRow.js113
-rw-r--r--gui/packages/desktop/src/renderer/components/Login.js2
-rw-r--r--gui/packages/desktop/src/renderer/components/RelayRow.js56
-rw-r--r--gui/packages/desktop/src/renderer/components/RelayStatusIndicator.js53
-rw-r--r--gui/packages/desktop/src/renderer/components/SelectLocation.js358
-rw-r--r--gui/packages/desktop/src/renderer/components/SelectLocationStyles.js74
-rw-r--r--gui/packages/desktop/test/components/SelectLocation.spec.js142
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);
-}