diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2022-11-25 14:47:33 +0100 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2022-11-25 14:47:33 +0100 |
| commit | 044b3868dfa1bd8c4573624aecd4c17b053d256e (patch) | |
| tree | 2f1b8d921540a9736a96eac6ddba115624c04ac9 | |
| parent | 1fcdf3c0ab63dd78fc491f0537a0a09a367b804c (diff) | |
| parent | 29e5fadc291946de824ea36fd94a0db197f42af7 (diff) | |
| download | mullvadvpn-044b3868dfa1bd8c4573624aecd4c17b053d256e.tar.xz mullvadvpn-044b3868dfa1bd8c4573624aecd4c17b053d256e.zip | |
Merge branch 'add-location-list-filter'
38 files changed, 2076 insertions, 2040 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index d49ea21a51..533044c4dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Line wrap the file at 100 chars. Th ## [Unreleased] ### Added - Add quit button to tray context menu on Linux and Window. +- Add search bar to location list in desktop app. #### Windows - Remove all settings when the app is uninstalled silently. diff --git a/gui/assets/images/icon-search.svg b/gui/assets/images/icon-search.svg new file mode 100644 index 0000000000..8c54192c53 --- /dev/null +++ b/gui/assets/images/icon-search.svg @@ -0,0 +1,17 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24"> + <defs> + <path id="d6xp5m7c6a" d="M7.5 0C11.642 0 15 3.358 15 7.5c0 1.77-.613 3.397-1.64 4.68l4.169 4.294c.55.566.54 1.487-.02 2.057-.56.57-1.46.574-2.01.008l-4.352-4.484c-1.08.602-2.323.945-3.647.945C3.358 15 0 11.642 0 7.5 0 3.358 3.358 0 7.5 0zm0 2C4.462 2 2 4.462 2 7.5S4.462 13 7.5 13 13 10.538 13 7.5 10.538 2 7.5 2z"/> + </defs> + <g fill="none" fill-rule="evenodd"> + <g transform="translate(3 3)"> + <mask id="ybpe0o1wtb" fill="#fff"> + <use xlink:href="#d6xp5m7c6a"/> + </mask> + <use fill="#D8D8D8" xlink:href="#d6xp5m7c6a"/> + <g fill="#294D73" mask="url(#ybpe0o1wtb)"> + <path d="M0 0H24V24H0z" transform="translate(-3 -3)"/> + </g> + <path fill="#FBFCFD" fill-opacity=".2" d="M8.608 12.175s-3.092.026-4.914-1.796c-.585-.585-.947-1.986-1.086-4.204.525 1.415 1.28 2.638 2.266 3.668 1.522 1.523 3.734 2.332 3.734 2.332z" mask="url(#ybpe0o1wtb)" transform="rotate(7 5.608 9.175)"/> + </g> + </g> +</svg> diff --git a/gui/locales/messages.pot b/gui/locales/messages.pot index 06e1043b1e..62e195a631 100644 --- a/gui/locales/messages.pot +++ b/gui/locales/messages.pot @@ -172,6 +172,9 @@ msgstr "" msgid "Next" msgstr "" +msgid "No result for <b>%(searchTerm)s</b>." +msgstr "" + msgid "Off" msgstr "" @@ -190,6 +193,9 @@ msgstr "" msgid "Reconnect" msgstr "" +msgid "Search for..." +msgstr "" + msgid "SECURE CONNECTION" msgstr "" @@ -206,6 +212,9 @@ msgstr "" msgid "This setting increases latency. Use only if needed." msgstr "" +msgid "Try a different search." +msgstr "" + msgid "UDP" msgstr "" @@ -978,11 +987,6 @@ msgctxt "select-location-view" msgid "Providers: %(numberOfProviders)d" msgstr "" -#. Heading in select location view -msgctxt "select-location-view" -msgid "Select location" -msgstr "" - msgctxt "select-location-view" msgid "The app selects a random bridge server, but servers have a higher probability the closer they are to you." msgstr "" @@ -1060,10 +1064,6 @@ msgid "Excluded apps" msgstr "" msgctxt "split-tunneling-view" -msgid "Filter..." -msgstr "" - -msgctxt "split-tunneling-view" msgid "Find another app" msgstr "" @@ -1075,10 +1075,6 @@ msgctxt "split-tunneling-view" msgid "Launch" msgstr "" -msgctxt "split-tunneling-view" -msgid "No result for <b>%(searchTerm)s</b>." -msgstr "" - #. This error message is shown if the user tries to launch a Linux desktop #. entry file that doesn't contain the required 'Exec' value. msgctxt "split-tunneling-view" @@ -1092,10 +1088,6 @@ msgctxt "split-tunneling-view" msgid "Please try again or contact support." msgstr "" -msgctxt "split-tunneling-view" -msgid "Try a different search." -msgstr "" - #. Error message showed in a dialog when an application failes to launch. msgctxt "split-tunneling-view" msgid "Unable to launch selection. %(detailedErrorMessage)s" diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts index 94607766a0..320185abd8 100644 --- a/gui/src/main/index.ts +++ b/gui/src/main/index.ts @@ -7,7 +7,13 @@ import util from 'util'; import config from '../config.json'; import { hasExpired } from '../shared/account-expiry'; import { IWindowsApplication } from '../shared/application-types'; -import { DaemonEvent, DeviceEvent, ISettings, TunnelState } from '../shared/daemon-rpc-types'; +import { + DaemonEvent, + DeviceEvent, + IRelayListWithEndpointData, + ISettings, + TunnelState, +} from '../shared/daemon-rpc-types'; import { messages, relayLocations } from '../shared/gettext'; import { SYSTEM_PREFERRED_LOCALE_KEY } from '../shared/gui-settings-state'; import { ITranslations, MacOsScrollbarVisibility } from '../shared/ipc-schema'; @@ -40,7 +46,6 @@ import NotificationController, { } from './notification-controller'; import * as problemReport from './problem-report'; import ReconnectionBackoff from './reconnection-backoff'; -import RelayList from './relay-list'; import Settings, { SettingsDelegate } from './settings'; import TunnelStateHandler, { TunnelStateHandlerDelegate, @@ -75,7 +80,6 @@ class ApplicationMain private notificationController = new NotificationController(this); private version = new Version(this, this.daemonRpc, UPDATE_NOTIFICATION_DISABLED); private settings = new Settings(this, this.daemonRpc, this.version.currentVersion); - private relayList = new RelayList(); private userInterface?: UserInterface; private account: Account = new Account(this, this.daemonRpc); private tunnelState = new TunnelStateHandler(this); @@ -102,6 +106,8 @@ class ApplicationMain private navigationHistory?: IHistoryObject; + private relayList?: IRelayListWithEndpointData; + public run() { // Remove window animations to combat window flickering when opening window. Can be removed when // this issue has been resolved: https://github.com/electron/electron/issues/12130 @@ -514,11 +520,7 @@ class ApplicationMain // fetch relays try { - this.relayList.setRelays( - await this.daemonRpc.getRelayLocations(), - this.settings.relaySettings, - this.settings.bridgeState, - ); + this.setRelayList(await this.daemonRpc.getRelayLocations()); } catch (e) { const error = e as Error; log.error(`Failed to fetch relay locations: ${error.message}`); @@ -610,11 +612,7 @@ class ApplicationMain } else if ('settings' in daemonEvent) { this.setSettings(daemonEvent.settings); } else if ('relayList' in daemonEvent) { - this.relayList.setRelays( - daemonEvent.relayList, - this.settings.relaySettings, - this.settings.bridgeState, - ); + IpcMainEventChannel.relays.notify?.(daemonEvent.relayList); } else if ('appVersionInfo' in daemonEvent) { this.version.setLatestVersion(daemonEvent.appVersionInfo); } else if ('device' in daemonEvent) { @@ -652,10 +650,11 @@ class ApplicationMain if (windowsSplitTunneling) { void this.updateSplitTunnelingApplications(newSettings.splitTunnel.appsList); } + } - // since settings can have the relay constraints changed, the relay - // list should also be updated - this.relayList.updateSettings(newSettings.relaySettings, newSettings.bridgeState); + private setRelayList(relayList: IRelayListWithEndpointData) { + this.relayList = relayList; + IpcMainEventChannel.relays.notify?.(relayList); } private async updateSplitTunnelingApplications(appList: string[]): Promise<void> { @@ -677,10 +676,7 @@ class ApplicationMain settings: this.settings.all, isPerformingPostUpgrade: this.isPerformingPostUpgrade, deviceState: this.account.deviceState, - relayListPair: this.relayList.getProcessedRelays( - this.settings.relaySettings, - this.settings.bridgeState, - ), + relayList: this.relayList, currentVersion: this.version.currentVersion, upgradeVersion: this.version.upgradeVersion, guiSettings: this.settings.gui.state, diff --git a/gui/src/main/relay-list.ts b/gui/src/main/relay-list.ts deleted file mode 100644 index c7b4a1fff1..0000000000 --- a/gui/src/main/relay-list.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { - BridgeState, - IRelayList, - IRelayListWithEndpointData, - liftConstraint, - RelaySettings, -} from '../shared/daemon-rpc-types'; -import { IRelayListPair } from '../shared/ipc-schema'; -import { IpcMainEventChannel } from './ipc-event-channel'; - -export default class RelayList { - private relays: IRelayListWithEndpointData = { - relayList: { - countries: [], - }, - wireguardEndpointData: { - portRanges: [], - udp2tcpPorts: [], - }, - }; - - public setRelays( - newRelayList: IRelayListWithEndpointData, - relaySettings: RelaySettings, - bridgeState: BridgeState, - ) { - this.relays = newRelayList; - - const processedRelays = this.processRelays(newRelayList, relaySettings, bridgeState); - IpcMainEventChannel.relays.notify?.(processedRelays); - } - - public updateSettings(relaySettings: RelaySettings, bridgeState: BridgeState) { - this.setRelays(this.relays, relaySettings, bridgeState); - } - - public getProcessedRelays(relaySettings: RelaySettings, bridgeState: BridgeState) { - return this.processRelays(this.relays, relaySettings, bridgeState); - } - - private processRelays( - relayList: IRelayListWithEndpointData, - relaySettings: RelaySettings, - bridgeState: BridgeState, - ): IRelayListPair { - const filteredRelays = this.processRelaysForPresentation(relayList.relayList, relaySettings); - const filteredBridges = this.processBridgesForPresentation(relayList.relayList, bridgeState); - - return { - relays: filteredRelays, - bridges: filteredBridges, - wireguardEndpointData: relayList.wireguardEndpointData, - }; - } - - private processRelaysForPresentation( - relayList: IRelayList, - relaySettings: RelaySettings, - ): IRelayList { - const tunnelProtocol = - 'normal' in relaySettings ? liftConstraint(relaySettings.normal.tunnelProtocol) : undefined; - - const filteredCountries = relayList.countries - .map((country) => ({ - ...country, - cities: country.cities - .map((city) => ({ - ...city, - relays: city.relays.filter((relay) => { - if (relay.endpointType != 'bridge') { - switch (tunnelProtocol) { - case 'openvpn': - return relay.endpointType == 'openvpn'; - - case 'wireguard': - return relay.endpointType == 'wireguard'; - - case 'any': { - const useMultihop = - 'normal' in relaySettings && - relaySettings.normal.wireguardConstraints.useMultihop; - return !useMultihop || relay.endpointType == 'wireguard'; - } - default: - return false; - } - } else { - return false; - } - }), - })) - .filter((city) => city.relays.length > 0), - })) - .filter((country) => country.cities.length > 0); - - return { countries: filteredCountries }; - } - - private processBridgesForPresentation( - relayList: IRelayList, - bridgeState: BridgeState, - ): IRelayList { - if (bridgeState === 'on') { - const filteredCountries = relayList.countries - .map((country) => ({ - ...country, - cities: country.cities - .map((city) => ({ - ...city, - relays: city.relays.filter((relay) => relay.endpointType == 'bridge'), - })) - .filter((city) => city.relays.length > 0), - })) - .filter((country) => country.cities.length > 0); - - return { countries: filteredCountries }; - } else { - return { countries: [] }; - } - } -} diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx index 80e969a920..fc6a32aa87 100644 --- a/gui/src/renderer/app.tsx +++ b/gui/src/renderer/app.tsx @@ -15,6 +15,7 @@ import { IDeviceRemoval, IDnsOptions, ILocation, + IRelayListWithEndpointData, ISettings, liftConstraint, ObfuscationSettings, @@ -24,7 +25,6 @@ import { } from '../shared/daemon-rpc-types'; import { messages, relayLocations } from '../shared/gettext'; import { IGuiSettingsState, SYSTEM_PREFERRED_LOCALE_KEY } from '../shared/gui-settings-state'; -import { IRelayListPair } from '../shared/ipc-schema'; import { IChangelog, ICurrentAppVersionInfo, IHistoryObject } from '../shared/ipc-types'; import log, { ConsoleOutput } from '../shared/logging'; import { LogLevel } from '../shared/logging-types'; @@ -92,7 +92,7 @@ export default class AppRenderer { private location?: Partial<ILocation>; private lastDisconnectedLocation?: Partial<ILocation>; - private relayListPair!: IRelayListPair; + private relayList?: IRelayListWithEndpointData; private tunnelState!: TunnelState; private settings!: ISettings; private deviceState?: DeviceState; @@ -150,7 +150,7 @@ export default class AppRenderer { this.updateBlockedState(this.tunnelState, newSettings.blockWhenDisconnected); }); - IpcRendererEventChannel.relays.listen((relayListPair: IRelayListPair) => { + IpcRendererEventChannel.relays.listen((relayListPair: IRelayListWithEndpointData) => { this.setRelayListPair(relayListPair); }); @@ -217,7 +217,7 @@ export default class AppRenderer { this.setTunnelState(initialState.tunnelState); this.updateBlockedState(initialState.tunnelState, initialState.settings.blockWhenDisconnected); - this.setRelayListPair(initialState.relayListPair); + this.setRelayListPair(initialState.relayList); this.setCurrentVersion(initialState.currentVersion); this.setUpgradeVersion(initialState.upgradeVersion); this.setGuiSettings(initialState.guiSettings); @@ -827,20 +827,16 @@ export default class AppRenderer { } } - private setRelayListPair(relayListPair: IRelayListPair) { - this.relayListPair = relayListPair; + private setRelayListPair(relayListPair?: IRelayListWithEndpointData) { + this.relayList = relayListPair; this.propagateRelayListPairToRedux(); } private propagateRelayListPairToRedux() { - const relays = this.relayListPair.relays.countries; - const bridges = this.relayListPair.bridges.countries; - - this.reduxActions.settings.updateRelayLocations(relays); - this.reduxActions.settings.updateBridgeLocations(bridges); - this.reduxActions.settings.updateWireguardEndpointData( - this.relayListPair.wireguardEndpointData, - ); + if (this.relayList) { + this.reduxActions.settings.updateRelayLocations(this.relayList.relayList.countries); + this.reduxActions.settings.updateWireguardEndpointData(this.relayList.wireguardEndpointData); + } } private setCurrentVersion(versionInfo: ICurrentAppVersionInfo) { diff --git a/gui/src/renderer/components/AppRouter.tsx b/gui/src/renderer/components/AppRouter.tsx index cab40d9db9..bd2ae6e53f 100644 --- a/gui/src/renderer/components/AppRouter.tsx +++ b/gui/src/renderer/components/AppRouter.tsx @@ -1,8 +1,8 @@ import { createRef, useCallback, useEffect, useState } from 'react'; import { Route, Switch } from 'react-router'; +import SelectLocation from '../components/select-location/SelectLocationContainer'; import LoginPage from '../containers/LoginPage'; -import SelectLocationPage from '../containers/SelectLocationPage'; import { useAppContext } from '../context'; import { ITransitionSpecification, transitions, useHistory } from '../lib/history'; import { RoutePath } from '../lib/routes'; @@ -81,7 +81,7 @@ export default function AppRouter() { <Route exact path={RoutePath.support} component={Support} /> <Route exact path={RoutePath.problemReport} component={ProblemReport} /> <Route exact path={RoutePath.debug} component={Debug} /> - <Route exact path={RoutePath.selectLocation} component={SelectLocationPage} /> + <Route exact path={RoutePath.selectLocation} component={SelectLocation} /> <Route exact path={RoutePath.filter} component={Filter} /> </Switch> </TransitionView> diff --git a/gui/src/renderer/components/BridgeLocations.tsx b/gui/src/renderer/components/BridgeLocations.tsx deleted file mode 100644 index 355b462222..0000000000 --- a/gui/src/renderer/components/BridgeLocations.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import * as React from 'react'; - -import { LiftedConstraint, RelayLocation } from '../../shared/daemon-rpc-types'; -import { messages } from '../../shared/gettext'; -import { IRelayLocationRedux } from '../redux/settings/reducers'; -import LocationList, { - LocationSelection, - LocationSelectionType, - RelayLocations, - SpecialLocation, - SpecialLocationIcon, - SpecialLocations, -} from './LocationList'; - -export enum SpecialBridgeLocationType { - closestToExit = 0, -} - -interface IBridgeLocationsProps { - source: IRelayLocationRedux[]; - locale: string; - defaultExpandedLocations?: RelayLocation[]; - selectedValue?: LiftedConstraint<RelayLocation>; - selectedElementRef?: React.Ref<React.ReactInstance>; - onSelect?: (value: LocationSelection<SpecialBridgeLocationType>) => void; - onWillExpand?: (locationRect: DOMRect, expandedContentHeight: number) => void; - onTransitionEnd?: () => void; -} - -const BridgeLocations = React.forwardRef(function BridgeLocationsT( - props: IBridgeLocationsProps, - ref: React.Ref<LocationList<SpecialBridgeLocationType>>, -) { - const selectedValue: - | LocationSelection<SpecialBridgeLocationType> - | undefined = props.selectedValue - ? props.selectedValue === 'any' - ? { type: LocationSelectionType.special, value: SpecialBridgeLocationType.closestToExit } - : { type: LocationSelectionType.relay, value: props.selectedValue } - : undefined; - - return ( - <LocationList - ref={ref} - defaultExpandedLocations={props.defaultExpandedLocations} - selectedValue={selectedValue} - selectedElementRef={props.selectedElementRef} - onSelect={props.onSelect}> - <SpecialLocations> - <SpecialLocation - icon={SpecialLocationIcon.geoLocation} - value={SpecialBridgeLocationType.closestToExit} - 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} - locale={props.locale} - onWillExpand={props.onWillExpand} - onTransitionEnd={props.onTransitionEnd} - /> - </LocationList> - ); -}); - -export default BridgeLocations; diff --git a/gui/src/renderer/components/CustomScrollbars.tsx b/gui/src/renderer/components/CustomScrollbars.tsx index a760647cbe..ab95d66511 100644 --- a/gui/src/renderer/components/CustomScrollbars.tsx +++ b/gui/src/renderer/components/CustomScrollbars.tsx @@ -137,17 +137,12 @@ class CustomScrollbars extends React.Component<IProps, IState> { public scrollToTop(smooth = false) { const scrollable = this.scrollableRef.current; - if (scrollable) { - scrollable.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' }); - } + scrollable?.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' }); } - public scrollTo(x: number, y: number) { + public scrollTo(x: number, y: number, smooth = false) { const scrollable = this.scrollableRef.current; - if (scrollable) { - scrollable.scrollLeft = x; - scrollable.scrollTop = y; - } + scrollable?.scrollTo({ top: y, left: x, behavior: smooth ? 'smooth' : 'auto' }); } public scrollToElement(child: HTMLElement, scrollPosition: ScrollPosition) { diff --git a/gui/src/renderer/components/Filter.tsx b/gui/src/renderer/components/Filter.tsx index 1bb208cd7f..26ed781396 100644 --- a/gui/src/renderer/components/Filter.tsx +++ b/gui/src/renderer/components/Filter.tsx @@ -5,9 +5,13 @@ import { colors } from '../../config.json'; import { Ownership } from '../../shared/daemon-rpc-types'; import { messages } from '../../shared/gettext'; import { useAppContext } from '../context'; -import filterLocations from '../lib/filter-locations'; +import { + EndpointType, + filterLocations, + filterLocationsByEndPointType, +} from '../lib/filter-locations'; import { useHistory } from '../lib/history'; -import { useBoolean } from '../lib/utilityHooks'; +import { useBoolean, useNormalRelaySettings } from '../lib/utilityHooks'; import { IRelayLocationRedux } from '../redux/settings/reducers'; import { IReduxState, useSelector } from '../redux/store'; import Accordion from './Accordion'; @@ -111,20 +115,31 @@ export default function Filter() { // Returns only the options for each filter that are compatible with current filter selection. function useFilteredFilters(providers: string[], ownership: Ownership) { - const locations = useSelector((state) => - state.settings.relayLocations.concat( - state.settings.bridgeState === 'on' ? state.settings.bridgeLocations : [], - ), - ); + const relaySettings = useNormalRelaySettings(); + const bridgeState = useSelector((state) => state.settings.bridgeState); + const locations = useSelector((state) => state.settings.relayLocations); + + const endpointType = bridgeState === 'on' ? EndpointType.any : EndpointType.exit; const availableProviders = useMemo(() => { - const filteredRelays = filterLocations(locations, [], ownership); - return providersFromRelays(filteredRelays); + const relayListForEndpointType = filterLocationsByEndPointType( + locations, + endpointType, + relaySettings, + ); + const relaylistForFilters = filterLocations(relayListForEndpointType, ownership, []); + return providersFromRelays(relaylistForFilters); }, [locations, ownership]); const availableOwnershipOptions = useMemo(() => { - const filteredRelays = filterLocations(locations, providers, Ownership.any); - const filteredRelayOwnership = filteredRelays.flatMap((country) => + const relayListForEndpointType = filterLocationsByEndPointType( + locations, + endpointType, + relaySettings, + ); + const relaylistForFilters = filterLocations(relayListForEndpointType, Ownership.any, providers); + + const filteredRelayOwnership = relaylistForFilters.flatMap((country) => country.cities.flatMap((city) => city.relays.map((relay) => relay.owned)), ); @@ -151,11 +166,15 @@ function providersFromRelays(relays: IRelayLocationRedux[]) { } function providersSelector(state: IReduxState): Record<string, boolean> { - const providerConstraint = - 'normal' in state.settings.relaySettings ? state.settings.relaySettings.normal.providers : []; + const relaySettings = + 'normal' in state.settings.relaySettings ? state.settings.relaySettings.normal : undefined; + const providerConstraint = relaySettings?.providers ?? []; - const relays = state.settings.relayLocations.concat( - state.settings.bridgeState === 'on' ? state.settings.bridgeLocations : [], + const endpointType = state.settings.bridgeState === 'on' ? EndpointType.any : EndpointType.exit; + const relays = filterLocationsByEndPointType( + state.settings.relayLocations, + endpointType, + relaySettings, ); const providers = providersFromRelays(relays); 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/LocationRow.tsx b/gui/src/renderer/components/LocationRow.tsx deleted file mode 100644 index 9172f8bcd3..0000000000 --- a/gui/src/renderer/components/LocationRow.tsx +++ /dev/null @@ -1,207 +0,0 @@ -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'; - -interface IButtonColorProps { - selected: boolean; - disabled?: boolean; - location?: RelayLocation; -} - -const buttonColor = (props: IButtonColorProps) => { - let background = colors.blue; - if (props.selected) { - background = colors.green; - } else if (props.location) { - if ('hostname' in props.location) { - background = colors.blue20; - } else if ('city' in props.location) { - background = colors.blue40; - } - } - - let backgroundHover = colors.blue80; - if (props.selected || props.disabled) { - backgroundHover = background; - } else if (props.location) { - backgroundHover = colors.blue80; - } - - return { - backgroundColor: background, - ':not(:disabled):hover': { - backgroundColor: backgroundHover, - }, - }; -}; - -export const StyledLocationRowContainer = styled(Cell.Container)({ - display: 'flex', - padding: 0, - background: 'none', -}); - -export const StyledLocationRowButton = styled(Cell.Row)( - buttonColor, - (props: { location?: RelayLocation }) => { - const paddingLeft = - props.location && 'hostname' in props.location - ? 50 - : props.location && 'city' in props.location - ? 34 - : 18; - - return { - flex: 1, - border: 'none', - padding: `0 10px 0 ${paddingLeft}px`, - margin: 0, - }; - }, -); - -export const StyledLocationRowIcon = styled.button(buttonColor, { - position: 'relative', - alignSelf: 'stretch', - paddingLeft: '22px', - paddingRight: measurements.viewMargin, - - '&::before': { - content: '""', - position: 'absolute', - margin: 'auto', - top: 0, - left: 0, - bottom: 0, - height: '50%', - width: '1px', - backgroundColor: colors.darkBlue, - }, -}); - -export const StyledLocationRowLabel = styled(Cell.Label)(normalText, { - fontWeight: 400, -}); - -interface IProps { - name: string; - active: boolean; - disabled: boolean; - location: RelayLocation; - selected: boolean; - expanded?: boolean; - onSelect?: (location: RelayLocation) => void; - onExpand?: (location: RelayLocation, value: boolean) => void; - onWillExpand?: (locationRect: DOMRect, expandedContentHeight: number) => void; - onTransitionEnd?: () => void; - children?: React.ReactElement<IProps>[]; -} - -function LocationRow(props: IProps, ref: React.Ref<HTMLDivElement>) { - const hasChildren = props.children !== undefined; - const buttonRef = useRef<HTMLButtonElement>() as React.RefObject<HTMLButtonElement>; - - const toggleCollapse = useCallback(() => { - props.onExpand?.(props.location, !props.expanded); - }, [props.onExpand, props.expanded, props.location]); - - const handleClick = useCallback(() => props.onSelect?.(props.location), [ - props.onSelect, - props.location, - ]); - - const onWillExpand = useCallback( - (nextHeight: number) => { - const buttonRect = buttonRef.current?.getBoundingClientRect(); - if (buttonRect) { - props.onWillExpand?.(buttonRect, nextHeight); - } - }, - [props.onWillExpand], - ); - - return ( - <> - <StyledLocationRowContainer ref={ref} disabled={props.disabled}> - <StyledLocationRowButton - as="button" - ref={buttonRef} - onClick={handleClick} - selected={props.selected} - location={props.location} - disabled={props.disabled}> - <RelayStatusIndicator active={props.active} selected={props.selected} /> - <StyledLocationRowLabel>{props.name}</StyledLocationRowLabel> - </StyledLocationRowButton> - {hasChildren ? ( - <StyledLocationRowIcon - as={ChevronButton} - onClick={toggleCollapse} - up={props.expanded ?? false} - selected={props.selected} - disabled={props.disabled} - location={props.location} - aria-label={sprintf( - props.expanded - ? messages.pgettext('accessibility', 'Collapse %(location)s') - : messages.pgettext('accessibility', 'Expand %(location)s'), - { location: props.name }, - )} - /> - ) : null} - </StyledLocationRowContainer> - - {hasChildren && ( - <Accordion - expanded={props.expanded} - onWillExpand={onWillExpand} - onTransitionEnd={props.onTransitionEnd} - animationDuration={150}> - <Cell.Group noMarginBottom>{props.children}</Cell.Group> - </Accordion> - )} - </> - ); -} - -export default React.memo(React.forwardRef(LocationRow), compareProps); - -function compareProps(oldProps: IProps, nextProps: IProps): boolean { - return ( - React.Children.count(oldProps.children) === React.Children.count(nextProps.children) && - oldProps.name === nextProps.name && - oldProps.active === nextProps.active && - oldProps.disabled === nextProps.disabled && - oldProps.selected === nextProps.selected && - oldProps.expanded === nextProps.expanded && - oldProps.onSelect === nextProps.onSelect && - oldProps.onExpand === nextProps.onExpand && - oldProps.onWillExpand === nextProps.onWillExpand && - oldProps.onTransitionEnd === nextProps.onTransitionEnd && - compareRelayLocation(oldProps.location, nextProps.location) && - compareChildren(oldProps.children, nextProps.children) - ); -} - -function compareChildren( - oldChildren?: React.ReactElement<IProps>[], - nextChildren?: React.ReactElement<IProps>[], -) { - if (oldChildren === undefined || nextChildren === undefined) { - return oldChildren === nextChildren; - } - - return ( - oldChildren.length === nextChildren.length && - oldChildren.every((oldChild, i) => compareProps(oldChild.props, nextChildren[i].props)) - ); -} diff --git a/gui/src/renderer/components/Locations.tsx b/gui/src/renderer/components/Locations.tsx deleted file mode 100644 index 4f7fadfb5d..0000000000 --- a/gui/src/renderer/components/Locations.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import * as React from 'react'; - -import { RelayLocation } from '../../shared/daemon-rpc-types'; -import { IRelayLocationRedux } from '../redux/settings/reducers'; -import LocationList, { - DisabledReason, - LocationSelection, - LocationSelectionType, - RelayLocations, -} from './LocationList'; - -interface ILocationsProps { - source: IRelayLocationRedux[]; - locale: string; - defaultExpandedLocations?: RelayLocation[]; - selectedValue?: RelayLocation; - disabledLocation?: { location: RelayLocation; reason: DisabledReason }; - selectedElementRef?: React.Ref<React.ReactInstance>; - onSelect?: (value: LocationSelection<never>) => void; - onWillExpand?: (locationRect: DOMRect, expandedContentHeight: number) => void; - onTransitionEnd?: () => void; -} - -function Locations(props: ILocationsProps, ref: React.Ref<LocationList<never>>) { - const selectedValue: LocationSelection<never> | undefined = props.selectedValue - ? { type: LocationSelectionType.relay, value: props.selectedValue } - : undefined; - - return ( - <LocationList - ref={ref} - defaultExpandedLocations={props.defaultExpandedLocations} - selectedValue={selectedValue} - selectedElementRef={props.selectedElementRef} - onSelect={props.onSelect}> - <RelayLocations - source={props.source} - locale={props.locale} - disabledLocation={props.disabledLocation} - onWillExpand={props.onWillExpand} - onTransitionEnd={props.onTransitionEnd} - /> - </LocationList> - ); -} - -export const ExitLocations = React.forwardRef(Locations); -export const EntryLocations = React.forwardRef(Locations); diff --git a/gui/src/renderer/components/SearchBar.tsx b/gui/src/renderer/components/SearchBar.tsx new file mode 100644 index 0000000000..c70e5c3de1 --- /dev/null +++ b/gui/src/renderer/components/SearchBar.tsx @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useRef } from 'react'; +import styled from 'styled-components'; + +import { colors } from '../../config.json'; +import { messages } from '../../shared/gettext'; +import { normalText } from './common-styles'; +import ImageView from './ImageView'; + +export const StyledSearchContainer = styled.div({ + position: 'relative', + display: 'flex', +}); + +export const StyledSearchInput = styled.input.attrs({ type: 'text' })({ + ...normalText, + flex: 1, + border: 'none', + borderRadius: '4px', + padding: '9px 38px', + margin: 0, + color: colors.white60, + backgroundColor: colors.white10, + '::placeholder': { + color: colors.white60, + }, + ':focus': { + color: colors.blue, + backgroundColor: colors.white, + '::placeholder': { + color: colors.blue40, + }, + }, +}); + +export const StyledClearButton = styled.button({ + position: 'absolute', + top: '50%', + transform: 'translateY(-50%)', + right: '9px', + border: 'none', + background: 'none', + padding: 0, +}); + +export const StyledSearchIcon = styled(ImageView)({ + position: 'absolute', + top: '50%', + transform: 'translateY(-50%)', + left: '9px', + [`${StyledSearchInput}:focus ~ &`]: { + backgroundColor: colors.blue, + }, +}); + +export const StyledClearIcon = styled(ImageView)({ + ':hover': { + backgroundColor: colors.white60, + }, + [`${StyledSearchInput}:focus ~ ${StyledClearButton} &`]: { + backgroundColor: colors.blue40, + ':hover': { + backgroundColor: colors.blue, + }, + }, +}); + +interface ISearchBarProps { + searchTerm: string; + onSearch: (searchTerm: string) => void; + className?: string; + disableAutoFocus?: boolean; +} + +export default function SearchBar(props: ISearchBarProps) { + const inputRef = useRef() as React.RefObject<HTMLInputElement>; + + const onInput = useCallback( + (event: React.FormEvent) => { + const element = event.target as HTMLInputElement; + props.onSearch(element.value); + }, + [props.onSearch], + ); + + const onClear = useCallback(() => { + props.onSearch(''); + inputRef.current?.blur(); + }, [props.onSearch]); + + useEffect(() => { + if (!props.disableAutoFocus) { + inputRef.current?.focus(); + } + }, []); + + return ( + <StyledSearchContainer className={props.className}> + <StyledSearchInput + ref={inputRef} + value={props.searchTerm} + onInput={onInput} + placeholder={messages.gettext('Search for...')} + /> + <StyledSearchIcon source="icon-search" width={24} tintColor={colors.white60} /> + {props.searchTerm.length > 0 && ( + <StyledClearButton onClick={onClear}> + <StyledClearIcon source="icon-close" width={18} tintColor={colors.white40} /> + </StyledClearButton> + )} + </StyledSearchContainer> + ); +} diff --git a/gui/src/renderer/components/SelectLocation.tsx b/gui/src/renderer/components/SelectLocation.tsx deleted file mode 100644 index 4c228c9a53..0000000000 --- a/gui/src/renderer/components/SelectLocation.tsx +++ /dev/null @@ -1,471 +0,0 @@ -import React from 'react'; -import { sprintf } from 'sprintf-js'; - -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'; -import { - NavigationBar, - NavigationContainer, - NavigationItems, - NavigationScrollbars, - TitleBarItem, -} from './NavigationBar'; -import { ScopeBarItem } from './ScopeBar'; -import { - StyledClearFilterButton, - StyledContent, - StyledFilter, - StyledFilterIconButton, - StyledFilterRow, - StyledNavigationBarAttachment, - StyledScopeBar, - StyledSettingsHeader, -} from './SelectLocationStyles'; -import { HeaderSubTitle, HeaderTitle } from './SettingsHeader'; - -interface IProps { - locale: string; - selectedExitLocation?: RelayLocation; - selectedEntryLocation?: RelayLocation; - selectedBridgeLocation?: LiftedConstraint<RelayLocation>; - relayLocations: IRelayLocationRedux[]; - bridgeLocations: IRelayLocationRedux[]; - allowEntrySelection: boolean; - tunnelProtocol: LiftedConstraint<TunnelProtocol>; - providers: string[]; - ownership: Ownership; - onClose: () => void; - onViewFilter: () => void; - onSelectExitLocation: (location: RelayLocation) => void; - onSelectEntryLocation: (location: RelayLocation) => void; - onSelectBridgeLocation: (location: RelayLocation) => void; - onSelectClosestToExit: () => void; - onClearProviders: () => void; - onClearOwnership: () => void; -} - -enum LocationScope { - entry = 0, - exit, -} - -interface IState { - headingHeight: number; - locationScope: LocationScope; -} - -interface ISelectLocationSnapshot { - scrollPosition: [number, number]; - expandedLocations: RelayLocation[]; -} - -export default class SelectLocation extends React.Component<IProps, IState> { - public state = { headingHeight: 0, locationScope: LocationScope.exit }; - - private scrollView = React.createRef<CustomScrollbarsRef>(); - private spacePreAllocationViewRef = React.createRef<SpacePreAllocationView>(); - private selectedExitLocationRef = React.createRef<React.ReactInstance>(); - private selectedEntryLocationRef = React.createRef<React.ReactInstance>(); - private selectedBridgeLocationRef = React.createRef<React.ReactInstance>(); - - private exitLocationList = React.createRef<LocationList<never>>(); - private entryLocationList = React.createRef<LocationList<never>>(); - private bridgeLocationList = React.createRef<LocationList<SpecialBridgeLocationType>>(); - - private snapshotByScope: Partial<Record<LocationScope, ISelectLocationSnapshot>> = {}; - - private headerRef = React.createRef<HTMLHeadingElement>(); - - public componentDidMount() { - this.scrollToSelectedCell(); - this.setState((state) => ({ - headingHeight: this.headerRef.current?.offsetHeight ?? state.headingHeight, - })); - } - - public componentDidUpdate( - _prevProps: IProps, - prevState: IState, - snapshot?: ISelectLocationSnapshot, - ) { - if (this.state.locationScope !== prevState.locationScope) { - this.restoreScrollPosition(this.state.locationScope); - - if (snapshot) { - this.snapshotByScope[prevState.locationScope] = snapshot; - } - } - } - - public getSnapshotBeforeUpdate( - prevProps: IProps, - prevState: IState, - ): ISelectLocationSnapshot | undefined { - const scrollView = this.scrollView.current; - const locationList = this.getLocationListRef(prevProps, prevState); - - if (scrollView && locationList) { - return { - scrollPosition: scrollView.getScrollPosition(), - expandedLocations: locationList.getExpandedLocations(), - }; - } else { - return undefined; - } - } - - public render() { - const showOwnershipFilter = this.props.ownership !== Ownership.any; - const showProvidersFilter = this.props.providers.length > 0; - const showFilters = showOwnershipFilter || showProvidersFilter; - return ( - <BackAction icon="close" action={this.props.onClose}> - <Layout> - <SettingsContainer> - <NavigationContainer> - <NavigationBar> - <NavigationItems> - <TitleBarItem> - { - // TRANSLATORS: Title label in navigation bar - messages.pgettext('select-location-nav', 'Select location') - } - </TitleBarItem> - - <StyledFilterIconButton - onClick={this.props.onViewFilter} - aria-label={messages.gettext('Filter')}> - <ImageView - source="icon-filter-round" - tintColor={colors.white40} - tintHoverColor={colors.white60} - height={24} - width={24} - /> - </StyledFilterIconButton> - </NavigationItems> - </NavigationBar> - <NavigationScrollbars ref={this.scrollView}> - <SpacePreAllocationView ref={this.spacePreAllocationViewRef}> - <StyledNavigationBarAttachment top={-this.state.headingHeight}> - <StyledSettingsHeader ref={this.headerRef}> - <HeaderTitle> - { - // TRANSLATORS: Heading in select location view - messages.pgettext('select-location-view', 'Select location') - } - </HeaderTitle> - {this.renderHeaderSubtitle()} - </StyledSettingsHeader> - - {showFilters && ( - <StyledFilterRow> - {messages.pgettext('select-location-view', 'Filtered:')} - - {showOwnershipFilter && ( - <StyledFilter> - {this.ownershipFilterLabel()} - <StyledClearFilterButton - aria-label={messages.gettext('Clear')} - onClick={this.props.onClearOwnership}> - <ImageView - height={16} - width={16} - source="icon-close" - tintColor={colors.white60} - tintHoverColor={colors.white80} - /> - </StyledClearFilterButton> - </StyledFilter> - )} - - {showProvidersFilter && ( - <StyledFilter> - {sprintf( - messages.pgettext( - 'select-location-view', - 'Providers: %(numberOfProviders)d', - ), - { - numberOfProviders: this.props.providers.length, - }, - )} - <StyledClearFilterButton - aria-label={messages.gettext('Clear')} - onClick={this.props.onClearProviders}> - <ImageView - height={16} - width={16} - source="icon-close" - tintColor={colors.white60} - tintHoverColor={colors.white80} - /> - </StyledClearFilterButton> - </StyledFilter> - )} - </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> - )} - </StyledNavigationBarAttachment> - - <StyledContent>{this.renderLocationList()}</StyledContent> - </SpacePreAllocationView> - </NavigationScrollbars> - </NavigationContainer> - </SettingsContainer> - </Layout> - </BackAction> - ); - } - - public restoreScrollPosition(scope: LocationScope) { - const snapshot = this.snapshotByScope[scope]; - - if (snapshot) { - this.scrollToPosition(...snapshot.scrollPosition); - } else { - this.scrollToSelectedCell(); - } - } - - private ownershipFilterLabel(): string { - switch (this.props.ownership) { - case Ownership.mullvadOwned: - return messages.pgettext('filter-view', 'Owned'); - case Ownership.rented: - return messages.pgettext('filter-view', 'Rented'); - default: - throw new Error('Only owned and rented should make label visible'); - } - } - - private getLocationListRef(prevProps: IProps, prevState: IState) { - if (prevState.locationScope === LocationScope.exit) { - return this.exitLocationList.current; - } else if (prevProps.tunnelProtocol === 'wireguard') { - return this.entryLocationList.current; - } else { - return this.bridgeLocationList.current; - } - } - - private getSelectedLocationRef() { - if (this.state.locationScope === LocationScope.exit) { - return this.selectedExitLocationRef.current; - } else if (this.props.tunnelProtocol === 'wireguard') { - return this.selectedEntryLocationRef.current; - } else { - return this.selectedBridgeLocationRef.current; - } - } - - private renderHeaderSubtitle() { - if (this.props.allowEntrySelection) { - if (this.props.tunnelProtocol === 'openvpn') { - return ( - <HeaderSubTitle> - {messages.pgettext( - 'select-location-view', - 'While connected, your traffic will be routed through two secure locations, the entry point (a bridge server) and the exit point (a VPN server).', - )} - </HeaderSubTitle> - ); - } else { - return ( - <HeaderSubTitle> - {messages.pgettext( - 'select-location-view', - 'While connected, your traffic will be routed through two secure locations, the entry point and the exit point (needs to be two different VPN servers).', - )} - </HeaderSubTitle> - ); - } - } else { - return null; - } - } - - private renderLocationList() { - if (this.state.locationScope === LocationScope.exit) { - const disabledLocation = this.props.selectedEntryLocation - ? { - location: this.props.selectedEntryLocation, - reason: DisabledReason.entry, - } - : undefined; - return ( - <ExitLocations - ref={this.exitLocationList} - source={this.props.relayLocations} - locale={this.props.locale} - defaultExpandedLocations={this.getExpandedLocationsFromSnapshot()} - selectedValue={this.props.selectedExitLocation} - selectedElementRef={this.selectedExitLocationRef} - disabledLocation={disabledLocation} - onSelect={this.onSelectExitLocation} - onWillExpand={this.onWillExpand} - onTransitionEnd={this.resetHeight} - /> - ); - } else if (this.props.tunnelProtocol === 'any' || this.props.tunnelProtocol === 'wireguard') { - const disabledLocation = this.props.selectedExitLocation - ? { - location: this.props.selectedExitLocation, - reason: DisabledReason.exit, - } - : undefined; - return ( - <EntryLocations - ref={this.entryLocationList} - source={this.props.relayLocations} - locale={this.props.locale} - defaultExpandedLocations={this.getExpandedLocationsFromSnapshot()} - selectedValue={this.props.selectedEntryLocation} - selectedElementRef={this.selectedEntryLocationRef} - disabledLocation={disabledLocation} - onSelect={this.onSelectEntryLocation} - onWillExpand={this.onWillExpand} - onTransitionEnd={this.resetHeight} - /> - ); - } else { - return ( - <BridgeLocations - ref={this.bridgeLocationList} - source={this.props.bridgeLocations} - locale={this.props.locale} - defaultExpandedLocations={this.getExpandedLocationsFromSnapshot()} - selectedValue={this.props.selectedBridgeLocation} - selectedElementRef={this.selectedBridgeLocationRef} - onSelect={this.onSelectBridgeLocation} - onWillExpand={this.onWillExpand} - onTransitionEnd={this.resetHeight} - /> - ); - } - } - - private resetHeight = () => { - this.spacePreAllocationViewRef.current?.reset(); - }; - - private getExpandedLocationsFromSnapshot(): RelayLocation[] | undefined { - const snapshot = this.snapshotByScope[this.state.locationScope]; - if (snapshot) { - return snapshot.expandedLocations; - } else { - return undefined; - } - } - - private scrollToPosition(x: number, y: number) { - const scrollView = this.scrollView.current; - if (scrollView) { - scrollView.scrollTo(x, y); - } - } - - private scrollToSelectedCell() { - const ref = this.getSelectedLocationRef(); - const scrollView = this.scrollView.current; - - if (scrollView) { - if (ref) { - if (ref instanceof HTMLElement) { - scrollView.scrollToElement(ref, 'middle'); - } - } else { - scrollView.scrollToTop(); - } - } - } - - private onChangeLocationScope = (locationScope: LocationScope) => { - this.setState({ locationScope }); - }; - - private onSelectExitLocation = (location: LocationSelection<never>) => { - if (location.type === LocationSelectionType.relay) { - this.props.onSelectExitLocation(location.value); - } - }; - - private onSelectEntryLocation = (location: LocationSelection<never>) => { - this.props.onSelectEntryLocation(location.value); - }; - - private onSelectBridgeLocation = (location: LocationSelection<SpecialBridgeLocationType>) => { - if (location.type === LocationSelectionType.relay) { - this.props.onSelectBridgeLocation(location.value); - } else if ( - location.type === LocationSelectionType.special && - location.value === SpecialBridgeLocationType.closestToExit - ) { - this.props.onSelectClosestToExit(); - } - }; - - private onWillExpand = (locationRect: DOMRect, expandedContentHeight: number) => { - locationRect.height += expandedContentHeight; - 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'; - }; - - 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/SplitTunnelingSettings.tsx b/gui/src/renderer/components/SplitTunnelingSettings.tsx index f68be19589..bf3b7f58b8 100644 --- a/gui/src/renderer/components/SplitTunnelingSettings.tsx +++ b/gui/src/renderer/components/SplitTunnelingSettings.tsx @@ -31,8 +31,6 @@ import { StyledCellButton, StyledCellLabel, StyledCellWarningIcon, - StyledClearButton, - StyledClearIcon, StyledContent, StyledHeaderTitle, StyledHeaderTitleContainer, @@ -43,9 +41,7 @@ import { StyledNoResult, StyledNoResultText, StyledPageCover, - StyledSearchContainer, - StyledSearchIcon, - StyledSearchInput, + StyledSearchBar, StyledSpinnerRow, } from './SplitTunnelingSettingsStyles'; import Switch from './Switch'; @@ -176,7 +172,7 @@ function LinuxSplitTunnelingSettings(props: IPlatformSplitTunnelingSettingsProps </HeaderSubTitle> </SettingsHeader> - <SearchBar searchTerm={searchTerm} onSearch={setSearchTerm} /> + <StyledSearchBar searchTerm={searchTerm} onSearch={setSearchTerm} /> <ApplicationList applications={filteredApplications} rowRenderer={rowRenderer} /> <StyledBrowseButton onClick={launchWithFilePicker}> @@ -435,7 +431,9 @@ export function WindowsSplitTunnelingSettings(props: IPlatformSplitTunnelingSett </HeaderSubTitle> </SettingsHeader> - {splitTunnelingEnabled && <SearchBar searchTerm={searchTerm} onSearch={setSearchTerm} />} + {splitTunnelingEnabled && ( + <StyledSearchBar searchTerm={searchTerm} onSearch={setSearchTerm} /> + )} <Accordion expanded={showSplitSection}> <Cell.Section sectionTitle={excludedTitle}> @@ -459,15 +457,10 @@ export function WindowsSplitTunnelingSettings(props: IPlatformSplitTunnelingSett <StyledNoResult> <StyledNoResultText> {formatHtml( - sprintf( - messages.pgettext('split-tunneling-view', 'No result for <b>%(searchTerm)s</b>.'), - { searchTerm }, - ), + sprintf(messages.gettext('No result for <b>%(searchTerm)s</b>.'), { searchTerm }), )} </StyledNoResultText> - <StyledNoResultText> - {messages.pgettext('split-tunneling-view', 'Try a different search.')} - </StyledNoResultText> + <StyledNoResultText>{messages.gettext('Try a different search.')}</StyledNoResultText> </StyledNoResult> )} @@ -566,45 +559,6 @@ function WindowsApplicationRow(props: IWindowsApplicationRowProps) { ); } -interface ISearchBarProps { - searchTerm: string; - onSearch: (searchTerm: string) => void; -} - -function SearchBar(props: ISearchBarProps) { - const inputRef = useRef() as React.RefObject<HTMLInputElement>; - - const onInput = useCallback( - (event: React.FormEvent) => { - const element = event.target as HTMLInputElement; - props.onSearch(element.value); - }, - [props.onSearch], - ); - - const onClear = useCallback(() => { - props.onSearch(''); - inputRef.current?.blur(); - }, [props.onSearch]); - - return ( - <StyledSearchContainer> - <StyledSearchInput - ref={inputRef} - value={props.searchTerm} - onInput={onInput} - placeholder={messages.pgettext('split-tunneling-view', 'Filter...')} - /> - <StyledSearchIcon source="icon-filter" width={24} tintColor={colors.white60} /> - {props.searchTerm.length > 0 && ( - <StyledClearButton onClick={onClear}> - <StyledClearIcon source="icon-close" width={18} tintColor={colors.white40} /> - </StyledClearButton> - )} - </StyledSearchContainer> - ); -} - function includesSearchTerm(application: IApplication, searchTerm: string) { return application.name.toLowerCase().includes(searchTerm.toLowerCase()); } diff --git a/gui/src/renderer/components/SplitTunnelingSettingsStyles.tsx b/gui/src/renderer/components/SplitTunnelingSettingsStyles.tsx index 6b135486db..028fac30a3 100644 --- a/gui/src/renderer/components/SplitTunnelingSettingsStyles.tsx +++ b/gui/src/renderer/components/SplitTunnelingSettingsStyles.tsx @@ -6,6 +6,7 @@ import * as Cell from './cell'; import { measurements, normalText } from './common-styles'; import ImageView from './ImageView'; import { NavigationScrollbars } from './NavigationBar'; +import SearchBar from './SearchBar'; import { HeaderTitle } from './SettingsHeader'; export const StyledPageCover = styled.div({}, (props: { show: boolean }) => ({ @@ -87,64 +88,6 @@ export const StyledCellContainer = styled(Cell.Container)({ marginBottom: measurements.rowVerticalMargin, }); -export const StyledSearchContainer = styled.div({ - position: 'relative', - marginBottom: measurements.buttonVerticalMargin, -}); - -export const StyledSearchInput = styled.input.attrs({ type: 'text' })({ - ...normalText, - width: `calc(100% - ${measurements.viewMargin} * 2)`, - border: 'none', - borderRadius: '4px', - padding: '9px 38px', - margin: `0 ${measurements.viewMargin}`, - color: colors.white60, - backgroundColor: colors.white10, - '::placeholder': { - color: colors.white60, - }, - ':focus': { - color: colors.blue, - backgroundColor: colors.white, - '::placeholder': { - color: colors.blue40, - }, - }, -}); - -export const StyledClearButton = styled.button({ - position: 'absolute', - top: '50%', - transform: 'translateY(-50%)', - right: '28px', - border: 'none', - background: 'none', - padding: 0, -}); - -export const StyledSearchIcon = styled(ImageView)({ - position: 'absolute', - top: '50%', - transform: 'translateY(-50%)', - left: '28px', - [`${StyledSearchInput}:focus ~ &`]: { - backgroundColor: colors.blue, - }, -}); - -export const StyledClearIcon = styled(ImageView)({ - ':hover': { - backgroundColor: colors.white60, - }, - [`${StyledSearchInput}:focus ~ ${StyledClearButton} &`]: { - backgroundColor: colors.blue40, - ':hover': { - backgroundColor: colors.blue, - }, - }, -}); - export const StyledNoResult = styled(Cell.CellFooter)({ display: 'flex', flexDirection: 'column', @@ -164,3 +107,9 @@ export const StyledHeaderTitleContainer = styled.div({ export const StyledHeaderTitle = styled(HeaderTitle)({ flex: 1, }); + +export const StyledSearchBar = styled(SearchBar)({ + marginLeft: measurements.viewMargin, + marginRight: measurements.viewMargin, + marginBottom: measurements.buttonVerticalMargin, +}); diff --git a/gui/src/renderer/components/select-location/CombinedLocationList.tsx b/gui/src/renderer/components/select-location/CombinedLocationList.tsx new file mode 100644 index 0000000000..d2a13af845 --- /dev/null +++ b/gui/src/renderer/components/select-location/CombinedLocationList.tsx @@ -0,0 +1,51 @@ +import React from 'react'; + +import { RelayLocation } from '../../../shared/daemon-rpc-types'; +import RelayLocationList from './RelayLocationList'; +import { + CountrySpecification, + LocationList, + LocationSelection, + LocationSelectionType, + SpecialLocation, +} from './select-location-types'; +import SpecialLocationList from './SpecialLocationList'; + +export interface CombinedLocationListProps<T> { + source: LocationList<T>; + selectedElementRef: React.Ref<HTMLDivElement>; + onSelect: (value: LocationSelection<T>) => void; + onExpand: (location: RelayLocation) => void; + onCollapse: (location: RelayLocation) => void; + onWillExpand: ( + locationRect: DOMRect, + expandedContentHeight: number, + invokedByUser: boolean, + ) => void; + onTransitionEnd: () => void; +} + +// Renders the special locations and the regular locations as separate lists +export default function CombinedLocationList<T>(props: CombinedLocationListProps<T>) { + const specialLocations = props.source.filter(isSpecialLocation); + const relayLocations = props.source.filter(isRelayLocation); + + return ( + <> + <SpecialLocationList {...props} source={specialLocations} /> + <RelayLocationList {...props} source={relayLocations} /> + </> + ); +} + +function isSpecialLocation<T>( + location: CountrySpecification | SpecialLocation<T>, +): location is SpecialLocation<T> { + return location.type === LocationSelectionType.special; +} + +function isRelayLocation<T>( + location: CountrySpecification | SpecialLocation<T>, +): location is CountrySpecification { + return location.type === LocationSelectionType.relay; +} diff --git a/gui/src/renderer/components/select-location/LocationRow.tsx b/gui/src/renderer/components/select-location/LocationRow.tsx new file mode 100644 index 0000000000..eafae9c509 --- /dev/null +++ b/gui/src/renderer/components/select-location/LocationRow.tsx @@ -0,0 +1,258 @@ +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 { + CitySpecification, + CountrySpecification, + getLocationChildren, + LocationSelection, + LocationSelectionType, + LocationSpecification, + RelaySpecification, +} from './select-location-types'; + +interface IButtonColorProps { + selected: boolean; + disabled?: boolean; + location?: RelayLocation; +} + +const buttonColor = (props: IButtonColorProps) => { + let background = colors.blue; + if (props.selected) { + background = colors.green; + } else if (props.location) { + if ('hostname' in props.location) { + background = colors.blue20; + } else if ('city' in props.location) { + background = colors.blue40; + } + } + + let backgroundHover = colors.blue80; + if (props.selected || props.disabled) { + backgroundHover = background; + } else if (props.location) { + backgroundHover = colors.blue80; + } + + return { + backgroundColor: background, + ':not(:disabled):hover': { + backgroundColor: backgroundHover, + }, + }; +}; + +export const StyledLocationRowContainer = styled(Cell.Container)({ + display: 'flex', + padding: 0, + background: 'none', +}); + +export const StyledLocationRowButton = styled(Cell.Row)( + buttonColor, + (props: { location?: RelayLocation }) => { + const paddingLeft = + props.location && 'hostname' in props.location + ? 50 + : props.location && 'city' in props.location + ? 34 + : 18; + + return { + flex: 1, + border: 'none', + padding: `0 10px 0 ${paddingLeft}px`, + margin: 0, + }; + }, +); + +export const StyledLocationRowIcon = styled.button(buttonColor, { + position: 'relative', + alignSelf: 'stretch', + paddingLeft: '22px', + paddingRight: measurements.viewMargin, + + '&::before': { + content: '""', + position: 'absolute', + margin: 'auto', + top: 0, + left: 0, + bottom: 0, + height: '50%', + width: '1px', + backgroundColor: colors.darkBlue, + }, +}); + +export const StyledLocationRowLabel = styled(Cell.Label)(normalText, { + fontWeight: 400, +}); + +interface IProps<C extends LocationSpecification> { + source: C; + selectedElementRef: React.Ref<HTMLDivElement>; + onSelect: (value: LocationSelection<never>) => void; + onExpand: (location: RelayLocation) => void; + onCollapse: (location: RelayLocation) => void; + onWillExpand: ( + locationRect: DOMRect, + expandedContentHeight: number, + invokedByUser: boolean, + ) => void; + onTransitionEnd: () => void; + children?: C extends RelaySpecification + ? never + : React.ReactElement< + IProps<C extends CountrySpecification ? CitySpecification : RelaySpecification> + >[]; +} + +// Renders the rows and its children for countries, cities and relays +function LocationRow<C extends LocationSpecification>(props: IProps<C>) { + const hasChildren = React.Children.count(props.children) > 0; + const buttonRef = useRef<HTMLButtonElement>() as React.RefObject<HTMLButtonElement>; + const userInvokedExpand = useRef(false); + + // Expand/collapse should only be available if the expanded property is provided in the source + const expanded = 'expanded' in props.source ? props.source.expanded : undefined; + const toggleCollapse = useCallback(() => { + if (expanded !== undefined) { + userInvokedExpand.current = true; + const callback = expanded ? props.onCollapse : props.onExpand; + callback(props.source.location); + } + }, [props.onExpand, props.onCollapse, props.source.location, expanded]); + + const handleClick = useCallback(() => { + if (!props.source.selected) { + props.onSelect({ type: LocationSelectionType.relay, value: props.source.location }); + } + }, [props.onSelect, props.source.location, props.source.selected]); + + const onWillExpand = useCallback( + (nextHeight: number) => { + const buttonRect = buttonRef.current?.getBoundingClientRect(); + if (expanded !== undefined && buttonRect) { + props.onWillExpand(buttonRect, nextHeight, userInvokedExpand.current); + userInvokedExpand.current = false; + } + }, + [props.onWillExpand, expanded], + ); + + // The selectedRef should only be used if the element is selected + const selectedRef = props.source.selected ? props.selectedElementRef : undefined; + return ( + <> + <StyledLocationRowContainer ref={selectedRef} disabled={props.source.disabled}> + <StyledLocationRowButton + as="button" + ref={buttonRef} + onClick={handleClick} + selected={props.source.selected} + location={props.source.location} + disabled={props.source.disabled}> + <RelayStatusIndicator active={props.source.active} selected={props.source.selected} /> + <StyledLocationRowLabel>{props.source.label}</StyledLocationRowLabel> + </StyledLocationRowButton> + {hasChildren ? ( + <StyledLocationRowIcon + as={ChevronButton} + onClick={toggleCollapse} + up={expanded ?? false} + selected={props.source.selected} + disabled={props.source.disabled} + location={props.source.location} + aria-label={sprintf( + expanded === true + ? messages.pgettext('accessibility', 'Collapse %(location)s') + : messages.pgettext('accessibility', 'Expand %(location)s'), + { location: props.source.label }, + )} + /> + ) : null} + </StyledLocationRowContainer> + + {hasChildren && ( + <Accordion + expanded={expanded} + onWillExpand={onWillExpand} + onTransitionEnd={props.onTransitionEnd} + animationDuration={150}> + <Cell.Group noMarginBottom>{props.children}</Cell.Group> + </Accordion> + )} + </> + ); +} + +// This is to avoid unnecessary rerenders since most of the subtree is hidden and would result in +// a lot more work than necessary +export default React.memo(LocationRow, compareProps); + +function compareProps<C extends LocationSpecification>( + oldProps: IProps<C>, + nextProps: IProps<C>, +): boolean { + return ( + oldProps.onSelect === nextProps.onSelect && + oldProps.onExpand === nextProps.onExpand && + oldProps.onWillExpand === nextProps.onWillExpand && + oldProps.onTransitionEnd === nextProps.onTransitionEnd && + compareLocation(oldProps.source, nextProps.source) + ); +} + +function compareLocation( + oldLocation: LocationSpecification, + nextLocation: LocationSpecification, +): boolean { + return ( + oldLocation.label === nextLocation.label && + oldLocation.active === nextLocation.active && + oldLocation.disabled === nextLocation.disabled && + oldLocation.selected === nextLocation.selected && + compareRelayLocation(oldLocation.location, nextLocation.location) && + compareExpanded(oldLocation, nextLocation) && + compareChildren(oldLocation, nextLocation) + ); +} + +function compareChildren( + oldLocation: LocationSpecification, + nextLocation: LocationSpecification, +): boolean { + const oldChildren = getLocationChildren(oldLocation); + const nextChildren = getLocationChildren(nextLocation); + + // Children shouldn't be checked if the row is collapsed + const nextExpanded = 'expanded' in nextLocation && nextLocation.expanded; + + return ( + !nextExpanded || + (oldChildren.length === nextChildren.length && + oldChildren.every((oldChild, i) => compareLocation(oldChild, nextChildren[i]))) + ); +} + +function compareExpanded( + oldLocation: LocationSpecification, + nextLocation: LocationSpecification, +): boolean { + const oldExpanded = 'expanded' in oldLocation && oldLocation.expanded; + const nextExpanded = 'expanded' in nextLocation && nextLocation.expanded; + return oldExpanded === nextExpanded; +} diff --git a/gui/src/renderer/components/select-location/RelayListContext.tsx b/gui/src/renderer/components/select-location/RelayListContext.tsx new file mode 100644 index 0000000000..b37cced8b5 --- /dev/null +++ b/gui/src/renderer/components/select-location/RelayListContext.tsx @@ -0,0 +1,311 @@ +import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; + +import { compareRelayLocation, RelayLocation } from '../../../shared/daemon-rpc-types'; +import { + EndpointType, + filterLocations, + filterLocationsByEndPointType, + getLocationsExpandedBySearch, + searchForLocations, +} from '../../lib/filter-locations'; +import { useNormalBridgeSettings, useNormalRelaySettings } from '../../lib/utilityHooks'; +import { IRelayLocationRedux } from '../../redux/settings/reducers'; +import { useSelector } from '../../redux/store'; +import { useScrollPositionContext } from './ScrollPositionContext'; +import { + defaultExpandedLocations, + formatRowName, + isCityDisabled, + isCountryDisabled, + isExpanded, + isRelayDisabled, + isSelected, +} from './select-location-helpers'; +import { + DisabledReason, + LocationList, + LocationSelectionType, + LocationType, +} from './select-location-types'; +import { useSelectLocationContext } from './SelectLocationContainer'; + +// Context containing the relay list and related data and callbacks +interface RelayListContext { + relayList: LocationList<never>; + expandLocation: (location: RelayLocation) => void; + collapseLocation: (location: RelayLocation) => void; + onBeforeExpand: ( + locationRect: DOMRect, + expandedContentHeight: number, + invokedByUser: boolean, + ) => void; + expandSearchResults: (searchTerm: string) => void; +} + +type ExpandedLocations = Partial<Record<LocationType, Array<RelayLocation>>>; + +const relayListContext = React.createContext<RelayListContext | undefined>(undefined); + +export function useRelayListContext() { + return useContext(relayListContext)!; +} + +interface RelayListContextProviderProps { + children: React.ReactNode; +} + +export function RelayListContextProvider(props: RelayListContextProviderProps) { + const { locationType, searchTerm } = useSelectLocationContext(); + const fullRelayList = useSelector((state) => state.settings.relayLocations); + const relaySettings = useNormalRelaySettings(); + + // Filters the relays to only keep the ones of the desired endpoint type, e.g. "wireguard", + // "openvpn" or "bridge" + const relayListForEndpointType = useMemo(() => { + const endpointType = + locationType === LocationType.entry ? EndpointType.entry : EndpointType.exit; + return filterLocationsByEndPointType(fullRelayList, endpointType, relaySettings); + }, [fullRelayList, locationType, relaySettings?.tunnelProtocol]); + + // Filters the relays to only keep the relays matching the currently selected filters, e.g. + // ownership and providers + const relayListForFilters = useMemo(() => { + return filterLocations( + relayListForEndpointType, + relaySettings?.ownership, + relaySettings?.providers, + ); + }, [relaySettings?.ownership, relaySettings?.providers, relayListForEndpointType]); + + // Filters the relays based on the provided search term + const relayListForSearch = useMemo(() => { + return searchForLocations(relayListForFilters, searchTerm); + }, [relayListForFilters, searchTerm]); + + const { + expandedLocations, + expandLocation, + collapseLocation, + onBeforeExpand, + expandSearchResults, + } = useExpandedLocations(relayListForFilters); + + // Prepares all relays and combines the data needed for rendering them + const relayList = useRelayList(relayListForSearch, expandedLocations); + + const contextValue = useMemo( + () => ({ + relayList, + expandLocation, + collapseLocation, + onBeforeExpand, + expandSearchResults, + }), + [relayList, expandLocation, collapseLocation, onBeforeExpand, expandSearchResults], + ); + + return ( + <relayListContext.Provider value={contextValue}>{props.children}</relayListContext.Provider> + ); +} + +// Return the final filtered and formatted relay list. This should be the only place in the app +// where processing of the relay list is performed. +function useRelayList( + relayList: Array<IRelayLocationRedux>, + expandedLocations?: Array<RelayLocation>, +): LocationList<never> { + const locale = useSelector((state) => state.userInterface.locale); + const selectedLocation = useSelectedLocation(); + const disabledLocation = useDisabledLocation(); + + return useMemo(() => { + return relayList + .map((country) => { + const countryLocation = { country: country.code }; + const countryDisabled = isCountryDisabled(country, country.code, disabledLocation); + + return { + ...country, + type: LocationSelectionType.relay as const, + label: formatRowName(country.name, countryLocation, countryDisabled), + location: countryLocation, + active: countryDisabled !== DisabledReason.inactive, + disabled: countryDisabled !== undefined, + expanded: isExpanded(countryLocation, expandedLocations), + selected: isSelected(countryLocation, selectedLocation), + cities: country.cities + .map((city) => { + const cityLocation: RelayLocation = { city: [country.code, city.code] }; + const cityDisabled = + countryDisabled ?? isCityDisabled(city, cityLocation.city, disabledLocation); + + return { + ...city, + label: formatRowName(city.name, cityLocation, cityDisabled), + location: cityLocation, + active: cityDisabled !== DisabledReason.inactive, + disabled: cityDisabled !== undefined, + expanded: isExpanded(cityLocation, expandedLocations), + selected: isSelected(cityLocation, selectedLocation), + relays: city.relays + .map((relay) => { + const relayLocation: RelayLocation = { + hostname: [country.code, city.code, relay.hostname], + }; + const relayDisabled = + countryDisabled ?? + cityDisabled ?? + isRelayDisabled(relay, relayLocation.hostname, disabledLocation); + + return { + ...relay, + label: formatRowName(relay.hostname, relayLocation, relayDisabled), + location: relayLocation, + disabled: relayDisabled !== undefined, + selected: isSelected(relayLocation, selectedLocation), + }; + }) + .sort((a, b) => a.hostname.localeCompare(b.hostname, locale, { numeric: true })), + }; + }) + .sort((a, b) => a.label.localeCompare(b.label, locale)), + }; + }) + .sort((a, b) => a.label.localeCompare(b.label, locale)); + }, [locale, expandedLocations, relayList, selectedLocation, disabledLocation]); +} + +// Return all RelayLocations that should be expanded +function useExpandedLocations(filteredLocations: Array<IRelayLocationRedux>) { + const { locationType, searchTerm } = useSelectLocationContext(); + const { spacePreAllocationViewRef, scrollViewRef } = useScrollPositionContext(); + const relaySettings = useNormalRelaySettings(); + const bridgeSettings = useNormalBridgeSettings(); + + // Keeps the state of which locations are expanded for which locationType. This is used to restore + // the state when switching back and forth between entry and exit. + const [expandedLocationsMap, setExpandedLocations] = useState<ExpandedLocations>(() => + defaultExpandedLocations(relaySettings, bridgeSettings), + ); + + const expandLocation = useCallback( + (location: RelayLocation) => { + setExpandedLocations((expandedLocations) => ({ + ...expandedLocations, + [locationType]: [...(expandedLocations[locationType] ?? []), location], + })); + }, + [locationType], + ); + + const collapseLocation = useCallback( + (location: RelayLocation) => { + setExpandedLocations((expandedLocations) => ({ + ...expandedLocations, + [locationType]: expandedLocations[locationType]!.filter( + (item) => !compareRelayLocation(location, item), + ), + })); + }, + [locationType], + ); + + // Called before expansion to make room for expansion and to scroll to fit the element + const onBeforeExpand = useCallback( + (locationRect: DOMRect, expandedContentHeight: number, invokedByUser: boolean) => { + if (invokedByUser) { + locationRect.height += expandedContentHeight; + spacePreAllocationViewRef.current?.allocate(expandedContentHeight); + scrollViewRef.current?.scrollIntoView(locationRect); + } + }, + [], + ); + + // Expand search results when searching + const expandSearchResults = useCallback( + (searchTerm: string) => { + if (searchTerm === '') { + setExpandedLocations(defaultExpandedLocations(relaySettings, bridgeSettings)); + } else { + setExpandedLocations((expandedLocations) => ({ + ...expandedLocations, + [locationType]: getLocationsExpandedBySearch(filteredLocations, searchTerm), + })); + } + }, + [relaySettings, bridgeSettings, locationType, filteredLocations], + ); + + // Expand locations when filters are changed + useEffect(() => { + if (searchTerm !== '') { + setExpandedLocations((expandedLocations) => ({ + ...expandedLocations, + [locationType]: getLocationsExpandedBySearch(filteredLocations, searchTerm), + })); + } + }, [filteredLocations]); + + return { + expandedLocations: expandedLocationsMap[locationType], + expandLocation, + collapseLocation, + onBeforeExpand, + expandSearchResults, + }; +} + +// Returns the location (if any) that should be disabled. This is currently used for disabling the +// entry location when selecting exit location etc. +function useDisabledLocation() { + const { locationType } = useSelectLocationContext(); + const relaySettings = useNormalRelaySettings(); + + return useMemo(() => { + if (relaySettings?.tunnelProtocol !== 'openvpn' && relaySettings?.wireguard.useMultihop) { + if (locationType === LocationType.exit && relaySettings?.wireguard.entryLocation !== 'any') { + return { + location: relaySettings?.wireguard.entryLocation, + reason: DisabledReason.entry, + }; + } else if (locationType === LocationType.entry && relaySettings?.location !== 'any') { + return { location: relaySettings?.location, reason: DisabledReason.exit }; + } + } + + return undefined; + }, [ + locationType, + relaySettings?.tunnelProtocol, + relaySettings?.wireguard.useMultihop, + relaySettings?.wireguard.entryLocation, + relaySettings?.location, + ]); +} + +// Returns the selected location for the current tunnel protocol and location type +function useSelectedLocation() { + const { locationType } = useSelectLocationContext(); + const relaySettings = useNormalRelaySettings(); + const bridgeSettings = useNormalBridgeSettings(); + + return useMemo(() => { + if (locationType === LocationType.exit) { + return relaySettings?.location === 'any' ? undefined : relaySettings?.location; + } else if (relaySettings?.tunnelProtocol !== 'openvpn') { + return relaySettings?.wireguard.entryLocation === 'any' + ? undefined + : relaySettings?.wireguard.entryLocation; + } else { + return bridgeSettings?.location; + } + }, [ + locationType, + relaySettings?.location, + relaySettings?.tunnelProtocol, + relaySettings?.wireguard.entryLocation, + bridgeSettings?.location, + ]); +} diff --git a/gui/src/renderer/components/select-location/RelayLocationList.tsx b/gui/src/renderer/components/select-location/RelayLocationList.tsx new file mode 100644 index 0000000000..f22695d775 --- /dev/null +++ b/gui/src/renderer/components/select-location/RelayLocationList.tsx @@ -0,0 +1,58 @@ +import React from 'react'; + +import { RelayLocation, relayLocationComponents } from '../../../shared/daemon-rpc-types'; +import * as Cell from '../cell'; +import LocationRow from './LocationRow'; +import { + getLocationChildren, + LocationSelection, + LocationSpecification, + RelayList, +} from './select-location-types'; + +interface CommonProps { + selectedElementRef: React.Ref<HTMLDivElement>; + onSelect: (value: LocationSelection<never>) => void; + onExpand: (location: RelayLocation) => void; + onCollapse: (location: RelayLocation) => void; + onWillExpand: ( + locationRect: DOMRect, + expandedContentHeight: number, + invokedByUser: boolean, + ) => void; + onTransitionEnd: () => void; +} + +interface RelayLocationsProps extends CommonProps { + source: RelayList; +} + +export default function RelayLocationList({ source, ...props }: RelayLocationsProps) { + return ( + <Cell.Group noMarginBottom> + {source.map((country) => ( + <RelayLocation key={getLocationKey(country.location)} source={country} {...props} /> + ))} + </Cell.Group> + ); +} + +interface RelayLocationProps extends CommonProps { + source: LocationSpecification; +} + +function RelayLocation(props: RelayLocationProps) { + const children = getLocationChildren(props.source); + + return ( + <LocationRow {...props}> + {children.map((child) => ( + <RelayLocation key={getLocationKey(child.location)} {...props} source={child} /> + ))} + </LocationRow> + ); +} + +function getLocationKey(location: RelayLocation): string { + return relayLocationComponents(location).join('-'); +} diff --git a/gui/src/renderer/components/ScopeBar.tsx b/gui/src/renderer/components/select-location/ScopeBar.tsx index 10b177c2c3..94c80dea7c 100644 --- a/gui/src/renderer/components/ScopeBar.tsx +++ b/gui/src/renderer/components/select-location/ScopeBar.tsx @@ -1,8 +1,8 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback } from 'react'; import styled from 'styled-components'; -import { colors } from '../../config.json'; -import { smallText } from './common-styles'; +import { colors } from '../../../config.json'; +import { smallText } from '../common-styles'; const StyledScopeBar = styled.div({ display: 'flex', @@ -13,25 +13,18 @@ const StyledScopeBar = styled.div({ }); interface IScopeBarProps { - defaultSelectedIndex?: number; + selectedIndex: number; onChange?: (selectedIndex: number) => void; className?: string; children: React.ReactElement<IScopeBarItemProps>[]; } export function ScopeBar(props: IScopeBarProps) { - const [selectedIndex, setSelectedIndex] = useState(props.defaultSelectedIndex ?? 0); - - const onClick = useCallback((index: number) => setSelectedIndex(index), []); - useEffect(() => { - props.onChange?.(selectedIndex); - }, [selectedIndex]); - const children = React.Children.map(props.children, (child, index) => { if (React.isValidElement(child)) { return React.cloneElement(child, { - selected: index === selectedIndex, - onClick, + selected: index === props.selectedIndex, + onClick: props.onChange, index, }); } else { diff --git a/gui/src/renderer/components/select-location/ScrollPositionContext.tsx b/gui/src/renderer/components/select-location/ScrollPositionContext.tsx new file mode 100644 index 0000000000..cdb7aba044 --- /dev/null +++ b/gui/src/renderer/components/select-location/ScrollPositionContext.tsx @@ -0,0 +1,88 @@ +import React, { useCallback, useContext, useEffect, useMemo, useRef } from 'react'; + +import { useNormalRelaySettings } from '../../lib/utilityHooks'; +import { CustomScrollbarsRef } from '../CustomScrollbars'; +import { LocationType } from './select-location-types'; +import { useSelectLocationContext } from './SelectLocationContainer'; +import { SpacePreAllocationView } from './SpacePreAllocationView'; + +// Context containing the scroll position for each location type and methods to interact with it. +interface ScrollPositionContext { + scrollPositions: React.RefObject<Partial<Record<LocationType, ScrollPosition>>>; + // The selected location element is used to scroll to it when opening the view + selectedLocationRef: React.RefObject<HTMLDivElement>; + // The scroll view container is used to get the current scroll position and to restore an old one + scrollViewRef: React.RefObject<CustomScrollbarsRef>; + // The space pre allocation view is used to enable smooth scrolling when opening locations + spacePreAllocationViewRef: React.RefObject<SpacePreAllocationView>; + saveScrollPosition: () => void; + resetScrollPositions: () => void; +} + +type ScrollPosition = [number, number]; + +const scrollPositionContext = React.createContext<ScrollPositionContext | undefined>(undefined); + +export function useScrollPositionContext() { + return useContext(scrollPositionContext)!; +} + +interface ScrollPositionContextProps { + children: React.ReactNode; +} + +export function ScrollPositionContextProvider(props: ScrollPositionContextProps) { + const { locationType, searchTerm } = useSelectLocationContext(); + const relaySettings = useNormalRelaySettings(); + + const scrollPositions = useRef<Partial<Record<LocationType, ScrollPosition>>>({}); + const scrollViewRef = useRef<CustomScrollbarsRef>(null); + const spacePreAllocationViewRef = useRef() as React.RefObject<SpacePreAllocationView>; + const selectedLocationRef = useRef<HTMLDivElement>(null); + + const saveScrollPosition = useCallback(() => { + const scrollPosition = scrollViewRef.current?.getScrollPosition(); + if (scrollPositions.current && scrollPosition) { + scrollPositions.current[locationType] = scrollPosition; + } + }, [locationType]); + + const resetScrollPositions = useCallback(() => { + for (const locationTypeVariant of [LocationType.entry, LocationType.exit]) { + if ( + scrollPositions.current && + (scrollPositions.current[locationTypeVariant] || locationTypeVariant === locationType) + ) { + scrollPositions.current[locationTypeVariant] = [0, 0]; + } + } + }, [locationType]); + + const value = useMemo( + () => ({ + scrollPositions, + selectedLocationRef, + scrollViewRef, + spacePreAllocationViewRef, + saveScrollPosition, + resetScrollPositions, + }), + [saveScrollPosition, resetScrollPositions], + ); + + // Restore the scroll position when parameters change + useEffect(() => { + const scrollPosition = scrollPositions.current?.[locationType]; + if (scrollPosition) { + scrollViewRef.current?.scrollTo(...scrollPosition); + } else if (selectedLocationRef.current) { + scrollViewRef.current?.scrollToElement(selectedLocationRef.current, 'middle'); + } else { + scrollViewRef.current?.scrollToTop(); + } + }, [locationType, searchTerm, relaySettings?.ownership, relaySettings?.providers]); + + return ( + <scrollPositionContext.Provider value={value}>{props.children}</scrollPositionContext.Provider> + ); +} diff --git a/gui/src/renderer/components/select-location/SelectLocation.tsx b/gui/src/renderer/components/select-location/SelectLocation.tsx new file mode 100644 index 0000000000..c26dacdd33 --- /dev/null +++ b/gui/src/renderer/components/select-location/SelectLocation.tsx @@ -0,0 +1,351 @@ +import { useCallback, useState } from 'react'; +import { sprintf } from 'sprintf-js'; + +import { colors } from '../../../config.json'; +import { Ownership } from '../../../shared/daemon-rpc-types'; +import { messages } from '../../../shared/gettext'; +import { useAppContext } from '../../context'; +import { filterSpecialLocations } from '../../lib/filter-locations'; +import { useHistory } from '../../lib/history'; +import { formatHtml } from '../../lib/html-formatter'; +import { RoutePath } from '../../lib/routes'; +import { useNormalBridgeSettings, useNormalRelaySettings } from '../../lib/utilityHooks'; +import { useSelector } from '../../redux/store'; +import ImageView from '../ImageView'; +import { BackAction } from '../KeyboardNavigation'; +import { Layout, SettingsContainer } from '../Layout'; +import { + NavigationBar, + NavigationContainer, + NavigationItems, + NavigationScrollbars, + TitleBarItem, +} from '../NavigationBar'; +import CombinedLocationList, { CombinedLocationListProps } from './CombinedLocationList'; +import { useRelayListContext } from './RelayListContext'; +import { ScopeBarItem } from './ScopeBar'; +import { useScrollPositionContext } from './ScrollPositionContext'; +import { + useOnSelectBridgeLocation, + useOnSelectEntryLocation, + useOnSelectExitLocation, +} from './select-location-hooks'; +import { + LocationSelectionType, + LocationType, + SpecialBridgeLocationType, + SpecialLocation, + SpecialLocationIcon, +} from './select-location-types'; +import { useSelectLocationContext } from './SelectLocationContainer'; +import { + StyledClearFilterButton, + StyledContent, + StyledFilter, + StyledFilterIconButton, + StyledFilterRow, + StyledHeaderSubTitle, + StyledNavigationBarAttachment, + StyledNoResult, + StyledNoResultText, + StyledScopeBar, + StyledSearchBar, +} from './SelectLocationStyles'; +import { SpacePreAllocationView } from './SpacePreAllocationView'; + +export default function SelectLocation() { + const history = useHistory(); + const { updateRelaySettings } = useAppContext(); + const { + saveScrollPosition, + resetScrollPositions, + scrollViewRef, + spacePreAllocationViewRef, + } = useScrollPositionContext(); + const { locationType, setLocationType, setSearchTerm } = useSelectLocationContext(); + const { expandSearchResults } = useRelayListContext(); + + const relaySettings = useNormalRelaySettings(); + const ownership = relaySettings?.ownership ?? Ownership.any; + const providers = relaySettings?.providers ?? []; + + const [searchValue, setSearchValue] = useState(''); + + const onClose = useCallback(() => history.dismiss(), [history]); + const onViewFilter = useCallback(() => history.push(RoutePath.filter), [history]); + + const tunnelProtocol = relaySettings?.tunnelProtocol ?? 'any'; + const bridgeState = useSelector((state) => state.settings.bridgeState); + const allowEntrySelection = + (tunnelProtocol === 'openvpn' && bridgeState === 'on') || + (tunnelProtocol !== 'openvpn' && relaySettings?.wireguard.useMultihop); + + const onClearProviders = useCallback(async () => { + resetScrollPositions(); + await updateRelaySettings({ normal: { providers: [] } }); + }, [resetScrollPositions]); + + const onClearOwnership = useCallback(async () => { + resetScrollPositions(); + await updateRelaySettings({ normal: { ownership: Ownership.any } }); + }, [resetScrollPositions]); + + const changeLocationType = useCallback( + (locationType: LocationType) => { + saveScrollPosition(); + setLocationType(locationType); + }, + [saveScrollPosition], + ); + + const updateSearchTerm = useCallback( + (value: string) => { + setSearchValue(value); + if (value.length === 1) { + expandSearchResults(''); + setSearchTerm(''); + } else { + resetScrollPositions(); + expandSearchResults(value); + setSearchTerm(value); + } + }, + [resetScrollPositions, expandSearchResults], + ); + + const showOwnershipFilter = ownership !== Ownership.any; + const showProvidersFilter = providers.length > 0; + const showFilters = showOwnershipFilter || showProvidersFilter; + return ( + <BackAction icon="close" action={onClose}> + <Layout> + <SettingsContainer> + <NavigationContainer> + <NavigationBar alwaysDisplayBarTitle> + <NavigationItems> + <TitleBarItem> + { + // TRANSLATORS: Title label in navigation bar + messages.pgettext('select-location-nav', 'Select location') + } + </TitleBarItem> + + <StyledFilterIconButton + onClick={onViewFilter} + aria-label={messages.gettext('Filter')}> + <ImageView + source="icon-filter-round" + tintColor={colors.white40} + tintHoverColor={colors.white60} + height={24} + width={24} + /> + </StyledFilterIconButton> + </NavigationItems> + </NavigationBar> + + <StyledNavigationBarAttachment> + {allowEntrySelection && ( + <> + <StyledScopeBar selectedIndex={locationType} onChange={changeLocationType}> + <ScopeBarItem> + {messages.pgettext('select-location-view', 'Entry')} + </ScopeBarItem> + <ScopeBarItem>{messages.pgettext('select-location-view', 'Exit')}</ScopeBarItem> + </StyledScopeBar> + + {tunnelProtocol === 'openvpn' ? ( + <StyledHeaderSubTitle> + {messages.pgettext( + 'select-location-view', + 'While connected, your traffic will be routed through two secure locations, the entry point (a bridge server) and the exit point (a VPN server).', + )} + </StyledHeaderSubTitle> + ) : ( + <StyledHeaderSubTitle> + {messages.pgettext( + 'select-location-view', + 'While connected, your traffic will be routed through two secure locations, the entry point and the exit point (needs to be two different VPN servers).', + )} + </StyledHeaderSubTitle> + )} + </> + )} + + {showFilters && ( + <StyledFilterRow> + {messages.pgettext('select-location-view', 'Filtered:')} + + {showOwnershipFilter && ( + <StyledFilter> + {ownershipFilterLabel(ownership)} + <StyledClearFilterButton + aria-label={messages.gettext('Clear')} + onClick={onClearOwnership}> + <ImageView + height={16} + width={16} + source="icon-close" + tintColor={colors.white60} + tintHoverColor={colors.white80} + /> + </StyledClearFilterButton> + </StyledFilter> + )} + + {showProvidersFilter && ( + <StyledFilter> + {sprintf( + messages.pgettext( + 'select-location-view', + 'Providers: %(numberOfProviders)d', + ), + { numberOfProviders: providers.length }, + )} + <StyledClearFilterButton + aria-label={messages.gettext('Clear')} + onClick={onClearProviders}> + <ImageView + height={16} + width={16} + source="icon-close" + tintColor={colors.white60} + tintHoverColor={colors.white80} + /> + </StyledClearFilterButton> + </StyledFilter> + )} + </StyledFilterRow> + )} + + <StyledSearchBar searchTerm={searchValue} onSearch={updateSearchTerm} /> + </StyledNavigationBarAttachment> + + <NavigationScrollbars ref={scrollViewRef}> + <SpacePreAllocationView ref={spacePreAllocationViewRef}> + <StyledContent> + <SelectLocationContent /> + </StyledContent> + </SpacePreAllocationView> + </NavigationScrollbars> + </NavigationContainer> + </SettingsContainer> + </Layout> + </BackAction> + ); +} + +function ownershipFilterLabel(ownership: Ownership): string { + switch (ownership) { + case Ownership.mullvadOwned: + return messages.pgettext('filter-view', 'Owned'); + case Ownership.rented: + return messages.pgettext('filter-view', 'Rented'); + default: + throw new Error('Only owned and rented should make label visible'); + } +} + +function SelectLocationContent() { + const { locationType, searchTerm } = useSelectLocationContext(); + const { selectedLocationRef, spacePreAllocationViewRef } = useScrollPositionContext(); + const { relayList, expandLocation, collapseLocation, onBeforeExpand } = useRelayListContext(); + const onSelectExitLocation = useOnSelectExitLocation(); + const onSelectEntryLocation = useOnSelectEntryLocation(); + const onSelectBridgeLocation = useOnSelectBridgeLocation(); + + const relaySettings = useNormalRelaySettings(); + const bridgeSettings = useNormalBridgeSettings(); + + const resetHeight = useCallback(() => spacePreAllocationViewRef.current?.reset(), []); + + if (locationType === LocationType.exit) { + // Add "Custom" item if a custom relay is selected + const specialList: Array<SpecialLocation<undefined>> = + relaySettings === undefined + ? [ + { + type: LocationSelectionType.special, + label: messages.gettext('Custom'), + value: undefined, + selected: true, + }, + ] + : []; + + const relayListWithSpecial = [...filterSpecialLocations(searchTerm, specialList), ...relayList]; + return ( + <LocationList + key={locationType} + source={relayListWithSpecial} + selectedElementRef={selectedLocationRef} + onSelect={onSelectExitLocation} + onExpand={expandLocation} + onCollapse={collapseLocation} + onWillExpand={onBeforeExpand} + onTransitionEnd={resetHeight} + /> + ); + } else if (relaySettings?.tunnelProtocol !== 'openvpn') { + return ( + <LocationList + key={locationType} + source={relayList} + selectedElementRef={selectedLocationRef} + onSelect={onSelectEntryLocation} + onExpand={expandLocation} + onCollapse={collapseLocation} + onWillExpand={onBeforeExpand} + onTransitionEnd={resetHeight} + /> + ); + } else { + // Add the "Automatic" item + const specialList: Array<SpecialLocation<SpecialBridgeLocationType>> = [ + { + type: LocationSelectionType.special, + label: messages.gettext('Automatic'), + icon: SpecialLocationIcon.geoLocation, + 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.', + ), + value: SpecialBridgeLocationType.closestToExit, + selected: bridgeSettings?.location === 'any', + }, + ]; + + const relayListWithSpecial = [...filterSpecialLocations(searchTerm, specialList), ...relayList]; + return ( + <LocationList + key={locationType} + source={relayListWithSpecial} + selectedElementRef={selectedLocationRef} + onSelect={onSelectBridgeLocation} + onExpand={expandLocation} + onCollapse={collapseLocation} + onWillExpand={onBeforeExpand} + onTransitionEnd={resetHeight} + /> + ); + } +} + +function LocationList<T>(props: CombinedLocationListProps<T>) { + const { searchTerm } = useSelectLocationContext(); + + if (searchTerm !== '' && props.source.length === 0) { + return ( + <StyledNoResult> + <StyledNoResultText> + {formatHtml( + sprintf(messages.gettext('No result for <b>%(searchTerm)s</b>.'), { searchTerm }), + )} + </StyledNoResultText> + <StyledNoResultText>{messages.gettext('Try a different search.')}</StyledNoResultText> + </StyledNoResult> + ); + } else { + return <CombinedLocationList {...props} />; + } +} diff --git a/gui/src/renderer/components/select-location/SelectLocationContainer.tsx b/gui/src/renderer/components/select-location/SelectLocationContainer.tsx new file mode 100644 index 0000000000..5843d5e6f4 --- /dev/null +++ b/gui/src/renderer/components/select-location/SelectLocationContainer.tsx @@ -0,0 +1,40 @@ +import React, { useContext, useMemo, useState } from 'react'; + +import { RelayListContextProvider } from './RelayListContext'; +import { ScrollPositionContextProvider } from './ScrollPositionContext'; +import { LocationType } from './select-location-types'; +import SelectLocation from './SelectLocation'; + +// Context containing data required by different components in the sub tree +interface SelectLocationContext { + locationType: LocationType; + setLocationType: (locationType: LocationType) => void; + searchTerm: string; + setSearchTerm: (value: string) => void; +} + +const selectLocationContext = React.createContext<SelectLocationContext | undefined>(undefined); + +export function useSelectLocationContext() { + return useContext(selectLocationContext)!; +} + +export default function SelectLocationContainer() { + const [locationType, setLocationType] = useState(LocationType.exit); + const [searchTerm, setSearchTerm] = useState(''); + + const value = useMemo(() => ({ locationType, setLocationType, searchTerm, setSearchTerm }), [ + locationType, + searchTerm, + ]); + + return ( + <selectLocationContext.Provider value={value}> + <ScrollPositionContextProvider> + <RelayListContextProvider> + <SelectLocation /> + </RelayListContextProvider> + </ScrollPositionContextProvider> + </selectLocationContext.Provider> + ); +} diff --git a/gui/src/renderer/components/SelectLocationStyles.tsx b/gui/src/renderer/components/select-location/SelectLocationStyles.tsx index d4a0450c7c..4ed9623ee1 100644 --- a/gui/src/renderer/components/SelectLocationStyles.tsx +++ b/gui/src/renderer/components/select-location/SelectLocationStyles.tsx @@ -1,13 +1,11 @@ import styled from 'styled-components'; -import { colors } from '../../config.json'; -import { tinyText } from './common-styles'; +import { colors } from '../../../config.json'; +import * as Cell from '../cell'; +import { tinyText } from '../common-styles'; +import SearchBar from '../SearchBar'; +import { HeaderSubTitle } from '../SettingsHeader'; import { ScopeBar } from './ScopeBar'; -import SettingsHeader from './SettingsHeader'; - -export const StyledScopeBar = styled(ScopeBar)({ - marginTop: '8px', -}); export const StyledContent = styled.div({ display: 'flex', @@ -16,13 +14,13 @@ export const StyledContent = styled.div({ overflow: 'visible', }); -export const StyledNavigationBarAttachment = styled.div({}, (props: { top: number }) => ({ - position: 'sticky', - top: `${props.top}px`, - padding: '8px 18px 8px 16px', - backgroundColor: colors.darkBlue, - zIndex: 1, -})); +export const StyledScopeBar = styled(ScopeBar)({ + marginBottom: '14px', +}); + +export const StyledNavigationBarAttachment = styled.div({ + padding: '0 16px 14px', +}); export const StyledFilterIconButton = styled.button({ justifySelf: 'end', @@ -33,16 +31,10 @@ export const StyledFilterIconButton = styled.button({ backgroundColor: 'transparent', }); -export const StyledSettingsHeader = styled(SettingsHeader)({ - paddingLeft: '6px', - paddingBottom: '11px', -}); - export const StyledFilterRow = styled.div({ ...tinyText, color: colors.white, - marginLeft: '6px', - marginBottom: '8px', + margin: '0 6px 14px', }); export const StyledFilter = styled.div({ @@ -64,3 +56,23 @@ export const StyledClearFilterButton = styled.div({ cursor: 'default', backgroundColor: 'transparent', }); + +export const StyledHeaderSubTitle = styled(HeaderSubTitle)({ + display: 'block', + margin: '0 6px 14px', +}); + +export const StyledSearchBar = styled(SearchBar)({ + margin: '0 6px', +}); + +export const StyledNoResult = styled(Cell.CellFooter)({ + display: 'flex', + flexDirection: 'column', + paddingTop: 0, + marginTop: 0, +}); + +export const StyledNoResultText = styled(Cell.CellFooterText)({ + textAlign: 'center', +}); 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/SpecialLocationList.tsx b/gui/src/renderer/components/select-location/SpecialLocationList.tsx new file mode 100644 index 0000000000..cbed2cf358 --- /dev/null +++ b/gui/src/renderer/components/select-location/SpecialLocationList.tsx @@ -0,0 +1,89 @@ +import React, { useCallback } 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'; +import { LocationSelection, LocationSelectionType, SpecialLocation } from './select-location-types'; + +interface SpecialLocationsProps<T> { + source: Array<SpecialLocation<T>>; + selectedElementRef: React.Ref<HTMLDivElement>; + onSelect: (value: LocationSelection<T>) => void; +} + +export default function SpecialLocationList<T>({ source, ...props }: SpecialLocationsProps<T>) { + return ( + <> + {source.map((location) => ( + <SpecialLocationRow key={location.label} source={location} {...props} /> + ))} + </> + ); +} + +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', + backgroundColor: colors.blue, +}); + +interface SpecialLocationRowProps<T> { + source: SpecialLocation<T>; + selectedElementRef: React.Ref<HTMLDivElement>; + onSelect: (value: LocationSelection<T>) => void; +} + +function SpecialLocationRow<T>(props: SpecialLocationRowProps<T>) { + const onSelect = useCallback(() => { + if (!props.source.selected) { + props.onSelect({ + type: LocationSelectionType.special, + value: props.source.value, + }); + } + }, [props.source.selected, props.onSelect, props.source.value]); + + const icon = props.source.selected ? 'icon-tick' : props.source.icon ?? undefined; + const selectedRef = props.source.selected ? props.selectedElementRef : undefined; + return ( + <StyledLocationRowContainerWithMargin ref={selectedRef}> + <StyledLocationRowButton onClick={onSelect} selected={props.source.selected}> + {icon && ( + <StyledSpecialLocationIcon + source={icon} + tintColor={colors.white} + height={22} + width={22} + /> + )} + <StyledLocationRowLabel>{props.source.label}</StyledLocationRowLabel> + </StyledLocationRowButton> + {props.source.info && ( + <StyledLocationRowIcon + as={StyledSpecialLocationInfoButton} + message={props.source.info} + selected={props.source.selected} + aria-label={messages.pgettext('accessibility', 'info')} + /> + )} + </StyledLocationRowContainerWithMargin> + ); +} diff --git a/gui/src/renderer/components/select-location/select-location-helpers.ts b/gui/src/renderer/components/select-location/select-location-helpers.ts new file mode 100644 index 0000000000..46225645cc --- /dev/null +++ b/gui/src/renderer/components/select-location/select-location-helpers.ts @@ -0,0 +1,185 @@ +import { sprintf } from 'sprintf-js'; + +import { + compareRelayLocation, + compareRelayLocationLoose, + LiftedConstraint, + RelayLocation, +} from '../../../shared/daemon-rpc-types'; +import { messages, relayLocations } from '../../../shared/gettext'; +import { + IRelayLocationCityRedux, + IRelayLocationRedux, + IRelayLocationRelayRedux, + NormalBridgeSettingsRedux, + NormalRelaySettingsRedux, +} from '../../redux/settings/reducers'; +import { DisabledReason, LocationType } from './select-location-types'; + +export function isSelected( + relayLocation: RelayLocation, + selected?: LiftedConstraint<RelayLocation>, +) { + return selected !== 'any' && compareRelayLocationLoose(selected, relayLocation); +} + +export function isExpanded(relayLocation: RelayLocation, expandedLocations?: Array<RelayLocation>) { + return ( + expandedLocations?.some((location) => compareRelayLocation(location, relayLocation)) ?? false + ); +} + +// Calculates which locations should be expanded based on selected location +export function defaultExpandedLocations( + relaySettings?: NormalRelaySettingsRedux, + bridgeSettings?: NormalBridgeSettingsRedux, +) { + const expandedLocations: Partial<Record<LocationType, Array<RelayLocation>>> = {}; + + const exitLocation = relaySettings?.location; + if (exitLocation && exitLocation !== 'any') { + expandedLocations[LocationType.exit] = expandRelayLocation(exitLocation); + } + + if (relaySettings?.tunnelProtocol === 'openvpn') { + const bridgeLocation = bridgeSettings?.location; + if (bridgeLocation && bridgeLocation !== 'any') { + expandedLocations[LocationType.entry] = expandRelayLocation(bridgeLocation); + } + } else if (relaySettings?.wireguard.useMultihop) { + const entryLocation = relaySettings?.wireguard.entryLocation; + if (entryLocation && entryLocation !== 'any') { + expandedLocations[LocationType.entry] = expandRelayLocation(entryLocation); + } + } + + return expandedLocations; +} + +// Expands a relay location and its parents +function expandRelayLocation(location: RelayLocation): RelayLocation[] { + if ('city' in location) { + return [{ country: location.city[0] }]; + } else if ('hostname' in location) { + return [ + { country: location.hostname[0] }, + { city: [location.hostname[0], location.hostname[1]] }, + ]; + } else { + return []; + } +} + +// Formats the label that is discplayed for a country, city or relay +export function formatRowName( + name: string, + location: RelayLocation, + disabledReason?: DisabledReason, +): string { + const translatedName = 'hostname' in location ? name : relayLocations.gettext(name); + + // In some situations the exit/entry server should be marked on a location + let info: string | undefined; + if (disabledReason === DisabledReason.entry) { + info = messages.pgettext('select-location-view', 'Entry'); + } else if (disabledReason === 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; +} + +export function isRelayDisabled( + relay: IRelayLocationRelayRedux, + location: [string, string, string], + disabledLocation?: { location: RelayLocation; reason: DisabledReason }, +): DisabledReason | undefined { + if (!relay.active) { + return DisabledReason.inactive; + } else if ( + disabledLocation && + compareRelayLocation({ hostname: location }, disabledLocation.location) + ) { + return disabledLocation.reason; + } else { + return undefined; + } +} + +export function isCityDisabled( + city: IRelayLocationCityRedux, + location: [string, string], + disabledLocation?: { location: RelayLocation; reason: DisabledReason }, +): DisabledReason | undefined { + const relaysDisabled = city.relays.map((relay) => + 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 ( + disabledLocation && + compareRelayLocation({ city: location }, disabledLocation.location) && + city.relays.filter((relay) => relay.active).length <= 1 + ) { + return disabledLocation.reason; + } + + return undefined; +} + +export function isCountryDisabled( + country: IRelayLocationRedux, + location: string, + disabledLocation?: { location: RelayLocation; reason: DisabledReason }, +): DisabledReason | undefined { + const citiesDisabled = country.cities.map((city) => 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 ( + disabledLocation && + compareRelayLocation({ country: location }, disabledLocation.location) && + country.cities.flatMap((city) => city.relays).filter((relay) => relay.active).length <= 1 + ) { + return disabledLocation.reason; + } + + return undefined; +} diff --git a/gui/src/renderer/components/select-location/select-location-hooks.ts b/gui/src/renderer/components/select-location/select-location-hooks.ts new file mode 100644 index 0000000000..a1d54b435d --- /dev/null +++ b/gui/src/renderer/components/select-location/select-location-hooks.ts @@ -0,0 +1,93 @@ +import { useCallback } from 'react'; + +import BridgeSettingsBuilder from '../../../shared/bridge-settings-builder'; +import { RelaySettingsUpdate } from '../../../shared/daemon-rpc-types'; +import log from '../../../shared/logging'; +import RelaySettingsBuilder from '../../../shared/relay-settings-builder'; +import { useAppContext } from '../../context'; +import { createWireguardRelayUpdater } from '../../lib/constraint-updater'; +import { useHistory } from '../../lib/history'; +import { useSelector } from '../../redux/store'; +import { + LocationSelection, + LocationSelectionType, + LocationType, + SpecialBridgeLocationType, +} from './select-location-types'; +import { useSelectLocationContext } from './SelectLocationContainer'; + +export function useOnSelectExitLocation() { + const onSelectLocation = useOnSelectLocation(); + const history = useHistory(); + const { connectTunnel } = useAppContext(); + + return useCallback( + async (relayLocation: LocationSelection<undefined>) => { + if (relayLocation.value === undefined) { + throw new Error('relayLocation should never be undefiend'); + } + + history.dismiss(); + const relayUpdate = RelaySettingsBuilder.normal() + .location.fromRaw(relayLocation.value) + .build(); + await onSelectLocation(relayUpdate); + await connectTunnel(); + }, + [history], + ); +} + +export function useOnSelectEntryLocation() { + const onSelectLocation = useOnSelectLocation(); + const { setLocationType } = useSelectLocationContext(); + const baseRelaySettings = useSelector((state) => state.settings.relaySettings); + + return useCallback(async (entryLocation: LocationSelection<never>) => { + setLocationType(LocationType.exit); + const relayUpdate = createWireguardRelayUpdater(baseRelaySettings) + .tunnel.wireguard((wireguard) => wireguard.entryLocation.exact(entryLocation.value)) + .build(); + await onSelectLocation(relayUpdate); + }, []); +} + +function useOnSelectLocation() { + const { updateRelaySettings } = useAppContext(); + + return useCallback(async (relayUpdate: RelaySettingsUpdate) => { + try { + await updateRelaySettings(relayUpdate); + } catch (e) { + const error = e as Error; + log.error(`Failed to select the exit location: ${error.message}`); + } + }, []); +} + +export function useOnSelectBridgeLocation() { + const { updateBridgeSettings } = useAppContext(); + const { setLocationType } = useSelectLocationContext(); + + return useCallback(async (location: LocationSelection<SpecialBridgeLocationType>) => { + let bridgeUpdate; + if (location.type === LocationSelectionType.relay) { + bridgeUpdate = new BridgeSettingsBuilder().location.fromRaw(location.value).build(); + } else if ( + location.type === LocationSelectionType.special && + location.value === SpecialBridgeLocationType.closestToExit + ) { + bridgeUpdate = new BridgeSettingsBuilder().location.any().build(); + } + + if (bridgeUpdate) { + setLocationType(LocationType.exit); + try { + await updateBridgeSettings(bridgeUpdate); + } catch (e) { + const error = e as Error; + log.error(`Failed to select the bridge location: ${error.message}`); + } + } + }, []); +} diff --git a/gui/src/renderer/components/select-location/select-location-types.ts b/gui/src/renderer/components/select-location/select-location-types.ts new file mode 100644 index 0000000000..6d895deeec --- /dev/null +++ b/gui/src/renderer/components/select-location/select-location-types.ts @@ -0,0 +1,87 @@ +import { RelayLocation } from '../../../shared/daemon-rpc-types'; +import { + IRelayLocationCityRedux, + IRelayLocationRedux, + IRelayLocationRelayRedux, +} from '../../redux/settings/reducers'; + +export enum LocationType { + entry = 0, + exit, +} + +export enum LocationSelectionType { + relay = 'relay', + special = 'special', +} + +export type LocationSelection<T> = + | { type: LocationSelectionType.special; value: T } + | { type: LocationSelectionType.relay; value: RelayLocation }; + +export type LocationList<T> = Array<CountrySpecification | SpecialLocation<T>>; +export type RelayList = Array<CountrySpecification>; + +export enum SpecialBridgeLocationType { + closestToExit = 0, +} + +export enum SpecialLocationIcon { + geoLocation = 'icon-nearest', +} + +export interface SpecialLocation<T> { + type: LocationSelectionType.special; + label: string; + icon?: SpecialLocationIcon; + info?: string; + value: T; + disabled?: boolean; + selected: boolean; +} + +export type LocationSpecification = CountrySpecification | CitySpecification | RelaySpecification; + +export interface CountrySpecification extends Omit<IRelayLocationRedux, 'cities'> { + type: LocationSelectionType.relay; + label: string; + location: RelayLocation; + active: boolean; + disabled: boolean; + expanded: boolean; + selected: boolean; + cities: Array<CitySpecification>; +} + +export interface CitySpecification extends Omit<IRelayLocationCityRedux, 'relays'> { + label: string; + location: RelayLocation; + active: boolean; + disabled: boolean; + expanded: boolean; + selected: boolean; + relays: Array<RelaySpecification>; +} + +export interface RelaySpecification extends IRelayLocationRelayRedux { + label: string; + location: RelayLocation; + disabled: boolean; + selected: boolean; +} + +export enum DisabledReason { + entry, + exit, + inactive, +} + +export function getLocationChildren(location: LocationSpecification): Array<LocationSpecification> { + if ('cities' in location) { + return location.cities; + } else if ('relays' in location) { + return location.relays; + } else { + return []; + } +} diff --git a/gui/src/renderer/containers/SelectLocationPage.tsx b/gui/src/renderer/containers/SelectLocationPage.tsx deleted file mode 100644 index 1c3f3ff32a..0000000000 --- a/gui/src/renderer/containers/SelectLocationPage.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { useCallback, useMemo } from 'react'; - -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 { useAppContext } from '../context'; -import { createWireguardRelayUpdater } from '../lib/constraint-updater'; -import filterLocations from '../lib/filter-locations'; -import { useHistory } from '../lib/history'; -import { RoutePath } from '../lib/routes'; -import { useSelector } from '../redux/store'; - -export default function SelectLocationPage() { - const history = useHistory(); - - const { updateRelaySettings, connectTunnel, updateBridgeSettings } = useAppContext(); - - const locale = useSelector((state) => state.userInterface.locale); - const settings = useSelector((state) => state.settings); - const { relaySettings, bridgeSettings, bridgeState } = settings; - - const providers = useMemo( - () => ('normal' in relaySettings ? relaySettings.normal.providers : []), - [relaySettings], - ); - - const ownership = useMemo( - () => ('normal' in relaySettings ? relaySettings.normal.ownership : Ownership.any), - [relaySettings], - ); - - const tunnelProtocol = useMemo( - () => ('normal' in relaySettings ? relaySettings.normal.tunnelProtocol : 'any'), - [relaySettings], - ); - - const selectedExitLocation = useMemo<RelayLocation | undefined>(() => { - if ('normal' in relaySettings) { - const exitLocation = relaySettings.normal.location; - if (exitLocation !== 'any') { - return exitLocation; - } - } - return undefined; - }, [relaySettings]); - - const selectedBridgeLocation = useMemo<LiftedConstraint<RelayLocation> | undefined>(() => { - return tunnelProtocol === 'openvpn' && 'normal' in bridgeSettings - ? bridgeSettings.normal.location - : undefined; - }, [tunnelProtocol, bridgeSettings]); - - const multihopEnabled = useMemo(() => { - return ( - tunnelProtocol !== 'openvpn' && - 'normal' in relaySettings && - relaySettings.normal.wireguard.useMultihop - ); - }, [tunnelProtocol, relaySettings]); - - const selectedEntryLocation = useMemo<RelayLocation | undefined>(() => { - if (multihopEnabled && 'normal' in relaySettings) { - const entryLocation = relaySettings.normal.wireguard.entryLocation; - if (multihopEnabled && entryLocation !== 'any') { - return entryLocation; - } - } - return undefined; - }, [relaySettings, multihopEnabled]); - - const allowEntrySelection = useMemo(() => { - return ( - (tunnelProtocol === 'openvpn' && bridgeState === 'on') || - ((tunnelProtocol === 'any' || tunnelProtocol === 'wireguard') && multihopEnabled) - ); - }, [tunnelProtocol, bridgeState, multihopEnabled]); - - const relayLocations = filterLocations(settings.relayLocations, providers, ownership); - const bridgeLocations = filterLocations(settings.bridgeLocations, providers, ownership); - - const onClose = useCallback(() => history.dismiss(), [history]); - const onViewFilter = useCallback(() => history.push(RoutePath.filter), [history]); - const onSelectExitLocation = useCallback( - async (relayLocation: RelayLocation) => { - // dismiss the view first - history.dismiss(); - try { - const relayUpdate = RelaySettingsBuilder.normal().location.fromRaw(relayLocation).build(); - - await updateRelaySettings(relayUpdate); - await connectTunnel(); - } catch (e) { - const error = e as Error; - log.error(`Failed to select the exit location: ${error.message}`); - } - }, - [connectTunnel, updateRelaySettings, history], - ); - const onSelectEntryLocation = useCallback( - async (entryLocation: RelayLocation) => { - // dismiss the view first - history.dismiss(); - - const relayUpdate = createWireguardRelayUpdater(relaySettings) - .tunnel.wireguard((wireguard) => wireguard.entryLocation.exact(entryLocation)) - .build(); - - try { - await updateRelaySettings(relayUpdate); - } catch (e) { - const error = e as Error; - log.error('Failed to select the entry location', error.message); - } - }, - [history, relaySettings, updateRelaySettings], - ); - const onSelectBridgeLocation = useCallback( - async (bridgeLocation: RelayLocation) => { - // dismiss the view first - history.dismiss(); - - try { - await updateBridgeSettings( - new BridgeSettingsBuilder().location.fromRaw(bridgeLocation).build(), - ); - } catch (e) { - const error = e as Error; - log.error(`Failed to select the bridge location: ${error.message}`); - } - }, - [history, updateBridgeSettings], - ); - const onSelectClosestToExit = useCallback(async () => { - history.dismiss(); - - try { - await updateBridgeSettings(new BridgeSettingsBuilder().location.any().build()); - } catch (e) { - const error = e as Error; - log.error(`Failed to set the bridge location to closest to exit: ${error.message}`); - } - }, [updateBridgeSettings, history]); - - const onClearProviders = useCallback(async () => { - await updateRelaySettings({ normal: { providers: [] } }); - }, [updateRelaySettings]); - - const onClearOwnership = useCallback(async () => { - await updateRelaySettings({ normal: { ownership: Ownership.any } }); - }, [updateRelaySettings]); - - return ( - <SelectLocation - locale={locale} - selectedExitLocation={selectedExitLocation} - selectedEntryLocation={selectedEntryLocation} - selectedBridgeLocation={selectedBridgeLocation} - relayLocations={relayLocations} - bridgeLocations={bridgeLocations} - allowEntrySelection={allowEntrySelection} - tunnelProtocol={tunnelProtocol} - providers={providers} - ownership={ownership} - onClose={onClose} - onViewFilter={onViewFilter} - onSelectExitLocation={onSelectExitLocation} - onSelectEntryLocation={onSelectEntryLocation} - onSelectBridgeLocation={onSelectBridgeLocation} - onSelectClosestToExit={onSelectClosestToExit} - onClearProviders={onClearProviders} - onClearOwnership={onClearOwnership} - /> - ); -} diff --git a/gui/src/renderer/lib/filter-locations.ts b/gui/src/renderer/lib/filter-locations.ts index 9459c06530..93d20f0c88 100644 --- a/gui/src/renderer/lib/filter-locations.ts +++ b/gui/src/renderer/lib/filter-locations.ts @@ -1,59 +1,159 @@ -import { Ownership } from '../../shared/daemon-rpc-types'; -import { IRelayLocationRedux } from '../redux/settings/reducers'; +import { Ownership, RelayEndpointType, RelayLocation } from '../../shared/daemon-rpc-types'; +import { SpecialLocation } from '../components/select-location/select-location-types'; +import { + IRelayLocationCityRedux, + IRelayLocationRedux, + IRelayLocationRelayRedux, + NormalRelaySettingsRedux, +} from '../redux/settings/reducers'; -export default function filterLocations( +export enum EndpointType { + any, + entry, + exit, +} + +export function filterLocationsByEndPointType( locations: IRelayLocationRedux[], - providers: string[], - ownership: Ownership, + endpointType: EndpointType, + relaySettings?: NormalRelaySettingsRedux, ): IRelayLocationRedux[] { - const locationsFilteredByOwnership = filterLocationsByOwnership(locations, ownership); - const locationsFilteredByProvider = filterLocationsByProvider( - locationsFilteredByOwnership, - providers, - ); - - return locationsFilteredByProvider; + return filterLocationsImpl(locations, getTunnelProtocolFilter(endpointType, relaySettings)); } -function filterLocationsByOwnership( +export function filterLocations( locations: IRelayLocationRedux[], - ownership: Ownership, + ownership?: Ownership, + providers?: Array<string>, ): IRelayLocationRedux[] { - if (ownership === Ownership.any) { - return locations; + const filters = [getOwnershipFilter(ownership), getProviderFilter(providers)]; + + return filters.some((filter) => filter !== undefined) + ? filterLocationsImpl(locations, (relay) => filters.every((filter) => filter?.(relay) ?? true)) + : locations; +} + +function getTunnelProtocolFilter( + endpointType: EndpointType, + relaySettings?: NormalRelaySettingsRedux, +): (relay: IRelayLocationRelayRedux) => boolean { + const tunnelProtocol = relaySettings?.tunnelProtocol ?? 'any'; + const endpointTypes: Array<RelayEndpointType> = []; + if (endpointType !== EndpointType.exit && tunnelProtocol === 'openvpn') { + endpointTypes.push('bridge'); + } else if (tunnelProtocol === 'any') { + endpointTypes.push('wireguard'); + if (!relaySettings?.wireguard.useMultihop) { + endpointTypes.push('openvpn'); + } + } else { + endpointTypes.push(tunnelProtocol); + } + + return (relay) => endpointTypes.includes(relay.endpointType); +} + +function getOwnershipFilter( + ownership?: Ownership, +): ((relay: IRelayLocationRelayRedux) => boolean) | undefined { + if (ownership === undefined || ownership === Ownership.any) { + return undefined; } const expectOwned = ownership === Ownership.mullvadOwned; + return (relay) => relay.owned === expectOwned; +} + +function getProviderFilter( + providers?: string[], +): ((relay: IRelayLocationRelayRedux) => boolean) | undefined { + return providers === undefined || providers.length === 0 + ? undefined + : (relay) => providers.includes(relay.provider); +} + +function filterLocationsImpl( + locations: Array<IRelayLocationRedux>, + filter: (relay: IRelayLocationRelayRedux) => boolean, +): Array<IRelayLocationRedux> { return locations .map((country) => ({ ...country, cities: country.cities - .map((city) => ({ - ...city, - relays: city.relays.filter((relay) => relay.owned === expectOwned), - })) + .map((city) => ({ ...city, relays: city.relays.filter(filter) })) .filter((city) => city.relays.length > 0), })) .filter((country) => country.cities.length > 0); } -function filterLocationsByProvider( - locations: IRelayLocationRedux[], - providers: string[], -): IRelayLocationRedux[] { - if (providers.length === 0) { - return locations; +export function searchForLocations( + countries: Array<IRelayLocationRedux>, + searchTerm: string, +): Array<IRelayLocationRedux> { + if (searchTerm === '') { + return countries; } - return locations - .map((country) => ({ - ...country, - cities: country.cities - .map((city) => ({ - ...city, - relays: city.relays.filter((relay) => providers.includes(relay.provider)), - })) - .filter((city) => city.relays.length > 0), - })) - .filter((country) => country.cities.length > 0); + return countries.reduce((countries, country) => { + const matchingCities = searchCities(country.cities, searchTerm); + const expanded = matchingCities.length > 0; + const match = search(searchTerm, country.code) || search(searchTerm, country.name); + const resultingCities = match ? country.cities : matchingCities; + return expanded || match ? [...countries, { ...country, cities: resultingCities }] : countries; + }, [] as Array<IRelayLocationRedux>); +} + +function searchCities( + cities: Array<IRelayLocationCityRedux>, + searchTerm: string, +): Array<IRelayLocationCityRedux> { + return cities.reduce((cities, city) => { + const matchingRelays = city.relays.filter((relay) => search(searchTerm, relay.hostname)); + const expanded = matchingRelays.length > 0; + const match = search(searchTerm, city.code) || search(searchTerm, city.name); + const resultingRelays = match ? city.relays : matchingRelays; + return expanded || match ? [...cities, { ...city, relays: resultingRelays }] : cities; + }, [] as Array<IRelayLocationCityRedux>); +} + +export function getLocationsExpandedBySearch( + countries: Array<IRelayLocationRedux>, + searchTerm: string, +): Array<RelayLocation> { + return countries.reduce((locations, country) => { + const cityLocations = getCityLocationsExpandecBySearch( + country.cities, + country.code, + searchTerm, + ); + const cityMatches = country.cities.some( + (city) => search(searchTerm, city.code) || search(searchTerm, city.name), + ); + const location = { country: country.code }; + const expanded = cityMatches || cityLocations.length > 0; + return expanded ? [...locations, ...cityLocations, location] : locations; + }, [] as Array<RelayLocation>); +} + +function getCityLocationsExpandecBySearch( + cities: Array<IRelayLocationCityRedux>, + countryCode: string, + searchTerm: string, +): Array<RelayLocation> { + return cities.reduce((locations, city) => { + const expanded = city.relays.filter((relay) => search(searchTerm, relay.hostname)).length > 0; + const location: RelayLocation = { city: [countryCode, city.code] }; + return expanded ? [...locations, location] : locations; + }, [] as Array<RelayLocation>); +} + +function search(searchTerm: string, value: string): boolean { + return value.toLowerCase().includes(searchTerm.toLowerCase()); +} + +export function filterSpecialLocations<T>( + searchTerm: string, + locations: Array<SpecialLocation<T>>, +): Array<SpecialLocation<T>> { + return locations.filter((location) => search(searchTerm, location.label)); } diff --git a/gui/src/renderer/lib/utilityHooks.ts b/gui/src/renderer/lib/utilityHooks.ts index 59686f1d6d..378a6d5ae5 100644 --- a/gui/src/renderer/lib/utilityHooks.ts +++ b/gui/src/renderer/lib/utilityHooks.ts @@ -1,5 +1,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useSelector } from '../redux/store'; + export function useMounted() { const mountedRef = useRef(false); const isMounted = useCallback(() => mountedRef.current, []); @@ -53,3 +55,13 @@ export function useBoolean(initialValue = false) { return [value, setTrue, setFalse, toggle] as const; } + +export function useNormalRelaySettings() { + const relaySettings = useSelector((state) => state.settings.relaySettings); + return 'normal' in relaySettings ? relaySettings.normal : undefined; +} + +export function useNormalBridgeSettings() { + const bridgeSettings = useSelector((state) => state.settings.bridgeSettings); + return 'normal' in bridgeSettings ? bridgeSettings.normal : undefined; +} diff --git a/gui/src/renderer/redux/settings/actions.ts b/gui/src/renderer/redux/settings/actions.ts index c6e5aed985..ef03ce14b8 100644 --- a/gui/src/renderer/redux/settings/actions.ts +++ b/gui/src/renderer/redux/settings/actions.ts @@ -23,11 +23,6 @@ export interface IUpdateRelayLocationsAction { relayLocations: IRelayLocationRedux[]; } -export interface IUpdateBridgeLocationsAction { - type: 'UPDATE_BRIDGE_LOCATIONS'; - bridgeLocations: IRelayLocationRedux[]; -} - export interface IUpdateWireguardEndpointData { type: 'UPDATE_WIREGUARD_ENDPOINT_DATA'; wireguardEndpointData: IWireguardEndpointData; @@ -102,7 +97,6 @@ export type SettingsAction = | IUpdateGuiSettingsAction | IUpdateRelayAction | IUpdateRelayLocationsAction - | IUpdateBridgeLocationsAction | IUpdateWireguardEndpointData | IUpdateAllowLanAction | IUpdateEnableIpv6Action @@ -139,15 +133,6 @@ function updateRelayLocations(relayLocations: IRelayLocationRedux[]): IUpdateRel }; } -function updateBridgeLocations( - bridgeLocations: IRelayLocationRedux[], -): IUpdateBridgeLocationsAction { - return { - type: 'UPDATE_BRIDGE_LOCATIONS', - bridgeLocations, - }; -} - function updateWireguardEndpointData( wireguardEndpointData: IWireguardEndpointData, ): IUpdateWireguardEndpointData { @@ -258,7 +243,6 @@ export default { updateGuiSettings, updateRelay, updateRelayLocations, - updateBridgeLocations, updateWireguardEndpointData, updateAllowLan, updateEnableIpv6, diff --git a/gui/src/renderer/redux/settings/reducers.ts b/gui/src/renderer/redux/settings/reducers.ts index e4437563ec..2d1d287f00 100644 --- a/gui/src/renderer/redux/settings/reducers.ts +++ b/gui/src/renderer/redux/settings/reducers.ts @@ -9,6 +9,7 @@ import { ObfuscationType, Ownership, ProxySettings, + RelayEndpointType, RelayLocation, RelayProtocol, TunnelProtocol, @@ -16,24 +17,30 @@ import { import { IGuiSettingsState } from '../../../shared/gui-settings-state'; import { ReduxAction } from '../store'; +export type NormalRelaySettingsRedux = { + tunnelProtocol: LiftedConstraint<TunnelProtocol>; + location: LiftedConstraint<RelayLocation>; + providers: string[]; + ownership: Ownership; + openvpn: { + port: LiftedConstraint<number>; + protocol: LiftedConstraint<RelayProtocol>; + }; + wireguard: { + port: LiftedConstraint<number>; + ipVersion: LiftedConstraint<IpVersion>; + useMultihop: boolean; + entryLocation: LiftedConstraint<RelayLocation>; + }; +}; + +export type NormalBridgeSettingsRedux = { + location: LiftedConstraint<RelayLocation>; +}; + export type RelaySettingsRedux = | { - normal: { - tunnelProtocol: LiftedConstraint<TunnelProtocol>; - location: LiftedConstraint<RelayLocation>; - providers: string[]; - ownership: Ownership; - openvpn: { - port: LiftedConstraint<number>; - protocol: LiftedConstraint<RelayProtocol>; - }; - wireguard: { - port: LiftedConstraint<number>; - ipVersion: LiftedConstraint<IpVersion>; - useMultihop: boolean; - entryLocation: LiftedConstraint<RelayLocation>; - }; - }; + normal: NormalRelaySettingsRedux; } | { customTunnelEndpoint: { @@ -45,9 +52,7 @@ export type RelaySettingsRedux = export type BridgeSettingsRedux = | { - normal: { - location: LiftedConstraint<RelayLocation>; - }; + normal: NormalBridgeSettingsRedux; } | { custom: ProxySettings; @@ -61,6 +66,7 @@ export interface IRelayLocationRelayRedux { active: boolean; owned: boolean; weight: number; + endpointType: RelayEndpointType; } export interface IRelayLocationCityRedux { @@ -82,7 +88,6 @@ export interface ISettingsReduxState { guiSettings: IGuiSettingsState; relaySettings: RelaySettingsRedux; relayLocations: IRelayLocationRedux[]; - bridgeLocations: IRelayLocationRedux[]; wireguardEndpointData: IWireguardEndpointData; allowLan: boolean; enableIpv6: boolean; @@ -128,7 +133,6 @@ const initialState: ISettingsReduxState = { }, }, relayLocations: [], - bridgeLocations: [], wireguardEndpointData: { portRanges: [], udp2tcpPorts: [] }, allowLan: false, enableIpv6: true, @@ -188,12 +192,6 @@ export default function ( relayLocations: action.relayLocations, }; - case 'UPDATE_BRIDGE_LOCATIONS': - return { - ...state, - bridgeLocations: action.bridgeLocations, - }; - case 'UPDATE_WIREGUARD_ENDPOINT_DATA': return { ...state, diff --git a/gui/src/shared/daemon-rpc-types.ts b/gui/src/shared/daemon-rpc-types.ts index 6b75ee7d3c..735bb91224 100644 --- a/gui/src/shared/daemon-rpc-types.ts +++ b/gui/src/shared/daemon-rpc-types.ts @@ -162,7 +162,7 @@ export type TunnelProtocol = 'wireguard' | 'openvpn'; export type IpVersion = 'ipv4' | 'ipv6'; -interface IRelaySettingsNormal<OpenVpn, Wireguard> { +export interface IRelaySettingsNormal<OpenVpn, Wireguard> { location: Constraint<RelayLocation>; tunnelProtocol: Constraint<TunnelProtocol>; providers: string[]; diff --git a/gui/src/shared/ipc-schema.ts b/gui/src/shared/ipc-schema.ts index 210e85e007..21d7b6216d 100644 --- a/gui/src/shared/ipc-schema.ts +++ b/gui/src/shared/ipc-schema.ts @@ -13,9 +13,8 @@ import { IDeviceRemoval, IDnsOptions, ILocation, - IRelayList, + IRelayListWithEndpointData, ISettings, - IWireguardEndpointData, ObfuscationSettings, RelaySettingsUpdate, TunnelState, @@ -42,12 +41,6 @@ export interface ITranslations { relayLocations?: GetTextTranslations; } -export interface IRelayListPair { - relays: IRelayList; - bridges: IRelayList; - wireguardEndpointData: IWireguardEndpointData; -} - export type LaunchApplicationResult = { success: true } | { error: string }; export enum MacOsScrollbarVisibility { @@ -65,7 +58,7 @@ export interface IAppStateSnapshot { settings: ISettings; isPerformingPostUpgrade: boolean; deviceState?: DeviceState; - relayListPair: IRelayListPair; + relayList?: IRelayListWithEndpointData; currentVersion: ICurrentAppVersionInfo; upgradeVersion: IAppVersionInfo; guiSettings: IGuiSettingsState; @@ -135,7 +128,7 @@ export const ipcSchema = { disconnected: notifyRenderer<void>(), }, relays: { - '': notifyRenderer<IRelayListPair>(), + '': notifyRenderer<IRelayListWithEndpointData>(), }, currentVersion: { '': notifyRenderer<ICurrentAppVersionInfo>(), |
