import React from 'react'; import { sprintf } from 'sprintf-js'; import { colors } from '../../config.json'; import { LiftedConstraint, RelayLocation } from '../../shared/daemon-rpc-types'; import { messages } from '../../shared/gettext'; import { IRelayLocationRedux } from '../redux/settings/reducers'; import { LocationScope } from '../redux/userinterface/reducers'; import BridgeLocations, { SpecialBridgeLocationType } from './BridgeLocations'; import { CustomScrollbarsRef } from './CustomScrollbars'; import ExitLocations from './ExitLocations'; import ImageView from './ImageView'; import { Layout } from './Layout'; import LocationList, { LocationSelection, LocationSelectionType } from './LocationList'; import { CloseBarItem, NavigationBar, NavigationContainer, NavigationItems, NavigationScrollbars, TitleBarItem, } from './NavigationBar'; import { ScopeBarItem } from './ScopeBar'; import { StyledContainer, StyledContent, StyledFilterIconButton, StyledFilterContainer, StyledFilterMenu, StyledNavigationBarAttachment, StyledScopeBar, StyledFilterByProviderButton, StyledProvidersCount, StyledProviderCountRow, StyledClearProvidersButton, StyledSettingsHeader, } from './SelectLocationStyles'; import { HeaderSubTitle, HeaderTitle } from './SettingsHeader'; interface IProps { locationScope: LocationScope; selectedExitLocation?: RelayLocation; selectedBridgeLocation?: LiftedConstraint; relayLocations: IRelayLocationRedux[]; bridgeLocations: IRelayLocationRedux[]; allowBridgeSelection: boolean; providers: string[]; onClose: () => void; onViewFilterByProvider: () => void; onChangeLocationScope: (location: LocationScope) => void; onSelectExitLocation: (location: RelayLocation) => void; onSelectBridgeLocation: (location: RelayLocation) => void; onSelectClosestToExit: () => void; onClearProviders: () => void; } interface IState { showFilterMenu: boolean; headingHeight: number; } interface ISelectLocationSnapshot { scrollPosition: [number, number]; expandedLocations: RelayLocation[]; } export default class SelectLocation extends React.Component { public state = { showFilterMenu: false, headingHeight: 0 }; private scrollView = React.createRef(); private spacePreAllocationViewRef = React.createRef(); private selectedExitLocationRef = React.createRef(); private selectedBridgeLocationRef = React.createRef(); private exitLocationList = React.createRef>(); private bridgeLocationList = React.createRef>(); private snapshotByScope: { [index: number]: ISelectLocationSnapshot } = {}; private filterButtonRef = React.createRef(); private headerRef = React.createRef(); public componentDidMount() { this.scrollToSelectedCell(); this.setState((state) => ({ headingHeight: this.headerRef.current?.offsetHeight ?? state.headingHeight, })); } public componentDidUpdate( prevProps: IProps, _prevState: unknown, snapshot?: ISelectLocationSnapshot, ) { if (this.props.locationScope !== prevProps.locationScope) { this.restoreScrollPosition(this.props.locationScope); if (snapshot) { this.snapshotByScope[prevProps.locationScope] = snapshot; } } } public getSnapshotBeforeUpdate(prevProps: IProps): ISelectLocationSnapshot | undefined { const scrollView = this.scrollView.current; const locationList = prevProps.locationScope === LocationScope.relay ? this.exitLocationList.current : this.bridgeLocationList.current; if (scrollView && locationList) { return { scrollPosition: scrollView.getScrollPosition(), expandedLocations: locationList.getExpandedLocations(), }; } else { return undefined; } } public render() { return ( { // TRANSLATORS: Title label in navigation bar messages.pgettext('select-location-nav', 'Select location') } {this.state.showFilterMenu && ( {messages.pgettext('select-location-view', 'Filter by provider')} )} { // TRANSLATORS: Heading in select location view messages.pgettext('select-location-view', 'Select location') } {this.props.allowBridgeSelection && 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).', )} {this.props.providers.length > 0 && ( {messages.pgettext('select-location-view', 'Filtered:')} {sprintf( messages.pgettext( 'select-location-view', 'Providers: %(numberOfProviders)d', ), { numberOfProviders: this.props.providers.length, }, )} )} {this.props.allowBridgeSelection && ( {messages.pgettext('select-location-nav', 'Entry')} {messages.pgettext('select-location-nav', 'Exit')} )} {this.props.locationScope === LocationScope.relay ? ( ) : ( )} ); } public restoreScrollPosition(scope: LocationScope) { const snapshot = this.snapshotByScope[scope]; if (snapshot) { this.scrollToPosition(...snapshot.scrollPosition); } else { this.scrollToSelectedCell(); } } private resetHeight = () => { this.spacePreAllocationViewRef.current?.reset(); }; private getExpandedLocationsFromSnapshot(): RelayLocation[] | undefined { const snapshot = this.snapshotByScope[this.props.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.props.locationScope === LocationScope.relay ? this.selectedExitLocationRef.current : this.selectedBridgeLocationRef.current; const scrollView = this.scrollView.current; if (scrollView) { if (ref) { if (ref instanceof HTMLElement) { scrollView.scrollToElement(ref, 'middle'); } } else { scrollView.scrollToTop(); } } } private onSelectExitLocation = (location: LocationSelection) => { if (location.type === LocationSelectionType.relay) { this.props.onSelectExitLocation(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); }; private toggleFilterMenu = () => { this.setState((state) => ({ showFilterMenu: !state.showFilterMenu, })); }; private onClickAnywhere = (event: React.MouseEvent) => { if ( this.state.showFilterMenu && !this.filterButtonRef.current?.contains(event.target as HTMLElement) ) { this.setState({ showFilterMenu: false }); } }; } 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; } } }