diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2022-11-09 15:14:11 +0100 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2022-11-24 16:26:28 +0100 |
| commit | ac580446338e20571c2089e9bede1b22fb8c0d73 (patch) | |
| tree | 7bdd763924641388612b7703cb4dd943d842e095 /gui/src | |
| parent | 8c40662a0816a43f2423a68596ea2f1b0b3a492a (diff) | |
| download | mullvadvpn-ac580446338e20571c2089e9bede1b22fb8c0d73.tar.xz mullvadvpn-ac580446338e20571c2089e9bede1b22fb8c0d73.zip | |
Move select location components to new directory and split components up
Diffstat (limited to 'gui/src')
13 files changed, 818 insertions, 741 deletions
diff --git a/gui/src/renderer/components/LocationList.tsx b/gui/src/renderer/components/LocationList.tsx deleted file mode 100644 index 19c3eca0a5..0000000000 --- a/gui/src/renderer/components/LocationList.tsx +++ /dev/null @@ -1,635 +0,0 @@ -import * as React from 'react'; -import { sprintf } from 'sprintf-js'; -import styled from 'styled-components'; - -import { colors } from '../../config.json'; -import { - compareRelayLocation, - compareRelayLocationLoose, - RelayLocation, - relayLocationComponents, -} from '../../shared/daemon-rpc-types'; -import { messages, relayLocations } from '../../shared/gettext'; -import { - IRelayLocationCityRedux, - IRelayLocationRedux, - IRelayLocationRelayRedux, -} from '../redux/settings/reducers'; -import * as Cell from './cell'; -import InfoButton from './InfoButton'; -import LocationRow, { - StyledLocationRowButton, - StyledLocationRowContainer, - StyledLocationRowIcon, - StyledLocationRowLabel, -} from './LocationRow'; - -export enum LocationSelectionType { - relay = 'relay', - special = 'special', -} - -export type LocationSelection<SpecialValueType> = - | { type: LocationSelectionType.special; value: SpecialValueType } - | { type: LocationSelectionType.relay; value: RelayLocation }; - -interface ILocationListState<SpecialValueType> { - selectedValue?: LocationSelection<SpecialValueType>; - expandedLocations: RelayLocation[]; -} - -interface ILocationListProps<SpecialValueType> { - defaultExpandedLocations?: RelayLocation[]; - selectedValue?: LocationSelection<SpecialValueType>; - selectedElementRef?: React.Ref<React.ReactInstance>; - onSelect?: (value: LocationSelection<SpecialValueType>) => void; - children?: React.ReactNode; -} - -export default class LocationList<SpecialValueType> extends React.Component< - ILocationListProps<SpecialValueType>, - ILocationListState<SpecialValueType> -> { - public state: ILocationListState<SpecialValueType> = { - expandedLocations: [], - }; - - public selectedRelayLocationRef: React.ReactInstance | null = null; - public selectedSpecialLocationRef: React.ReactInstance | null = null; - - constructor(props: ILocationListProps<SpecialValueType>) { - super(props); - - if (props.selectedValue) { - const expandedLocations = - props.defaultExpandedLocations || - (props.selectedValue.type === LocationSelectionType.relay - ? expandRelayLocation(props.selectedValue.value) - : []); - - this.state = { - selectedValue: props.selectedValue, - expandedLocations, - }; - } - } - - public getExpandedLocations(): RelayLocation[] { - return this.state.expandedLocations; - } - - public componentDidUpdate(prevProps: ILocationListProps<SpecialValueType>) { - if (!compareLocationSelectionLoose(prevProps.selectedValue, this.props.selectedValue)) { - this.setState({ selectedValue: this.props.selectedValue }); - } - } - - public render() { - const selection = this.state.selectedValue; - const specialSelection = - selection && selection.type === LocationSelectionType.special ? selection.value : undefined; - const relaySelection = - selection && selection.type === LocationSelectionType.relay ? selection.value : undefined; - - return ( - <> - {React.Children.map(this.props.children, (child) => { - if (React.isValidElement(child)) { - if (child.type === SpecialLocations) { - return React.cloneElement(child, { - ...child.props, - selectedElementRef: this.onSpecialLocationRef, - selectedValue: specialSelection, - onSelect: this.onSelectSpecialLocation, - }); - } else if (child.type === RelayLocations) { - return React.cloneElement(child, { - ...child.props, - selectedLocation: relaySelection, - selectedElementRef: this.onRelayLocationRef, - expandedItems: this.state.expandedLocations, - onSelect: this.onSelectRelayLocation, - onExpand: this.onExpandRelayLocation, - }); - } - } - return child; - })} - </> - ); - } - - private onSpecialLocationRef = (ref: React.ReactInstance | null) => { - this.selectedSpecialLocationRef = ref; - - this.updateExternalRef(); - }; - - private onRelayLocationRef = (ref: React.ReactInstance | null) => { - this.selectedRelayLocationRef = ref; - - this.updateExternalRef(); - }; - - private updateExternalRef() { - if (this.props.selectedElementRef) { - const value = this.selectedRelayLocationRef || this.selectedSpecialLocationRef; - - if (typeof this.props.selectedElementRef === 'function') { - this.props.selectedElementRef(value); - } else { - const ref = this.props - .selectedElementRef as React.MutableRefObject<React.ReactInstance | null>; - ref.current = value; - } - } - } - - private onSelectRelayLocation = (value: RelayLocation) => { - const selectedValue: LocationSelection<SpecialValueType> = { - type: LocationSelectionType.relay, - value, - }; - - this.setState({ selectedValue }, () => { - this.notifySelection(selectedValue); - }); - }; - - private onSelectSpecialLocation = (value: SpecialValueType) => { - const selectedValue: LocationSelection<SpecialValueType> = { - type: LocationSelectionType.special, - value, - }; - - this.setState({ selectedValue }, () => { - this.notifySelection(selectedValue); - }); - }; - - private notifySelection(value: LocationSelection<SpecialValueType>) { - if (this.props.onSelect) { - this.props.onSelect(value); - } - } - - private onExpandRelayLocation = (location: RelayLocation, expand: boolean) => { - this.setState((state) => { - const expandedLocations = state.expandedLocations.filter( - (item) => !compareRelayLocation(item, location), - ); - - if (expand) { - expandedLocations.push(location); - } - - return { - ...state, - expandedLocations, - }; - }); - }; -} - -export enum SpecialLocationIcon { - geoLocation = 'icon-nearest', -} - -interface ISpecialLocationsProps<T> { - children: React.ReactNode; - selectedValue?: T; - selectedElementRef?: React.Ref<SpecialLocation<T>>; - onSelect?: (value: T) => void; -} - -export function SpecialLocations<T>(props: ISpecialLocationsProps<T>) { - return ( - <> - {React.Children.map(props.children, (child) => { - if (React.isValidElement(child) && child.type === SpecialLocation) { - const isSelected = props.selectedValue === child.props.value; - - return React.cloneElement(child, { - ...child.props, - forwardedRef: isSelected ? props.selectedElementRef : undefined, - onSelect: props.onSelect, - isSelected, - }); - } else { - return undefined; - } - })} - </> - ); -} - -const StyledLocationRowContainerWithMargin = styled(StyledLocationRowContainer)({ - marginBottom: 1, -}); - -const StyledSpecialLocationIcon = styled(Cell.Icon)({ - flex: 0, - marginLeft: '2px', - marginRight: '8px', -}); - -const StyledSpecialLocationInfoButton = styled(InfoButton)({ - margin: 0, - padding: '0 25px', -}); - -interface ISpecialLocationProps<T> { - icon: SpecialLocationIcon; - value: T; - isSelected?: boolean; - onSelect?: (value: T) => void; - info?: string; - forwardedRef?: React.Ref<HTMLButtonElement>; - children?: React.ReactNode; -} - -export class SpecialLocation<T> extends React.Component<ISpecialLocationProps<T>> { - public render() { - return ( - <StyledLocationRowContainerWithMargin> - <StyledLocationRowButton onClick={this.onSelect} selected={this.props.isSelected ?? false}> - <StyledSpecialLocationIcon - source={this.props.isSelected ? 'icon-tick' : this.props.icon} - tintColor={colors.white} - height={22} - width={22} - /> - <StyledLocationRowLabel>{this.props.children}</StyledLocationRowLabel> - </StyledLocationRowButton> - <StyledLocationRowIcon - as={StyledSpecialLocationInfoButton} - message={this.props.info} - selected={this.props.isSelected ?? false} - aria-label={messages.pgettext('accessibility', 'info')} - /> - </StyledLocationRowContainerWithMargin> - ); - } - - private onSelect = () => { - if (!this.props.isSelected && this.props.onSelect) { - this.props.onSelect(this.props.value); - } - }; -} - -export enum DisabledReason { - entry, - exit, - inactive, -} - -interface IRelayLocationsProps { - source: IRelayLocationRedux[]; - locale: string; - selectedLocation?: RelayLocation; - selectedElementRef?: React.Ref<React.ReactInstance>; - expandedItems?: RelayLocation[]; - disabledLocation?: { location: RelayLocation; reason: DisabledReason }; - onSelect?: (location: RelayLocation) => void; - onExpand?: (location: RelayLocation, expand: boolean) => void; - onWillExpand?: (locationRect: DOMRect, expandedContentHeight: number) => void; - onTransitionEnd?: () => void; -} - -interface Relay extends IRelayLocationRelayRedux { - label: string; - disabled: boolean; -} - -interface City extends Omit<IRelayLocationCityRedux, 'relays'> { - label: string; - active: boolean; - disabled: boolean; - relays: Array<Relay>; -} - -interface Country extends Omit<IRelayLocationRedux, 'cities'> { - label: string; - active: boolean; - disabled: boolean; - cities: Array<City>; -} - -type CountryList = Array<Country>; - -interface IRelayLocationsState { - countries: CountryList; -} - -interface ICommonCellProps { - location: RelayLocation; - selected: boolean; - ref?: React.Ref<HTMLDivElement>; -} - -export class RelayLocations extends React.PureComponent< - IRelayLocationsProps, - IRelayLocationsState -> { - public state = { - countries: this.prepareRelaysForPresentation(this.props.source), - }; - - public componentDidUpdate(prevProps: IRelayLocationsProps) { - if (this.props.source !== prevProps.source) { - this.setState({ countries: this.prepareRelaysForPresentation(this.props.source) }); - } - } - - public render() { - return ( - <Cell.Group noMarginBottom> - {this.state.countries.map((relayCountry) => { - const countryLocation: RelayLocation = { country: relayCountry.code }; - - return ( - <LocationRow - key={getLocationKey(countryLocation)} - name={relayCountry.label} - active={relayCountry.active} - disabled={relayCountry.disabled} - expanded={this.isExpanded(countryLocation)} - onSelect={this.handleSelection} - onExpand={this.handleExpand} - onWillExpand={this.props.onWillExpand} - onTransitionEnd={this.props.onTransitionEnd} - {...this.getCommonCellProps(countryLocation)}> - {relayCountry.cities.map((relayCity) => { - const cityLocation: RelayLocation = { - city: [relayCountry.code, relayCity.code], - }; - - return ( - <LocationRow - key={getLocationKey(cityLocation)} - name={relayCity.label} - active={relayCity.active} - disabled={relayCity.disabled} - expanded={this.isExpanded(cityLocation)} - onSelect={this.handleSelection} - onExpand={this.handleExpand} - onWillExpand={this.props.onWillExpand} - onTransitionEnd={this.props.onTransitionEnd} - {...this.getCommonCellProps(cityLocation)}> - {relayCity.relays.map((relay) => { - const relayLocation: RelayLocation = { - hostname: [relayCountry.code, relayCity.code, relay.hostname], - }; - - return ( - <LocationRow - key={getLocationKey(relayLocation)} - name={relay.label} - active={relay.active} - disabled={relay.disabled} - onSelect={this.handleSelection} - {...this.getCommonCellProps(relayLocation)} - /> - ); - })} - </LocationRow> - ); - })} - </LocationRow> - ); - })} - </Cell.Group> - ); - } - - private prepareRelaysForPresentation(relayList: IRelayLocationRedux[]): CountryList { - return relayList - .map((country) => { - const countryDisabled = this.isCountryDisabled(country, country.code); - const countryLocation = { country: country.code }; - - return { - ...country, - label: this.formatRowName(country.name, countryLocation, countryDisabled), - active: countryDisabled !== DisabledReason.inactive, - disabled: countryDisabled !== undefined, - cities: country.cities - .map((city) => { - const cityDisabled = - countryDisabled ?? this.isCityDisabled(city, [country.code, city.code]); - const cityLocation: RelayLocation = { city: [country.code, city.code] }; - - return { - ...city, - label: this.formatRowName(city.name, cityLocation, cityDisabled), - active: cityDisabled !== DisabledReason.inactive, - disabled: cityDisabled !== undefined, - relays: city.relays - .map((relay) => { - const relayDisabled = - countryDisabled ?? - cityDisabled ?? - this.isRelayDisabled(relay, [country.code, city.code, relay.hostname]); - const relayLocation: RelayLocation = { - hostname: [country.code, city.code, relay.hostname], - }; - - return { - ...relay, - label: this.formatRowName(relay.hostname, relayLocation, relayDisabled), - disabled: relayDisabled !== undefined, - }; - }) - .sort((a, b) => - a.hostname.localeCompare(b.hostname, this.props.locale, { numeric: true }), - ), - }; - }) - .sort((a, b) => a.label.localeCompare(b.label, this.props.locale)), - }; - }) - .sort((a, b) => a.label.localeCompare(b.label, this.props.locale)); - } - - private formatRowName( - name: string, - location: RelayLocation, - disabledReason?: DisabledReason, - ): string { - const translatedName = 'hostname' in location ? name : relayLocations.gettext(name); - const disabledLocation = this.props.disabledLocation; - const matchDisabledLocation = compareRelayLocationLoose(location, disabledLocation?.location); - - let info: string | undefined; - if ( - disabledReason === DisabledReason.entry || - (matchDisabledLocation && disabledLocation?.reason === DisabledReason.entry) - ) { - info = messages.pgettext('select-location-view', 'Entry'); - } else if ( - disabledReason === DisabledReason.exit || - (matchDisabledLocation && disabledLocation?.reason === DisabledReason.exit) - ) { - info = messages.pgettext('select-location-view', 'Exit'); - } - - return info !== undefined - ? sprintf( - // TRANSLATORS: This is used for appending information about a location. - // TRANSLATORS: E.g. "Gothenburg (Entry)" if Gothenburg has been selected as the entrypoint. - // TRANSLATORS: Available placeholders: - // TRANSLATORS: %(location)s - Translated location name - // TRANSLATORS: %(info)s - Information about the location - messages.pgettext('select-location-view', '%(location)s (%(info)s)'), - { - location: translatedName, - info, - }, - ) - : translatedName; - } - - private isRelayDisabled( - relay: IRelayLocationRelayRedux, - location: [string, string, string], - ): DisabledReason | undefined { - if (!relay.active) { - return DisabledReason.inactive; - } else if ( - this.props.disabledLocation && - compareRelayLocation({ hostname: location }, this.props.disabledLocation.location) - ) { - return this.props.disabledLocation.reason; - } else { - return undefined; - } - } - - private isCityDisabled( - city: IRelayLocationCityRedux, - location: [string, string], - ): DisabledReason | undefined { - const relaysDisabled = city.relays.map((relay) => - this.isRelayDisabled(relay, [...location, relay.hostname]), - ); - if (relaysDisabled.every((status) => status === DisabledReason.inactive)) { - return DisabledReason.inactive; - } - - const disabledDueToSelection = relaysDisabled.find( - (status) => status === DisabledReason.entry || status === DisabledReason.exit, - ); - - if ( - relaysDisabled.every((status) => status !== undefined) && - disabledDueToSelection !== undefined - ) { - return disabledDueToSelection; - } - - if ( - this.props.disabledLocation && - compareRelayLocation({ city: location }, this.props.disabledLocation.location) && - city.relays.filter((relay) => relay.active).length <= 1 - ) { - return this.props.disabledLocation.reason; - } - - return undefined; - } - - private isCountryDisabled( - country: IRelayLocationRedux, - location: string, - ): DisabledReason | undefined { - const citiesDisabled = country.cities.map((city) => - this.isCityDisabled(city, [location, city.code]), - ); - if (citiesDisabled.every((status) => status === DisabledReason.inactive)) { - return DisabledReason.inactive; - } - - const disabledDueToSelection = citiesDisabled.find( - (status) => status === DisabledReason.entry || status === DisabledReason.exit, - ); - if ( - citiesDisabled.every((status) => status !== undefined) && - disabledDueToSelection !== undefined - ) { - return disabledDueToSelection; - } - - if ( - this.props.disabledLocation && - compareRelayLocation({ country: location }, this.props.disabledLocation.location) && - country.cities.flatMap((city) => city.relays).filter((relay) => relay.active).length <= 1 - ) { - return this.props.disabledLocation.reason; - } - - return undefined; - } - - private isExpanded(relayLocation: RelayLocation) { - return (this.props.expandedItems || []).some((location) => - compareRelayLocation(location, relayLocation), - ); - } - - private isSelected(relayLocation: RelayLocation) { - return compareRelayLocationLoose(this.props.selectedLocation, relayLocation); - } - - private handleSelection = (location: RelayLocation) => { - if (!compareRelayLocationLoose(this.props.selectedLocation, location)) { - if (this.props.onSelect) { - this.props.onSelect(location); - } - } - }; - - private handleExpand = (location: RelayLocation, expand: boolean) => { - if (this.props.onExpand) { - this.props.onExpand(location, expand); - } - }; - - private getCommonCellProps(location: RelayLocation): ICommonCellProps { - const selected = this.isSelected(location); - const ref = - selected && this.props.selectedElementRef ? this.props.selectedElementRef : undefined; - - return { ref: ref as React.Ref<HTMLDivElement>, selected, location }; - } -} - -function expandRelayLocation(location: RelayLocation): RelayLocation[] { - const expandedItems: RelayLocation[] = []; - - if ('city' in location) { - expandedItems.push({ country: location.city[0] }); - } else if ('hostname' in location) { - expandedItems.push({ country: location.hostname[0] }); - expandedItems.push({ city: [location.hostname[0], location.hostname[1]] }); - } - - return expandedItems; -} - -function getLocationKey(location: RelayLocation): string { - return relayLocationComponents(location).join('-'); -} - -function compareLocationSelectionLoose<SpecialValueType>( - lhs?: LocationSelection<SpecialValueType>, - rhs?: LocationSelection<SpecialValueType>, -) { - if (!lhs || !rhs) { - return lhs === rhs; - } else if (lhs.type === LocationSelectionType.relay && rhs.type === LocationSelectionType.relay) { - return compareRelayLocation(lhs.value, rhs.value); - } else { - return lhs.value === rhs.value; - } -} diff --git a/gui/src/renderer/components/BridgeLocations.tsx b/gui/src/renderer/components/select-location/BridgeLocations.tsx index 355b462222..4ab227de7f 100644 --- a/gui/src/renderer/components/BridgeLocations.tsx +++ b/gui/src/renderer/components/select-location/BridgeLocations.tsx @@ -1,16 +1,12 @@ import * as React from 'react'; -import { LiftedConstraint, RelayLocation } from '../../shared/daemon-rpc-types'; -import { messages } from '../../shared/gettext'; -import { IRelayLocationRedux } from '../redux/settings/reducers'; -import LocationList, { - LocationSelection, - LocationSelectionType, - RelayLocations, - SpecialLocation, - SpecialLocationIcon, - SpecialLocations, -} from './LocationList'; +import { LiftedConstraint, RelayLocation } from '../../../shared/daemon-rpc-types'; +import { messages } from '../../../shared/gettext'; +import { IRelayLocationRedux } from '../../redux/settings/reducers'; +import LocationList, { LocationSelection, LocationSelectionType } from './LocationList'; +import { RelayLocations } from './RelayLocations'; +import { SpecialLocation, SpecialLocationIcon } from './SpecialLocation'; +import { SpecialLocations } from './SpecialLocations'; export enum SpecialBridgeLocationType { closestToExit = 0, @@ -18,6 +14,7 @@ export enum SpecialBridgeLocationType { interface IBridgeLocationsProps { source: IRelayLocationRedux[]; + filter: string; locale: string; defaultExpandedLocations?: RelayLocation[]; selectedValue?: LiftedConstraint<RelayLocation>; @@ -46,19 +43,22 @@ const BridgeLocations = React.forwardRef(function BridgeLocationsT( selectedValue={selectedValue} selectedElementRef={props.selectedElementRef} onSelect={props.onSelect}> - <SpecialLocations> - <SpecialLocation - icon={SpecialLocationIcon.geoLocation} - value={SpecialBridgeLocationType.closestToExit} - info={messages.pgettext( - 'select-location-view', - 'The app selects a random bridge server, but servers have a higher probability the closer they are to you.', - )}> - {messages.gettext('Automatic')} - </SpecialLocation> - </SpecialLocations> + {!props.filter && ( + <SpecialLocations> + <SpecialLocation + icon={SpecialLocationIcon.geoLocation} + value={SpecialBridgeLocationType.closestToExit} + info={messages.pgettext( + 'select-location-view', + 'The app selects a random bridge server, but servers have a higher probability the closer they are to you.', + )}> + {messages.gettext('Automatic')} + </SpecialLocation> + </SpecialLocations> + )} <RelayLocations source={props.source} + filter={props.filter} locale={props.locale} onWillExpand={props.onWillExpand} onTransitionEnd={props.onTransitionEnd} diff --git a/gui/src/renderer/components/select-location/LocationList.tsx b/gui/src/renderer/components/select-location/LocationList.tsx new file mode 100644 index 0000000000..7ba3b76740 --- /dev/null +++ b/gui/src/renderer/components/select-location/LocationList.tsx @@ -0,0 +1,198 @@ +import * as React from 'react'; + +import { compareRelayLocation, RelayLocation } from '../../../shared/daemon-rpc-types'; +import { RelayLocations } from './RelayLocations'; +import { SpecialLocations } from './SpecialLocations'; + +export enum LocationSelectionType { + relay = 'relay', + special = 'special', +} + +export type LocationSelection<SpecialValueType> = + | { type: LocationSelectionType.special; value: SpecialValueType } + | { type: LocationSelectionType.relay; value: RelayLocation }; + +interface ILocationListState<SpecialValueType> { + selectedValue?: LocationSelection<SpecialValueType>; + expandedLocations: RelayLocation[]; +} + +interface ILocationListProps<SpecialValueType> { + defaultExpandedLocations?: RelayLocation[]; + selectedValue?: LocationSelection<SpecialValueType>; + selectedElementRef?: React.Ref<React.ReactInstance>; + onSelect?: (value: LocationSelection<SpecialValueType>) => void; + children?: React.ReactNode; +} + +export default class LocationList<SpecialValueType> extends React.Component< + ILocationListProps<SpecialValueType>, + ILocationListState<SpecialValueType> +> { + public state: ILocationListState<SpecialValueType> = { + expandedLocations: [], + }; + + public selectedRelayLocationRef: React.ReactInstance | null = null; + public selectedSpecialLocationRef: React.ReactInstance | null = null; + + constructor(props: ILocationListProps<SpecialValueType>) { + super(props); + + if (props.selectedValue) { + const expandedLocations = + props.defaultExpandedLocations || + (props.selectedValue.type === LocationSelectionType.relay + ? expandRelayLocation(props.selectedValue.value) + : []); + + this.state = { + selectedValue: props.selectedValue, + expandedLocations, + }; + } + } + + public getExpandedLocations(): RelayLocation[] { + return this.state.expandedLocations; + } + + public componentDidUpdate(prevProps: ILocationListProps<SpecialValueType>) { + if (!compareLocationSelectionLoose(prevProps.selectedValue, this.props.selectedValue)) { + this.setState({ selectedValue: this.props.selectedValue }); + } + } + + public render() { + const selection = this.state.selectedValue; + const specialSelection = + selection && selection.type === LocationSelectionType.special ? selection.value : undefined; + const relaySelection = + selection && selection.type === LocationSelectionType.relay ? selection.value : undefined; + + return ( + <> + {React.Children.map(this.props.children, (child) => { + if (React.isValidElement(child)) { + if (child.type === SpecialLocations) { + return React.cloneElement(child, { + ...child.props, + selectedElementRef: this.onSpecialLocationRef, + selectedValue: specialSelection, + onSelect: this.onSelectSpecialLocation, + }); + } else if (child.type === RelayLocations) { + return React.cloneElement(child, { + ...child.props, + selectedLocation: relaySelection, + selectedElementRef: this.onRelayLocationRef, + expandedItems: this.state.expandedLocations, + onSelect: this.onSelectRelayLocation, + onExpand: this.onExpandRelayLocation, + }); + } + } + return child; + })} + </> + ); + } + + private onSpecialLocationRef = (ref: React.ReactInstance | null) => { + this.selectedSpecialLocationRef = ref; + + this.updateExternalRef(); + }; + + private onRelayLocationRef = (ref: React.ReactInstance | null) => { + this.selectedRelayLocationRef = ref; + + this.updateExternalRef(); + }; + + private updateExternalRef() { + if (this.props.selectedElementRef) { + const value = this.selectedRelayLocationRef || this.selectedSpecialLocationRef; + + if (typeof this.props.selectedElementRef === 'function') { + this.props.selectedElementRef(value); + } else { + const ref = this.props + .selectedElementRef as React.MutableRefObject<React.ReactInstance | null>; + ref.current = value; + } + } + } + + private onSelectRelayLocation = (value: RelayLocation) => { + const selectedValue: LocationSelection<SpecialValueType> = { + type: LocationSelectionType.relay, + value, + }; + + this.setState({ selectedValue }, () => { + this.notifySelection(selectedValue); + }); + }; + + private onSelectSpecialLocation = (value: SpecialValueType) => { + const selectedValue: LocationSelection<SpecialValueType> = { + type: LocationSelectionType.special, + value, + }; + + this.setState({ selectedValue }, () => { + this.notifySelection(selectedValue); + }); + }; + + private notifySelection(value: LocationSelection<SpecialValueType>) { + if (this.props.onSelect) { + this.props.onSelect(value); + } + } + + private onExpandRelayLocation = (location: RelayLocation, expand: boolean) => { + this.setState((state) => { + const expandedLocations = state.expandedLocations.filter( + (item) => !compareRelayLocation(item, location), + ); + + if (expand) { + expandedLocations.push(location); + } + + return { + ...state, + expandedLocations, + }; + }); + }; +} + +function expandRelayLocation(location: RelayLocation): RelayLocation[] { + const expandedItems: RelayLocation[] = []; + + if ('city' in location) { + expandedItems.push({ country: location.city[0] }); + } else if ('hostname' in location) { + expandedItems.push({ country: location.hostname[0] }); + expandedItems.push({ city: [location.hostname[0], location.hostname[1]] }); + } + + return expandedItems; +} + +function compareLocationSelectionLoose<SpecialValueType>( + lhs?: LocationSelection<SpecialValueType>, + rhs?: LocationSelection<SpecialValueType>, +) { + if (!lhs || !rhs) { + return lhs === rhs; + } else if (lhs.type === LocationSelectionType.relay && rhs.type === LocationSelectionType.relay) { + return compareRelayLocation(lhs.value, rhs.value); + } else { + return lhs.value === rhs.value; + } +} diff --git a/gui/src/renderer/components/LocationRow.tsx b/gui/src/renderer/components/select-location/LocationRow.tsx index 9172f8bcd3..10a55a17ce 100644 --- a/gui/src/renderer/components/LocationRow.tsx +++ b/gui/src/renderer/components/select-location/LocationRow.tsx @@ -2,14 +2,14 @@ import React, { useCallback, useRef } from 'react'; import { sprintf } from 'sprintf-js'; import styled from 'styled-components'; -import { colors } from '../../config.json'; -import { compareRelayLocation, RelayLocation } from '../../shared/daemon-rpc-types'; -import { messages } from '../../shared/gettext'; -import Accordion from './Accordion'; -import * as Cell from './cell'; -import ChevronButton from './ChevronButton'; -import { measurements, normalText } from './common-styles'; -import RelayStatusIndicator from './RelayStatusIndicator'; +import { colors } from '../../../config.json'; +import { compareRelayLocation, RelayLocation } from '../../../shared/daemon-rpc-types'; +import { messages } from '../../../shared/gettext'; +import Accordion from '../Accordion'; +import * as Cell from '../cell'; +import ChevronButton from '../ChevronButton'; +import { measurements, normalText } from '../common-styles'; +import RelayStatusIndicator from '../RelayStatusIndicator'; interface IButtonColorProps { selected: boolean; @@ -99,6 +99,7 @@ interface IProps { location: RelayLocation; selected: boolean; expanded?: boolean; + expandable: boolean; onSelect?: (location: RelayLocation) => void; onExpand?: (location: RelayLocation, value: boolean) => void; onWillExpand?: (locationRect: DOMRect, expandedContentHeight: number) => void; @@ -107,7 +108,7 @@ interface IProps { } function LocationRow(props: IProps, ref: React.Ref<HTMLDivElement>) { - const hasChildren = props.children !== undefined; + const hasChildren = React.Children.count(props.children) > 0; const buttonRef = useRef<HTMLButtonElement>() as React.RefObject<HTMLButtonElement>; const toggleCollapse = useCallback(() => { @@ -142,7 +143,7 @@ function LocationRow(props: IProps, ref: React.Ref<HTMLDivElement>) { <RelayStatusIndicator active={props.active} selected={props.selected} /> <StyledLocationRowLabel>{props.name}</StyledLocationRowLabel> </StyledLocationRowButton> - {hasChildren ? ( + {hasChildren && props.expandable ? ( <StyledLocationRowIcon as={ChevronButton} onClick={toggleCollapse} @@ -163,7 +164,7 @@ function LocationRow(props: IProps, ref: React.Ref<HTMLDivElement>) { {hasChildren && ( <Accordion expanded={props.expanded} - onWillExpand={onWillExpand} + onWillExpand={props.expandable ? onWillExpand : undefined} onTransitionEnd={props.onTransitionEnd} animationDuration={150}> <Cell.Group noMarginBottom>{props.children}</Cell.Group> diff --git a/gui/src/renderer/components/Locations.tsx b/gui/src/renderer/components/select-location/Locations.tsx index 4f7fadfb5d..acfe21a8f6 100644 --- a/gui/src/renderer/components/Locations.tsx +++ b/gui/src/renderer/components/select-location/Locations.tsx @@ -1,16 +1,13 @@ -import * as React from 'react'; +import React from 'react'; -import { RelayLocation } from '../../shared/daemon-rpc-types'; -import { IRelayLocationRedux } from '../redux/settings/reducers'; -import LocationList, { - DisabledReason, - LocationSelection, - LocationSelectionType, - RelayLocations, -} from './LocationList'; +import { RelayLocation } from '../../../shared/daemon-rpc-types'; +import { IRelayLocationRedux } from '../../redux/settings/reducers'; +import LocationList, { LocationSelection, LocationSelectionType } from './LocationList'; +import { DisabledReason, RelayLocations } from './RelayLocations'; interface ILocationsProps { source: IRelayLocationRedux[]; + filter: string; locale: string; defaultExpandedLocations?: RelayLocation[]; selectedValue?: RelayLocation; @@ -35,6 +32,7 @@ function Locations(props: ILocationsProps, ref: React.Ref<LocationList<never>>) onSelect={props.onSelect}> <RelayLocations source={props.source} + filter={props.filter} locale={props.locale} disabledLocation={props.disabledLocation} onWillExpand={props.onWillExpand} diff --git a/gui/src/renderer/components/select-location/RelayLocations.tsx b/gui/src/renderer/components/select-location/RelayLocations.tsx new file mode 100644 index 0000000000..120c7c548c --- /dev/null +++ b/gui/src/renderer/components/select-location/RelayLocations.tsx @@ -0,0 +1,365 @@ +import React from 'react'; +import { sprintf } from 'sprintf-js'; + +import { + compareRelayLocation, + compareRelayLocationLoose, + RelayLocation, + relayLocationComponents, +} from '../../../shared/daemon-rpc-types'; +import { messages, relayLocations } from '../../../shared/gettext'; +import { + IRelayLocationCityRedux, + IRelayLocationRedux, + IRelayLocationRelayRedux, +} from '../../redux/settings/reducers'; +import * as Cell from '../cell'; +import LocationRow from './LocationRow'; +import { City, Country, Relay } from './types'; + +export enum DisabledReason { + entry, + exit, + inactive, +} + +interface IRelayLocationsProps { + source: IRelayLocationRedux[]; + filter: string; + locale: string; + selectedLocation?: RelayLocation; + selectedElementRef?: React.Ref<React.ReactInstance>; + expandedItems?: RelayLocation[]; + disabledLocation?: { location: RelayLocation; reason: DisabledReason }; + onSelect?: (location: RelayLocation) => void; + onExpand?: (location: RelayLocation, expand: boolean) => void; + onWillExpand?: (locationRect: DOMRect, expandedContentHeight: number) => void; + onTransitionEnd?: () => void; +} + +interface IRelayLocationsState { + countries: Array<Country>; +} + +interface ICommonCellProps { + location: RelayLocation; + selected: boolean; + ref?: React.Ref<HTMLDivElement>; +} + +export class RelayLocations extends React.PureComponent< + IRelayLocationsProps, + IRelayLocationsState +> { + public state = { + countries: this.applyFilter(this.prepareRelaysForPresentation(this.props.source)), + }; + + public componentDidUpdate(prevProps: IRelayLocationsProps) { + if ( + this.props.source !== prevProps.source || + this.props.filter !== prevProps.filter || + this.props.expandedItems !== prevProps.expandedItems + ) { + this.setState({ + countries: this.applyFilter(this.prepareRelaysForPresentation(this.props.source)), + }); + } + } + + public render() { + return ( + <Cell.Group noMarginBottom> + {this.state.countries.map((relayCountry) => { + return ( + <LocationRow + key={getLocationKey(relayCountry.location)} + name={relayCountry.label} + active={relayCountry.active} + disabled={relayCountry.disabled} + expanded={relayCountry.expanded} + expandable={!this.props.filter} + onSelect={this.handleSelection} + onExpand={this.handleExpand} + onWillExpand={this.props.onWillExpand} + onTransitionEnd={this.props.onTransitionEnd} + {...this.getCommonCellProps(relayCountry.location)}> + {relayCountry.cities.map((relayCity) => { + return ( + <LocationRow + key={getLocationKey(relayCity.location)} + name={relayCity.label} + active={relayCity.active} + disabled={relayCity.disabled} + expanded={relayCity.expanded} + expandable={!this.props.filter} + onSelect={this.handleSelection} + onExpand={this.handleExpand} + onWillExpand={this.props.onWillExpand} + onTransitionEnd={this.props.onTransitionEnd} + {...this.getCommonCellProps(relayCity.location)}> + {relayCity.relays.map((relay) => { + return ( + <LocationRow + key={getLocationKey(relay.location)} + name={relay.label} + active={relay.active} + disabled={relay.disabled} + expandable={false} + onSelect={this.handleSelection} + {...this.getCommonCellProps(relay.location)} + /> + ); + })} + </LocationRow> + ); + })} + </LocationRow> + ); + })} + </Cell.Group> + ); + } + + private prepareRelaysForPresentation(relayList: IRelayLocationRedux[]): Array<Country> { + return relayList + .map((country) => { + const countryDisabled = this.isCountryDisabled(country, country.code); + const countryLocation = { country: country.code }; + + return { + ...country, + label: this.formatRowName(country.name, countryLocation, countryDisabled), + location: countryLocation, + active: countryDisabled !== DisabledReason.inactive, + disabled: countryDisabled !== undefined, + expanded: this.isExpanded(countryLocation), + cities: country.cities + .map((city) => { + const cityDisabled = + countryDisabled ?? this.isCityDisabled(city, [country.code, city.code]); + const cityLocation: RelayLocation = { city: [country.code, city.code] }; + + return { + ...city, + label: this.formatRowName(city.name, cityLocation, cityDisabled), + location: cityLocation, + active: cityDisabled !== DisabledReason.inactive, + disabled: cityDisabled !== undefined, + expanded: this.isExpanded(cityLocation), + relays: city.relays + .map((relay) => { + const relayDisabled = + countryDisabled ?? + cityDisabled ?? + this.isRelayDisabled(relay, [country.code, city.code, relay.hostname]); + const relayLocation: RelayLocation = { + hostname: [country.code, city.code, relay.hostname], + }; + + return { + ...relay, + label: this.formatRowName(relay.hostname, relayLocation, relayDisabled), + location: relayLocation, + disabled: relayDisabled !== undefined, + }; + }) + .sort((a, b) => + a.hostname.localeCompare(b.hostname, this.props.locale, { numeric: true }), + ), + }; + }) + .sort((a, b) => a.label.localeCompare(b.label, this.props.locale)), + }; + }) + .sort((a, b) => a.label.localeCompare(b.label, this.props.locale)); + } + + private applyFilter(countries: Array<Country>): Array<Country> { + if (!this.props.filter) { + return countries; + } + + const filter = this.props.filter.toLowerCase(); + return countries.reduce((countries, country) => { + const cities = RelayLocations.filterCities(country.cities, filter); + const match = + cities.length > 0 || + country.code.toLowerCase().includes(filter) || + country.name.toLowerCase().includes(filter); + return match + ? [...countries, { ...country, expanded: cities.length > 0, cities }] + : countries; + }, [] as Array<Country>); + } + + private static filterCities(cities: Array<City>, filter: string): Array<City> { + return cities.reduce((cities, city) => { + const relays = RelayLocations.filterRelays(city.relays, filter); + const match = + relays.length > 0 || + city.code.toLowerCase().includes(filter) || + city.name.toLowerCase().includes(filter); + return match ? [...cities, { ...city, expanded: relays.length > 0, relays }] : cities; + }, [] as Array<City>); + } + + private static filterRelays(relays: Array<Relay>, filter: string): Array<Relay> { + return relays.filter((relay) => relay.hostname.toLowerCase().includes(filter)); + } + + private formatRowName( + name: string, + location: RelayLocation, + disabledReason?: DisabledReason, + ): string { + const translatedName = 'hostname' in location ? name : relayLocations.gettext(name); + const disabledLocation = this.props.disabledLocation; + const matchDisabledLocation = compareRelayLocationLoose(location, disabledLocation?.location); + + let info: string | undefined; + if ( + disabledReason === DisabledReason.entry || + (matchDisabledLocation && disabledLocation?.reason === DisabledReason.entry) + ) { + info = messages.pgettext('select-location-view', 'Entry'); + } else if ( + disabledReason === DisabledReason.exit || + (matchDisabledLocation && disabledLocation?.reason === DisabledReason.exit) + ) { + info = messages.pgettext('select-location-view', 'Exit'); + } + + return info !== undefined + ? sprintf( + // TRANSLATORS: This is used for appending information about a location. + // TRANSLATORS: E.g. "Gothenburg (Entry)" if Gothenburg has been selected as the entrypoint. + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(location)s - Translated location name + // TRANSLATORS: %(info)s - Information about the location + messages.pgettext('select-location-view', '%(location)s (%(info)s)'), + { + location: translatedName, + info, + }, + ) + : translatedName; + } + + private isRelayDisabled( + relay: IRelayLocationRelayRedux, + location: [string, string, string], + ): DisabledReason | undefined { + if (!relay.active) { + return DisabledReason.inactive; + } else if ( + this.props.disabledLocation && + compareRelayLocation({ hostname: location }, this.props.disabledLocation.location) + ) { + return this.props.disabledLocation.reason; + } else { + return undefined; + } + } + + private isCityDisabled( + city: IRelayLocationCityRedux, + location: [string, string], + ): DisabledReason | undefined { + const relaysDisabled = city.relays.map((relay) => + this.isRelayDisabled(relay, [...location, relay.hostname]), + ); + if (relaysDisabled.every((status) => status === DisabledReason.inactive)) { + return DisabledReason.inactive; + } + + const disabledDueToSelection = relaysDisabled.find( + (status) => status === DisabledReason.entry || status === DisabledReason.exit, + ); + + if ( + relaysDisabled.every((status) => status !== undefined) && + disabledDueToSelection !== undefined + ) { + return disabledDueToSelection; + } + + if ( + this.props.disabledLocation && + compareRelayLocation({ city: location }, this.props.disabledLocation.location) && + city.relays.filter((relay) => relay.active).length <= 1 + ) { + return this.props.disabledLocation.reason; + } + + return undefined; + } + + private isCountryDisabled( + country: IRelayLocationRedux, + location: string, + ): DisabledReason | undefined { + const citiesDisabled = country.cities.map((city) => + this.isCityDisabled(city, [location, city.code]), + ); + if (citiesDisabled.every((status) => status === DisabledReason.inactive)) { + return DisabledReason.inactive; + } + + const disabledDueToSelection = citiesDisabled.find( + (status) => status === DisabledReason.entry || status === DisabledReason.exit, + ); + if ( + citiesDisabled.every((status) => status !== undefined) && + disabledDueToSelection !== undefined + ) { + return disabledDueToSelection; + } + + if ( + this.props.disabledLocation && + compareRelayLocation({ country: location }, this.props.disabledLocation.location) && + country.cities.flatMap((city) => city.relays).filter((relay) => relay.active).length <= 1 + ) { + return this.props.disabledLocation.reason; + } + + return undefined; + } + + private isExpanded(relayLocation: RelayLocation) { + return (this.props.expandedItems || []).some((location) => + compareRelayLocation(location, relayLocation), + ); + } + + private isSelected(relayLocation: RelayLocation) { + return compareRelayLocationLoose(this.props.selectedLocation, relayLocation); + } + + private handleSelection = (location: RelayLocation) => { + if (!compareRelayLocationLoose(this.props.selectedLocation, location)) { + if (this.props.onSelect) { + this.props.onSelect(location); + } + } + }; + + private handleExpand = (location: RelayLocation, expand: boolean) => { + if (this.props.onExpand) { + this.props.onExpand(location, expand); + } + }; + + private getCommonCellProps(location: RelayLocation): ICommonCellProps { + const selected = this.isSelected(location); + const ref = + selected && this.props.selectedElementRef ? this.props.selectedElementRef : undefined; + + return { ref: ref as React.Ref<HTMLDivElement>, selected, location }; + } +} + +function getLocationKey(location: RelayLocation): string { + return relayLocationComponents(location).join('-'); +} diff --git a/gui/src/renderer/components/SelectLocation.tsx b/gui/src/renderer/components/select-location/SelectLocation.tsx index 4c228c9a53..0af4a0d8d5 100644 --- a/gui/src/renderer/components/SelectLocation.tsx +++ b/gui/src/renderer/components/select-location/SelectLocation.tsx @@ -1,34 +1,32 @@ import React from 'react'; import { sprintf } from 'sprintf-js'; -import { colors } from '../../config.json'; +import { colors } from '../../../config.json'; import { LiftedConstraint, Ownership, RelayLocation, TunnelProtocol, -} from '../../shared/daemon-rpc-types'; -import { messages } from '../../shared/gettext'; -import { IRelayLocationRedux } from '../redux/settings/reducers'; -import BridgeLocations, { SpecialBridgeLocationType } from './BridgeLocations'; -import { CustomScrollbarsRef } from './CustomScrollbars'; -import ImageView from './ImageView'; -import { BackAction } from './KeyboardNavigation'; -import { Layout, SettingsContainer } from './Layout'; -import LocationList, { - DisabledReason, - LocationSelection, - LocationSelectionType, -} from './LocationList'; -import { EntryLocations, ExitLocations } from './Locations'; +} from '../../../shared/daemon-rpc-types'; +import { messages } from '../../../shared/gettext'; +import { IRelayLocationRedux } from '../../redux/settings/reducers'; +import { CustomScrollbarsRef } from '../CustomScrollbars'; +import ImageView from '../ImageView'; +import { BackAction } from '../KeyboardNavigation'; +import { Layout, SettingsContainer } from '../Layout'; import { NavigationBar, NavigationContainer, NavigationItems, NavigationScrollbars, TitleBarItem, -} from './NavigationBar'; -import { ScopeBarItem } from './ScopeBar'; +} from '../NavigationBar'; +import { ScopeBarItem } from '../ScopeBar'; +import { HeaderSubTitle, HeaderTitle } from '../SettingsHeader'; +import BridgeLocations, { SpecialBridgeLocationType } from './BridgeLocations'; +import LocationList, { LocationSelection, LocationSelectionType } from './LocationList'; +import { EntryLocations, ExitLocations } from './Locations'; +import { DisabledReason } from './RelayLocations'; import { StyledClearFilterButton, StyledContent, @@ -37,9 +35,10 @@ import { StyledFilterRow, StyledNavigationBarAttachment, StyledScopeBar, + StyledSearchBar, StyledSettingsHeader, } from './SelectLocationStyles'; -import { HeaderSubTitle, HeaderTitle } from './SettingsHeader'; +import { SpacePreAllocationView } from './SpacePreAllocationView'; interface IProps { locale: string; @@ -70,6 +69,7 @@ enum LocationScope { interface IState { headingHeight: number; locationScope: LocationScope; + filter: string; } interface ISelectLocationSnapshot { @@ -78,7 +78,7 @@ interface ISelectLocationSnapshot { } export default class SelectLocation extends React.Component<IProps, IState> { - public state = { headingHeight: 0, locationScope: LocationScope.exit }; + public state = { headingHeight: 0, locationScope: LocationScope.exit, filter: '' }; private scrollView = React.createRef<CustomScrollbarsRef>(); private spacePreAllocationViewRef = React.createRef<SpacePreAllocationView>(); @@ -173,9 +173,23 @@ export default class SelectLocation extends React.Component<IProps, IState> { messages.pgettext('select-location-view', 'Select location') } </HeaderTitle> - {this.renderHeaderSubtitle()} </StyledSettingsHeader> + {this.props.allowEntrySelection && ( + <StyledScopeBar + defaultSelectedIndex={this.state.locationScope} + onChange={this.onChangeLocationScope}> + <ScopeBarItem> + {messages.pgettext('select-location-view', 'Entry')} + </ScopeBarItem> + <ScopeBarItem> + {messages.pgettext('select-location-view', 'Exit')} + </ScopeBarItem> + </StyledScopeBar> + )} + + {this.renderHeaderSubtitle()} + {showFilters && ( <StyledFilterRow> {messages.pgettext('select-location-view', 'Filtered:')} @@ -223,18 +237,8 @@ export default class SelectLocation extends React.Component<IProps, IState> { )} </StyledFilterRow> )} - {this.props.allowEntrySelection && ( - <StyledScopeBar - defaultSelectedIndex={this.state.locationScope} - onChange={this.onChangeLocationScope}> - <ScopeBarItem> - {messages.pgettext('select-location-view', 'Entry')} - </ScopeBarItem> - <ScopeBarItem> - {messages.pgettext('select-location-view', 'Exit')} - </ScopeBarItem> - </StyledScopeBar> - )} + + <StyledSearchBar searchTerm={this.state.filter} onSearch={this.updateFilter} /> </StyledNavigationBarAttachment> <StyledContent>{this.renderLocationList()}</StyledContent> @@ -325,6 +329,7 @@ export default class SelectLocation extends React.Component<IProps, IState> { return ( <ExitLocations ref={this.exitLocationList} + filter={this.state.filter} source={this.props.relayLocations} locale={this.props.locale} defaultExpandedLocations={this.getExpandedLocationsFromSnapshot()} @@ -346,6 +351,7 @@ export default class SelectLocation extends React.Component<IProps, IState> { return ( <EntryLocations ref={this.entryLocationList} + filter={this.state.filter} source={this.props.relayLocations} locale={this.props.locale} defaultExpandedLocations={this.getExpandedLocationsFromSnapshot()} @@ -361,6 +367,7 @@ export default class SelectLocation extends React.Component<IProps, IState> { return ( <BridgeLocations ref={this.bridgeLocationList} + filter={this.state.filter} source={this.props.bridgeLocations} locale={this.props.locale} defaultExpandedLocations={this.getExpandedLocationsFromSnapshot()} @@ -439,33 +446,8 @@ export default class SelectLocation extends React.Component<IProps, IState> { this.spacePreAllocationViewRef.current?.allocate(expandedContentHeight); this.scrollView.current?.scrollIntoView(locationRect); }; -} - -interface ISpacePreAllocationView { - children?: React.ReactNode; -} - -class SpacePreAllocationView extends React.Component<ISpacePreAllocationView> { - private ref = React.createRef<HTMLDivElement>(); - - public allocate(height: number) { - if (this.ref.current) { - this.minHeight = this.ref.current.offsetHeight + height + 'px'; - } - } - public reset = () => { - this.minHeight = 'auto'; + private updateFilter = (filter: string) => { + this.setState({ filter }); }; - - public render() { - return <div ref={this.ref}>{this.props.children}</div>; - } - - private set minHeight(value: string) { - const element = this.ref.current; - if (element) { - element.style.minHeight = value; - } - } } diff --git a/gui/src/renderer/components/SelectLocationStyles.tsx b/gui/src/renderer/components/select-location/SelectLocationStyles.tsx index d4a0450c7c..00c8d02d97 100644 --- a/gui/src/renderer/components/SelectLocationStyles.tsx +++ b/gui/src/renderer/components/select-location/SelectLocationStyles.tsx @@ -1,9 +1,10 @@ import styled from 'styled-components'; -import { colors } from '../../config.json'; -import { tinyText } from './common-styles'; -import { ScopeBar } from './ScopeBar'; -import SettingsHeader from './SettingsHeader'; +import { colors } from '../../../config.json'; +import { tinyText } from '../common-styles'; +import { ScopeBar } from '../ScopeBar'; +import SearchBar from '../SearchBar'; +import SettingsHeader from '../SettingsHeader'; export const StyledScopeBar = styled(ScopeBar)({ marginTop: '8px', @@ -64,3 +65,7 @@ export const StyledClearFilterButton = styled.div({ cursor: 'default', backgroundColor: 'transparent', }); + +export const StyledSearchBar = styled(SearchBar)({ + marginBottom: '14px', +}); diff --git a/gui/src/renderer/components/select-location/SpacePreAllocationView.tsx b/gui/src/renderer/components/select-location/SpacePreAllocationView.tsx new file mode 100644 index 0000000000..4b493aeed1 --- /dev/null +++ b/gui/src/renderer/components/select-location/SpacePreAllocationView.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +interface ISpacePreAllocationView { + children?: React.ReactNode; +} + +export class SpacePreAllocationView extends React.Component<ISpacePreAllocationView> { + private ref = React.createRef<HTMLDivElement>(); + + public allocate(height: number) { + if (this.ref.current) { + this.minHeight = this.ref.current.offsetHeight + height + 'px'; + } + } + + public reset = () => { + this.minHeight = 'auto'; + }; + + public render() { + return <div ref={this.ref}>{this.props.children}</div>; + } + + private set minHeight(value: string) { + const element = this.ref.current; + if (element) { + element.style.minHeight = value; + } + } +} diff --git a/gui/src/renderer/components/select-location/SpecialLocation.tsx b/gui/src/renderer/components/select-location/SpecialLocation.tsx new file mode 100644 index 0000000000..06d3a8b408 --- /dev/null +++ b/gui/src/renderer/components/select-location/SpecialLocation.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import styled from 'styled-components'; + +import { colors } from '../../../config.json'; +import { messages } from '../../../shared/gettext'; +import * as Cell from '../cell'; +import InfoButton from '../InfoButton'; +import { + StyledLocationRowButton, + StyledLocationRowContainer, + StyledLocationRowIcon, + StyledLocationRowLabel, +} from './LocationRow'; + +const StyledLocationRowContainerWithMargin = styled(StyledLocationRowContainer)({ + marginBottom: 1, +}); + +const StyledSpecialLocationIcon = styled(Cell.Icon)({ + flex: 0, + marginLeft: '2px', + marginRight: '8px', +}); + +const StyledSpecialLocationInfoButton = styled(InfoButton)({ + margin: 0, + padding: '0 25px', +}); + +export enum SpecialLocationIcon { + geoLocation = 'icon-nearest', +} + +interface ISpecialLocationProps<T> { + icon: SpecialLocationIcon; + value: T; + isSelected?: boolean; + onSelect?: (value: T) => void; + info?: string; + forwardedRef?: React.Ref<HTMLButtonElement>; + children?: React.ReactNode; +} + +export class SpecialLocation<T> extends React.Component<ISpecialLocationProps<T>> { + public render() { + return ( + <StyledLocationRowContainerWithMargin> + <StyledLocationRowButton onClick={this.onSelect} selected={this.props.isSelected ?? false}> + <StyledSpecialLocationIcon + source={this.props.isSelected ? 'icon-tick' : this.props.icon} + tintColor={colors.white} + height={22} + width={22} + /> + <StyledLocationRowLabel>{this.props.children}</StyledLocationRowLabel> + </StyledLocationRowButton> + <StyledLocationRowIcon + as={StyledSpecialLocationInfoButton} + message={this.props.info} + selected={this.props.isSelected ?? false} + aria-label={messages.pgettext('accessibility', 'info')} + /> + </StyledLocationRowContainerWithMargin> + ); + } + + private onSelect = () => { + if (!this.props.isSelected && this.props.onSelect) { + this.props.onSelect(this.props.value); + } + }; +} diff --git a/gui/src/renderer/components/select-location/SpecialLocations.tsx b/gui/src/renderer/components/select-location/SpecialLocations.tsx new file mode 100644 index 0000000000..fb65f9c6ae --- /dev/null +++ b/gui/src/renderer/components/select-location/SpecialLocations.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import { SpecialLocation } from './SpecialLocation'; + +interface ISpecialLocationsProps<T> { + children: React.ReactNode; + selectedValue?: T; + selectedElementRef?: React.Ref<SpecialLocation<T>>; + onSelect?: (value: T) => void; +} + +export function SpecialLocations<T>(props: ISpecialLocationsProps<T>) { + return ( + <> + {React.Children.map(props.children, (child) => { + if (React.isValidElement(child) && child.type === SpecialLocation) { + const isSelected = props.selectedValue === child.props.value; + + return React.cloneElement(child, { + ...child.props, + forwardedRef: isSelected ? props.selectedElementRef : undefined, + onSelect: props.onSelect, + isSelected, + }); + } else { + return undefined; + } + })} + </> + ); +} diff --git a/gui/src/renderer/components/select-location/types.ts b/gui/src/renderer/components/select-location/types.ts new file mode 100644 index 0000000000..dd0d563401 --- /dev/null +++ b/gui/src/renderer/components/select-location/types.ts @@ -0,0 +1,30 @@ +import { RelayLocation } from '../../../shared/daemon-rpc-types'; +import { + IRelayLocationCityRedux, + IRelayLocationRedux, + IRelayLocationRelayRedux, +} from '../../redux/settings/reducers'; + +export interface Relay extends IRelayLocationRelayRedux { + label: string; + location: RelayLocation; + disabled: boolean; +} + +export interface City extends Omit<IRelayLocationCityRedux, 'relays'> { + label: string; + location: RelayLocation; + active: boolean; + disabled: boolean; + expanded: boolean; + relays: Array<Relay>; +} + +export interface Country extends Omit<IRelayLocationRedux, 'cities'> { + label: string; + location: RelayLocation; + active: boolean; + disabled: boolean; + expanded: boolean; + cities: Array<City>; +} diff --git a/gui/src/renderer/containers/SelectLocationPage.tsx b/gui/src/renderer/containers/SelectLocationPage.tsx index 1c3f3ff32a..d459696ed4 100644 --- a/gui/src/renderer/containers/SelectLocationPage.tsx +++ b/gui/src/renderer/containers/SelectLocationPage.tsx @@ -4,7 +4,7 @@ import BridgeSettingsBuilder from '../../shared/bridge-settings-builder'; import { LiftedConstraint, Ownership, RelayLocation } from '../../shared/daemon-rpc-types'; import log from '../../shared/logging'; import RelaySettingsBuilder from '../../shared/relay-settings-builder'; -import SelectLocation from '../components/SelectLocation'; +import SelectLocation from '../components/select-location/SelectLocation'; import { useAppContext } from '../context'; import { createWireguardRelayUpdater } from '../lib/constraint-updater'; import filterLocations from '../lib/filter-locations'; |
