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 } 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, StyledContainer, StyledContent, StyledFilter, StyledFilterIconButton, StyledFilterRow, StyledNavigationBarAttachment, StyledScopeBar, StyledSettingsHeader, } from './SelectLocationStyles'; import { HeaderSubTitle, HeaderTitle } from './SettingsHeader'; interface IProps { locale: string; selectedExitLocation?: RelayLocation; selectedEntryLocation?: RelayLocation; selectedBridgeLocation?: LiftedConstraint; relayLocations: IRelayLocationRedux[]; bridgeLocations: IRelayLocationRedux[]; allowEntrySelection: boolean; tunnelProtocol: LiftedConstraint; 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 { public state = { headingHeight: 0, locationScope: LocationScope.exit }; private scrollView = React.createRef(); private spacePreAllocationViewRef = React.createRef(); private selectedExitLocationRef = React.createRef(); private selectedEntryLocationRef = React.createRef(); private selectedBridgeLocationRef = React.createRef(); private exitLocationList = React.createRef>(); private entryLocationList = React.createRef>(); private bridgeLocationList = React.createRef>(); private snapshotByScope: Partial> = {}; private headerRef = React.createRef(); 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 ( { // TRANSLATORS: Title label in navigation bar messages.pgettext('select-location-nav', 'Select location') } { // TRANSLATORS: Heading in select location view messages.pgettext('select-location-view', 'Select location') } {this.renderHeaderSubtitle()} {showFilters && ( {messages.pgettext('select-location-view', 'Filtered:')} {showOwnershipFilter && ( {this.ownershipFilterLabel()} )} {showProvidersFilter && ( {sprintf( messages.pgettext( 'select-location-view', 'Providers: %(numberOfProviders)d', ), { numberOfProviders: this.props.providers.length, }, )} )} )} {this.props.allowEntrySelection && ( {messages.pgettext('select-location-view', 'Entry')} {messages.pgettext('select-location-view', 'Exit')} )} {this.renderLocationList()} ); } 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 ( {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).', )} ); } else { return ( {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).', )} ); } } 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 ( ); } else if (this.props.tunnelProtocol === 'any' || this.props.tunnelProtocol === 'wireguard') { const disabledLocation = this.props.selectedExitLocation ? { location: this.props.selectedExitLocation, reason: DisabledReason.exit, } : undefined; return ( ); } else { return ( ); } } 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) => { if (location.type === LocationSelectionType.relay) { this.props.onSelectExitLocation(location.value); } }; private onSelectEntryLocation = (location: LocationSelection) => { this.props.onSelectEntryLocation(location.value); }; private onSelectBridgeLocation = (location: LocationSelection) => { 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 { private ref = React.createRef(); public allocate(height: number) { if (this.ref.current) { this.minHeight = this.ref.current.offsetHeight + height + 'px'; } } public reset = () => { this.minHeight = 'auto'; }; public render() { return
{this.props.children}
; } private set minHeight(value: string) { const element = this.ref.current; if (element) { element.style.minHeight = value; } } }