diff options
| -rw-r--r-- | app/assets/images/icon-chevron-down.svg | 1 | ||||
| -rw-r--r-- | app/assets/images/icon-chevron-up.svg | 1 | ||||
| -rw-r--r-- | app/components/SelectLocation.css | 75 | ||||
| -rw-r--r-- | app/components/SelectLocation.js | 215 | ||||
| -rw-r--r-- | app/lib/ipc-facade.js | 6 | ||||
| -rw-r--r-- | test/components/SelectLocation.spec.js | 43 |
6 files changed, 260 insertions, 81 deletions
diff --git a/app/assets/images/icon-chevron-down.svg b/app/assets/images/icon-chevron-down.svg new file mode 100644 index 0000000000..7d7419a75d --- /dev/null +++ b/app/assets/images/icon-chevron-down.svg @@ -0,0 +1 @@ +<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>icon-cheveron-down</title><desc>Created with Sketch.</desc><defs><path d="M11.9497475,15.9497475 C11.6938252,15.9497475 11.4379028,15.8521164 11.2426407,15.6568542 L6.29289322,10.7071068 C5.90236893,10.3165825 5.90236893,9.68341751 6.29289322,9.29289322 C6.68341751,8.90236893 7.31658249,8.90236893 7.70710678,9.29289322 L11.9497475,13.5355339 L16.1923882,9.29289322 C16.5829124,8.90236893 17.2160774,8.90236893 17.6066017,9.29289322 C17.997126,9.68341751 17.997126,10.3165825 17.6066017,10.7071068 L12.6568542,15.6568542 C12.4615921,15.8521164 12.2056698,15.9497475 11.9497475,15.9497475 Z" id="path-1"/></defs><g id="Icons/chevron-down" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"><mask id="mask-2" fill="#fff"><use xlink:href="#path-1"/></mask><use id="Icon-shape" fill="#294D73" xlink:href="#path-1"/></g></svg>
\ No newline at end of file diff --git a/app/assets/images/icon-chevron-up.svg b/app/assets/images/icon-chevron-up.svg new file mode 100644 index 0000000000..fa97da6e26 --- /dev/null +++ b/app/assets/images/icon-chevron-up.svg @@ -0,0 +1 @@ +<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>icon-cheveron-up</title><desc>Created with Sketch.</desc><defs><path d="M11.9497475,15.9497475 C11.6938252,15.9497475 11.4379028,15.8521164 11.2426407,15.6568542 L6.29289322,10.7071068 C5.90236893,10.3165825 5.90236893,9.68341751 6.29289322,9.29289322 C6.68341751,8.90236893 7.31658249,8.90236893 7.70710678,9.29289322 L11.9497475,13.5355339 L16.1923882,9.29289322 C16.5829124,8.90236893 17.2160774,8.90236893 17.6066017,9.29289322 C17.997126,9.68341751 17.997126,10.3165825 17.6066017,10.7071068 L12.6568542,15.6568542 C12.4615921,15.8521164 12.2056698,15.9497475 11.9497475,15.9497475 Z" id="path-1"/></defs><g id="Icons/chevron-up" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"><mask id="mask-2" fill="#fff"><use xlink:href="#path-1"/></mask><use id="Icon-shape" fill="#294D73" transform="translate(11.949747, 12.474874) scale(1, -1) translate(-11.949747, -12.474874)" xlink:href="#path-1"/></g></svg>
\ No newline at end of file diff --git a/app/components/SelectLocation.css b/app/components/SelectLocation.css index cb63f61115..2bf7ec8adc 100644 --- a/app/components/SelectLocation.css +++ b/app/components/SelectLocation.css @@ -48,14 +48,17 @@ padding: 0 24px 24px; } -.select-location__separator { - height: 24px; -} - .select-location__cell { background-color: rgba(41,71,115,1); + display: flex; + flex-direction: row; + align-items: stretch; +} + +.select-location__cell-content { padding: 15px 24px; display: flex; + flex: 1 1 auto; flex-direction: row; align-items: center; } @@ -69,7 +72,7 @@ background-color: #44AD4D; } -.select-location__cell + .select-location__cell { +.select-location__country + .select-location__country { margin-top: 1px; } @@ -86,21 +89,61 @@ height: 24px; flex: 0 0 auto; margin-right: 8px; + align-items: center; + justify-content: center; + display: flex; } -.select-location__cell-value { - flex: 0 0 auto; +.select-location__cities { + margin-top: 1px; } -.select-location__cell-accessory { - margin-left: auto; +.select-location__collapse-button { + border: 0; + background: transparent; + padding: 0; + margin: 0 0 0 auto; + display: flex; + align-items: stretch; + padding: 12px; } -.select-location__cell-footer { - padding: 8px 24px 24px; - font-family: "Open Sans"; - font-size: 13px; - font-weight: 600; - line-height: 20px; - color: rgba(255,255,255,0.8); +.select-location__collapse-icon path { + fill: #fff; } + + +.select-location__sub-cell { + background-color: rgb(36, 57, 84); + padding: 15px 24px 15px 40px; + display: flex; + flex-direction: row; + align-items: center; +} + +.select-location__sub-cell:hover { + background-color: rgba(41,71,115,0.9); +} + +.select-location__sub-cell + .select-location__sub-cell { + margin-top: 1px; +} + +.select-location__sub-cell--selected, +.select-location__sub-cell--selected:hover { + background-color: #44AD4D; +} + +.select-location-relay-status { + width: 16px; + height: 16px; + border-radius: 8px; +} + +.select-location-relay-status--inactive { + background: rgba(208, 2, 27, 0.95); +} + +.select-location-relay-status--active { + background: rgba(68, 173, 77, 0.9); +}
\ No newline at end of file diff --git a/app/components/SelectLocation.js b/app/components/SelectLocation.js index e0f50a12d4..952d3882ea 100644 --- a/app/components/SelectLocation.js +++ b/app/components/SelectLocation.js @@ -2,11 +2,12 @@ import React, { Component } from 'react'; import { Layout, Container, Header } from './Layout'; import CustomScrollbars from './CustomScrollbars'; -import { servers } from '../config'; -import type { ServerInfo } from '../lib/backend'; +import ChevronDownSVG from '../assets/images/icon-chevron-down.svg'; +import ChevronUpSVG from '../assets/images/icon-chevron-up.svg'; + import type { SettingsReduxState } from '../redux/settings/reducers'; -import type { RelayLocation } from '../lib/ipc-facade'; +import type { RelayLocation, RelayListCity, RelayListCountry } from '../lib/ipc-facade'; export type SelectLocationProps = { settings: SettingsReduxState, @@ -18,54 +19,25 @@ export default class SelectLocation extends Component { props: SelectLocationProps; _selectedCell: ?HTMLElement; - _onSelect(location: RelayLocation) { - if (!this._isSelected(location)) { - this.props.onSelect(location); - } - } + state = { + expanded: ([]: Array<string>), + }; - _isSelected(selectedLocation: RelayLocation) { - const { relaySettings } = this.props.settings; - if(relaySettings.normal) { - const otherLocation = relaySettings.normal.location; + constructor(props: SelectLocationProps, context?: any) { + super(props, context); - 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]); + // set initially expanded country based on relaySettings + const relaySettings = this.props.settings.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) { + this.state.expanded.push(location.city[0]); } } - return false; - } - - drawCell(key: string, name: string, selected: bool, icon: ?string, onClick: (e: Event) => void): React.Element<*> { - const classes = ['select-location__cell']; - if(selected) { - classes.push('select-location__cell--selected'); - } - const cellClass = classes.join(' '); - const onRef = selected ? (element) => { - this._selectedCell = element; - } : undefined; - - return ( - <div key={ key } className={ cellClass } onClick={ onClick } ref={ onRef }> - - { icon && <img className="select-location__cell-icon" src={ icon } />} - - <div className="select-location__cell-label">{ name }</div> - - { selected && <img className="select-location__cell-accessory" src="./assets/images/icon-tick.svg" /> } - - </div> - ); } componentDidMount() { @@ -81,7 +53,7 @@ export default class SelectLocation extends Component { } } - render(): React.Element<*> { + render() { return ( <Layout> <Header hidden={ true } style={ 'defaultDark' } /> @@ -99,16 +71,8 @@ export default class SelectLocation extends Component { While connected, your real location is masked with a private and secure location in the selected region </div> - <div className="select-location__separator"></div> - - { (servers: Array<ServerInfo>).map((server) => { - const { address, name, country_code, city_code } = server; - const relayLocation = { - city: [ country_code, city_code ] - }; - const selected = this._isSelected(relayLocation); - const clickHandler = () => this._onSelect(relayLocation); - return this.drawCell(address, name, selected, null, clickHandler); + { this.props.settings.relayLocations.countries.map((relayCountry) => { + return this._renderCountry(relayCountry); }) } </div> @@ -119,4 +83,139 @@ export default class SelectLocation extends Component { </Layout> ); } + + _onSelect(location: RelayLocation) { + if (!this._isSelected(location)) { + this.props.onSelect(location); + } + } + + _isSelected(selectedLocation: RelayLocation) { + const { relaySettings } = this.props.settings; + 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]); + } + } + return false; + } + + _toggleCollapse = (countryCode: string) => { + this.setState((state) => { + const expanded = state.expanded.slice(); + const index = expanded.indexOf(countryCode); + if(index === -1) { + expanded.push(countryCode); + } else { + expanded.splice(index, 1); + } + return { expanded }; + }); + } + + _relayStatusIndicator(active: boolean) { + const statusClass = active ? 'select-location-relay-status--active' : 'select-location-relay-status--inactive'; + + return (<div className={ 'select-location-relay-status ' + statusClass }></div>); + } + + _renderCountry(relayCountry: RelayListCountry) { + const countryHasActiveRelays = relayCountry.cities.some((relayCity) => { + return relayCity.has_active_relays; + }); + + const isSelected = this._isSelected({ country: relayCountry.code }); + + // either expanded by user or when the city selected within the country + const isExpanded = this.state.expanded.includes(relayCountry.code); + + const handleSelect = () => this._onSelect({ country: relayCountry.code }); + const handleCollapse = (e) => { + this._toggleCollapse(relayCountry.code); + e.stopPropagation(); + }; + + const countryClass = 'select-location__cell ' + + (isSelected ? 'select-location__cell--selected' : ''); + + const onRef = isSelected ? (element) => { + this._selectedCell = element; + } : undefined; + + return ( + <div key={ relayCountry.code } className="select-location__country"> + <div className={ countryClass } + onClick={ handleSelect } + ref={ onRef }> + <div className="select-location__cell-content"> + + <div className="select-location__cell-icon"> + { isSelected ? + <img src="./assets/images/icon-tick.svg" /> : + this._relayStatusIndicator(countryHasActiveRelays) } + </div> + + <div className="select-location__cell-label">{ relayCountry.name }</div> + </div> + + { countryHasActiveRelays && <button type="button" className="select-location__collapse-button" onClick={ handleCollapse }> + { isExpanded ? + <ChevronUpSVG className="select-location__collapse-icon" /> : + <ChevronDownSVG className="select-location__collapse-icon" /> } + </button> } + + </div> + + { isExpanded && countryHasActiveRelays && relayCountry.cities.length > 0 && + (<div className="select-location__cities"> + { relayCountry.cities.map((relayCity) => this._renderCity(relayCountry.code, relayCity)) } + </div>) + } + </div> + ); + } + + _renderCity(countryCode: string, relayCity: RelayListCity) { + const relayLocation: RelayLocation = { city: [countryCode, relayCity.code] }; + + const handleSelect = () => this._onSelect(relayLocation); + + const isSelected = this._isSelected(relayLocation); + const key = countryCode + '_' + relayCity.code; + + const cityClass = 'select-location__sub-cell ' + + (isSelected ? 'select-location__sub-cell--selected' : ''); + + const onRef = isSelected ? (element) => { + this._selectedCell = element; + } : undefined; + + return ( + <div key={ key } + className={ cityClass } + onClick={ handleSelect } + ref={ onRef }> + + <div className="select-location__cell-icon"> + { isSelected ? + <img src="./assets/images/icon-tick.svg" /> : + this._relayStatusIndicator(relayCity.has_active_relays) } + </div> + + <div className="select-location__cell-label">{ relayCity.name }</div> + </div> + ); + } + } diff --git a/app/lib/ipc-facade.js b/app/lib/ipc-facade.js index 2fa4212622..567a6ab8f1 100644 --- a/app/lib/ipc-facade.js +++ b/app/lib/ipc-facade.js @@ -1,7 +1,7 @@ // @flow import JsonRpcWs, { InvalidReply } from './jsonrpc-ws-ipc'; -import { object, string, arrayOf, number, enumeration, oneOf } from 'validated/schema'; +import { object, string, number, boolean, enumeration, arrayOf, oneOf } from 'validated/schema'; import { validate } from 'validated/object'; import type { Coordinate2d } from '../types'; @@ -121,7 +121,8 @@ export type RelayListCountry = { export type RelayListCity = { name: string, code: string, - position: [Number, Number], + position: [number, number], + has_active_relays: boolean, }; const RelayListSchema = object({ @@ -132,6 +133,7 @@ const RelayListSchema = object({ name: string, code: string, position: arrayOf(number), + has_active_relays: boolean, })), })), }); diff --git a/test/components/SelectLocation.spec.js b/test/components/SelectLocation.spec.js index 099594ca66..3266fe7aa9 100644 --- a/test/components/SelectLocation.spec.js +++ b/test/components/SelectLocation.spec.js @@ -18,7 +18,16 @@ describe('components/SelectLocation', () => { } }, relayLocations: { - countries: [], + countries: [{ + name: 'Sweden', + code: 'se', + cities: [{ + name: 'Malmö', + code: 'mma', + position: [0, 0], + has_active_relays: true, + }], + }], }, }; @@ -45,13 +54,37 @@ describe('components/SelectLocation', () => { Simulate.click(domNode); }); - it('should call select callback', (done) => { + it('should call select callback for country', (done) => { const props = makeProps(state, { - onSelect: (_server) => done() + onSelect: (location) => { + done(new Error('Fix me! ' + JSON.stringify(location))); + } }); const elements = ReactTestUtils.scryRenderedDOMComponentsWithClass(render(props), 'select-location__cell'); - expect(elements).to.have.length.greaterThan(1); - Simulate.click(elements[1]); + expect(elements).to.have.length(1); + Simulate.click(elements[0]); + }); + + it('should call select callback for city', (done) => { + const testState = { + ...state, + relaySettings: { + normal: { + // should auto-expand Sweden + location: { city: ['se', 'mma'] }, + protocol: 'any', + port: 'any', + } + } + }; + const props = makeProps(testState, { + onSelect: (location) => { + done(new Error('Fix me! ' + JSON.stringify(location))); + } + }); + const elements = ReactTestUtils.scryRenderedDOMComponentsWithClass(render(props), 'select-location__sub-cell'); + expect(elements).to.have.length(1); + Simulate.click(elements[0]); }); }); |
