diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2022-11-17 17:57:35 +0100 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2022-11-24 16:26:28 +0100 |
| commit | 4f8f400dd010235a3e52fc9294bebad06166f27e (patch) | |
| tree | 33d95e84efe30945b14fdb4ecf5939f3a2ccca3d | |
| parent | 83e0a8758668a803b3865d926579bb79d7df1415 (diff) | |
| download | mullvadvpn-4f8f400dd010235a3e52fc9294bebad06166f27e.tar.xz mullvadvpn-4f8f400dd010235a3e52fc9294bebad06166f27e.zip | |
Add search field to select location view
9 files changed, 272 insertions, 176 deletions
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 ef3cb84ef2..26ed781396 100644 --- a/gui/src/renderer/components/Filter.tsx +++ b/gui/src/renderer/components/Filter.tsx @@ -5,7 +5,11 @@ import { colors } from '../../config.json'; import { Ownership } from '../../shared/daemon-rpc-types'; import { messages } from '../../shared/gettext'; import { useAppContext } from '../context'; -import { EndpointType, filterLocations } from '../lib/filter-locations'; +import { + EndpointType, + filterLocations, + filterLocationsByEndPointType, +} from '../lib/filter-locations'; import { useHistory } from '../lib/history'; import { useBoolean, useNormalRelaySettings } from '../lib/utilityHooks'; import { IRelayLocationRedux } from '../redux/settings/reducers'; @@ -118,19 +122,24 @@ function useFilteredFilters(providers: string[], ownership: Ownership) { const endpointType = bridgeState === 'on' ? EndpointType.any : EndpointType.exit; const availableProviders = useMemo(() => { - const filteredRelays = filterLocations(locations, endpointType, relaySettings, 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( + const relayListForEndpointType = filterLocationsByEndPointType( locations, endpointType, relaySettings, - Ownership.any, - providers, ); - const filteredRelayOwnership = filteredRelays.flatMap((country) => + const relaylistForFilters = filterLocations(relayListForEndpointType, Ownership.any, providers); + + const filteredRelayOwnership = relaylistForFilters.flatMap((country) => country.cities.flatMap((city) => city.relays.map((relay) => relay.owned)), ); @@ -162,12 +171,10 @@ function providersSelector(state: IReduxState): Record<string, boolean> { const providerConstraint = relaySettings?.providers ?? []; const endpointType = state.settings.bridgeState === 'on' ? EndpointType.any : EndpointType.exit; - const relays = filterLocations( + const relays = filterLocationsByEndPointType( state.settings.relayLocations, endpointType, relaySettings, - Ownership.any, - [], ); const providers = providersFromRelays(relays); diff --git a/gui/src/renderer/components/select-location/LocationRow.tsx b/gui/src/renderer/components/select-location/LocationRow.tsx index 17261d79e4..56a16b84f9 100644 --- a/gui/src/renderer/components/select-location/LocationRow.tsx +++ b/gui/src/renderer/components/select-location/LocationRow.tsx @@ -119,10 +119,12 @@ interface IProps<C extends LocationSpecification> { 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 recentClickRef = useRef(false); const expanded = 'expanded' in props.source ? props.source.expanded : undefined; const toggleCollapse = useCallback(() => { if (expanded !== undefined) { + recentClickRef.current = true; const callback = expanded ? props.onCollapse : props.onExpand; callback(props.source.location); } @@ -137,8 +139,9 @@ function LocationRow<C extends LocationSpecification>(props: IProps<C>) { const onWillExpand = useCallback( (nextHeight: number) => { const buttonRect = buttonRef.current?.getBoundingClientRect(); - if (expanded !== undefined && buttonRect) { + if (expanded !== undefined && buttonRect && recentClickRef.current) { props.onWillExpand(buttonRect, nextHeight); + recentClickRef.current = false; } }, [props.onWillExpand], diff --git a/gui/src/renderer/components/select-location/SelectLocation.tsx b/gui/src/renderer/components/select-location/SelectLocation.tsx index 66d70bc174..660bdacafa 100644 --- a/gui/src/renderer/components/select-location/SelectLocation.tsx +++ b/gui/src/renderer/components/select-location/SelectLocation.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect } from 'react'; import { sprintf } from 'sprintf-js'; import { colors } from '../../../config.json'; @@ -9,7 +9,6 @@ import { useHistory } from '../../lib/history'; import { RoutePath } from '../../lib/routes'; import { useNormalBridgeSettings, useNormalRelaySettings } from '../../lib/utilityHooks'; import { useSelector } from '../../redux/store'; -import { CustomScrollbarsRef } from '../CustomScrollbars'; import ImageView from '../ImageView'; import { BackAction } from '../KeyboardNavigation'; import { Layout, SettingsContainer } from '../Layout'; @@ -43,16 +42,15 @@ import { StyledFilterIconButton, StyledFilterRow, StyledNavigationBarAttachment, + StyledSearchBar, } from './SelectLocationStyles'; import { SpacePreAllocationView } from './SpacePreAllocationView'; export default function SelectLocation() { const history = useHistory(); const { updateRelaySettings } = useAppContext(); - const { scrollViewRef, saveScrollPosition, resetScrollPositions } = useScrollPosition(); - const { resetExpandedLocations } = useExpandedLocations(); - const spacePreAllocationViewRef = useRef() as React.RefObject<SpacePreAllocationView>; - const { locationType, setLocationType } = useSelectLocationContext(); + const { saveScrollPosition, resetScrollPositions, applyScrollPosition } = useScrollPosition(); + const { scrollViewRef, spacePreAllocationViewRef, locationType, setLocationType, searchTerm, setSearchTerm } = useSelectLocationContext(); const relaySettings = useNormalRelaySettings(); const ownership = relaySettings?.ownership ?? Ownership.any; @@ -69,13 +67,11 @@ export default function SelectLocation() { const onClearProviders = useCallback(async () => { resetScrollPositions(); - resetExpandedLocations(); await updateRelaySettings({ normal: { providers: [] } }); }, []); const onClearOwnership = useCallback(async () => { resetScrollPositions(); - resetExpandedLocations(); await updateRelaySettings({ normal: { ownership: Ownership.any } }); }, []); @@ -87,6 +83,16 @@ export default function SelectLocation() { [saveScrollPosition], ); + const updateSearchTerm = useCallback( + (value: string) => { + resetScrollPositions(); + setSearchTerm(value); + }, + [resetScrollPositions], + ); + + useEffect(applyScrollPosition, [applyScrollPosition]); + const showOwnershipFilter = ownership !== Ownership.any; const showProvidersFilter = providers.length > 0; const showFilters = showOwnershipFilter || showProvidersFilter; @@ -171,15 +177,14 @@ export default function SelectLocation() { )} </StyledFilterRow> )} + + <StyledSearchBar searchTerm={searchTerm} onSearch={updateSearchTerm} /> </StyledNavigationBarAttachment> <NavigationScrollbars ref={scrollViewRef}> <SpacePreAllocationView ref={spacePreAllocationViewRef}> <StyledContent> - <SelectLocationContent - spacePreAllocationViewRef={spacePreAllocationViewRef} - scrollViewRef={scrollViewRef} - /> + <SelectLocationContent /> </StyledContent> </SpacePreAllocationView> </NavigationScrollbars> @@ -201,30 +206,20 @@ function ownershipFilterLabel(ownership: Ownership): string { } } -interface SelectLocationContentProps { - spacePreAllocationViewRef: React.RefObject<SpacePreAllocationView>; - scrollViewRef: React.RefObject<CustomScrollbarsRef>; -} - -function SelectLocationContent(props: SelectLocationContentProps) { - const { locationType, selectedLocationRef } = useSelectLocationContext(); +function SelectLocationContent() { + const { locationType, selectedLocationRef, spacePreAllocationViewRef } = useSelectLocationContext(); const relayList = useRelayList(); - const { expandLocation, collapseLocation } = useExpandedLocations(); + const { expandLocation, collapseLocation, updateExpandedLocations } = useExpandedLocations(); + const { onBeforeExpand } = useScrollPosition(); const onSelectLocation = useOnSelectLocation(); const onSelectBridgeLocation = useOnSelectBridgeLocation(); const relaySettings = useNormalRelaySettings(); const bridgeSettings = useNormalBridgeSettings(); - const onWillExpand = useCallback((locationRect: DOMRect, expandedContentHeight: number) => { - locationRect.height += expandedContentHeight; - props.spacePreAllocationViewRef.current?.allocate(expandedContentHeight); - props.scrollViewRef.current?.scrollIntoView(locationRect); - }, []); + const resetHeight = useCallback(() => spacePreAllocationViewRef.current?.reset(), []); - const resetHeight = useCallback(() => { - props.spacePreAllocationViewRef.current?.reset(); - }, []); + useEffect(updateExpandedLocations, [updateExpandedLocations]); if (locationType === LocationType.exit) { return ( @@ -235,7 +230,7 @@ function SelectLocationContent(props: SelectLocationContentProps) { onSelect={onSelectLocation} onExpand={expandLocation} onCollapse={collapseLocation} - onWillExpand={onWillExpand} + onWillExpand={onBeforeExpand} onTransitionEnd={resetHeight} /> ); @@ -248,7 +243,7 @@ function SelectLocationContent(props: SelectLocationContentProps) { onSelect={onSelectLocation} onExpand={expandLocation} onCollapse={collapseLocation} - onWillExpand={onWillExpand} + onWillExpand={onBeforeExpand} onTransitionEnd={resetHeight} /> ); @@ -275,7 +270,7 @@ function SelectLocationContent(props: SelectLocationContentProps) { onSelect={onSelectBridgeLocation} onExpand={expandLocation} onCollapse={collapseLocation} - onWillExpand={onWillExpand} + onWillExpand={onBeforeExpand} onTransitionEnd={resetHeight} /> ); @@ -283,17 +278,12 @@ function SelectLocationContent(props: SelectLocationContentProps) { } function useScrollPosition() { - const { - activeFilter, - locationType, - scrollPositions, - selectedLocationRef, - } = useSelectLocationContext(); - const scrollViewRef = useRef<CustomScrollbarsRef>(null); + const { locationType, scrollPositions, scrollViewRef, spacePreAllocationViewRef, selectedLocationRef, searchTerm } = useSelectLocationContext(); + const relaySettings = useNormalRelaySettings(); const saveScrollPosition = useCallback(() => { const scrollPosition = scrollViewRef.current?.getScrollPosition(); - if (scrollPositions.current) { + if (scrollPositions.current && scrollPosition) { scrollPositions.current[locationType] = scrollPosition; } }, [locationType]); @@ -309,7 +299,7 @@ function useScrollPosition() { } }, [locationType]); - useEffect(() => { + const applyScrollPosition = useCallback(() => { const scrollPosition = scrollPositions.current?.[locationType]; if (scrollPosition) { scrollViewRef.current?.scrollTo(...scrollPosition); @@ -318,7 +308,13 @@ function useScrollPosition() { } else { scrollViewRef.current?.scrollToTop(); } - }, [locationType, activeFilter]); + }, [locationType, searchTerm, relaySettings?.ownership, relaySettings?.providers]); + + const onBeforeExpand = useCallback((locationRect: DOMRect, expandedContentHeight: number) => { + locationRect.height += expandedContentHeight; + spacePreAllocationViewRef.current?.allocate(expandedContentHeight); + scrollViewRef.current?.scrollIntoView(locationRect); + }, []); - return { scrollViewRef, saveScrollPosition, resetScrollPositions }; + return { spacePreAllocationViewRef, saveScrollPosition, resetScrollPositions, applyScrollPosition, onBeforeExpand }; } diff --git a/gui/src/renderer/components/select-location/SelectLocationContainer.tsx b/gui/src/renderer/components/select-location/SelectLocationContainer.tsx index 71e898a229..4f64aa79f3 100644 --- a/gui/src/renderer/components/select-location/SelectLocationContainer.tsx +++ b/gui/src/renderer/components/select-location/SelectLocationContainer.tsx @@ -1,10 +1,12 @@ import React, { useContext, useMemo, useRef, useState } from 'react'; -import { Ownership, RelayLocation } from '../../../shared/daemon-rpc-types'; +import { RelayLocation } from '../../../shared/daemon-rpc-types'; import { useNormalBridgeSettings, useNormalRelaySettings } from '../../lib/utilityHooks'; +import {CustomScrollbarsRef} from '../CustomScrollbars'; import { defaultExpandedLocations } from './select-location-helpers'; import { LocationType } from './select-location-types'; import SelectLocation from './SelectLocation'; +import {SpacePreAllocationView} from './SpacePreAllocationView'; type ExpandedLocations = Partial<Record<LocationType, Array<RelayLocation>>>; type ScrollPosition = [number, number]; @@ -12,13 +14,16 @@ type ScrollPosition = [number, number]; interface SelectLocationContext { locationType: LocationType; setLocationType: (locationType: LocationType) => void; - activeFilter: boolean; + searchTerm: string; + setSearchTerm: (value: string) => void; expandedLocations: ExpandedLocations; setExpandedLocations: ( arg: ExpandedLocations | ((prev: ExpandedLocations) => ExpandedLocations), ) => void; scrollPositions: React.RefObject<Partial<Record<LocationType, ScrollPosition>>>; selectedLocationRef: React.RefObject<HTMLDivElement>; + scrollViewRef: React.RefObject<CustomScrollbarsRef>; + spacePreAllocationViewRef: React.RefObject<SpacePreAllocationView>; } const selectLocationContext = React.createContext<SelectLocationContext | undefined>(undefined); @@ -33,26 +38,36 @@ export default function SelectLocationContainer() { const [locationType, setLocationType] = useState(LocationType.exit); const selectedLocationRef = useRef<HTMLDivElement>(null); + const scrollViewRef = useRef<CustomScrollbarsRef>(null); + const spacePreAllocationViewRef = useRef() as React.RefObject<SpacePreAllocationView>; const [expandedLocations, setExpandedLocations] = useState< Partial<Record<LocationType, Array<RelayLocation>>> >(() => defaultExpandedLocations(relaySettings, bridgeSettings)); const scrollPositions = useRef<Partial<Record<LocationType, ScrollPosition>>>({}); - const ownershipActive = relaySettings !== undefined && relaySettings.ownership !== Ownership.any; - const providersActive = relaySettings !== undefined && relaySettings.providers.length > 0; + const [searchTerm, setSearchTerm] = useState(''); const value = useMemo( () => ({ locationType, setLocationType, - activeFilter: ownershipActive || providersActive, + searchTerm, + setSearchTerm, expandedLocations, setExpandedLocations, scrollPositions, selectedLocationRef, + scrollViewRef, + spacePreAllocationViewRef, }), - [locationType, relaySettings?.ownership, relaySettings?.providers, expandedLocations], + [ + locationType, + relaySettings?.ownership, + relaySettings?.providers, + expandedLocations, + searchTerm, + ], ); return ( diff --git a/gui/src/renderer/components/select-location/SelectLocationStyles.tsx b/gui/src/renderer/components/select-location/SelectLocationStyles.tsx index ba5c0c5f24..f1702d13dc 100644 --- a/gui/src/renderer/components/select-location/SelectLocationStyles.tsx +++ b/gui/src/renderer/components/select-location/SelectLocationStyles.tsx @@ -51,5 +51,6 @@ export const StyledClearFilterButton = styled.div({ }); export const StyledSearchBar = styled(SearchBar)({ - marginBottom: '14px', + marginTop: '10px', + marginBottom: '4px', }); diff --git a/gui/src/renderer/components/select-location/select-location-hooks.ts b/gui/src/renderer/components/select-location/select-location-hooks.ts index c42c5899c7..5bab2a429d 100644 --- a/gui/src/renderer/components/select-location/select-location-hooks.ts +++ b/gui/src/renderer/components/select-location/select-location-hooks.ts @@ -10,9 +10,19 @@ import log from '../../../shared/logging'; import RelaySettingsBuilder from '../../../shared/relay-settings-builder'; import { useAppContext } from '../../context'; import { createWireguardRelayUpdater } from '../../lib/constraint-updater'; -import { EndpointType, filterLocations } from '../../lib/filter-locations'; +import { + EndpointType, + filterLocations, + filterLocationsByEndPointType, + getLocationsExpandedBySearch, + searchForLocations, +} from '../../lib/filter-locations'; import { useHistory } from '../../lib/history'; -import { useNormalBridgeSettings, useNormalRelaySettings } from '../../lib/utilityHooks'; +import { + useNormalBridgeSettings, + useNormalRelaySettings, + useSharedMemo, +} from '../../lib/utilityHooks'; import { IRelayLocationRedux } from '../../redux/settings/reducers'; import { useSelector } from '../../redux/store'; import { @@ -36,14 +46,38 @@ import { useSelectLocationContext } from './SelectLocationContainer'; // Return all locations that matches both the set filters and the search term. function useFilteredRelays(): Array<IRelayLocationRedux> { - const { locationType } = useSelectLocationContext(); + const { locationType, searchTerm } = useSelectLocationContext(); const relayList = useSelector((state) => state.settings.relayLocations); const relaySettings = useNormalRelaySettings(); - const endpointType = locationType === LocationType.entry ? EndpointType.entry : EndpointType.exit; - const filteredRelayList = useMemo( - () => (relaySettings ? filterLocations(relayList, endpointType, relaySettings) : relayList), - [relaySettings, relayList, endpointType], + const relayListForEndpointType = useSharedMemo( + 'relay-list-endpoint-type', + () => { + const endpointType = + locationType === LocationType.entry ? EndpointType.entry : EndpointType.exit; + return filterLocationsByEndPointType(relayList, endpointType, relaySettings); + }, + [relayList, locationType, relaySettings?.tunnelProtocol], + ); + + const relayListForFilters = useSharedMemo( + 'relay-list-filters', + () => { + return filterLocations( + relayListForEndpointType, + relaySettings?.ownership, + relaySettings?.providers, + ); + }, + [relaySettings?.ownership, relaySettings?.providers, relayListForEndpointType], + ); + + const filteredRelayList = useSharedMemo( + 'relay-list-search', + () => { + return searchForLocations(relayListForFilters, searchTerm); + }, + [relayListForFilters, searchTerm], ); return filteredRelayList; @@ -53,12 +87,13 @@ function useFilteredRelays(): Array<IRelayLocationRedux> { export function useExpandedLocations() { const relaySettings = useNormalRelaySettings(); const bridgeSettings = useNormalBridgeSettings(); - const { locationType, expandedLocations, setExpandedLocations } = useSelectLocationContext(); - - const expandedLocationsForType = useMemo(() => expandedLocations[locationType], [ - expandedLocations, + const { locationType, - ]); + expandedLocations, + setExpandedLocations, + searchTerm, + } = useSelectLocationContext(); + const relayList = useFilteredRelays(); const expandLocation = useCallback( (location: RelayLocation) => { @@ -82,15 +117,22 @@ export function useExpandedLocations() { [locationType], ); - const resetExpandedLocations = useCallback(() => { - setExpandedLocations(defaultExpandedLocations(relaySettings, bridgeSettings)); - }, [relaySettings, bridgeSettings]); + const updateExpandedLocations = useCallback(() => { + if (searchTerm === '') { + setExpandedLocations(defaultExpandedLocations(relaySettings, bridgeSettings)); + } else { + setExpandedLocations((expandedLocations) => ({ + ...expandedLocations, + [locationType]: getLocationsExpandedBySearch(relayList, searchTerm), + })); + } + }, [relayList, searchTerm, relaySettings?.ownership, relaySettings?.providers]); return { - expandedLocations: expandedLocationsForType, + expandedLocations: expandedLocations[locationType], expandLocation, collapseLocation, - resetExpandedLocations, + updateExpandedLocations, }; } @@ -103,77 +145,81 @@ export function useRelayList(): LocationList<never> { const selectedLocation = useSelectedLocation(); const disabledLocation = useDisabledLocation(); - return relayList - .map((country) => { - const countryLocation = { country: country.code }; - const countryDisabled = isCountryDisabled(country, countryLocation.country, disabledLocation); + return useSharedMemo('relay-list-formatted', () => { + return relayList + .map((country) => { + const countryLocation = { country: country.code }; + const countryDisabled = isCountryDisabled(country, countryLocation.country, 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 { + ...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 { + ...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)); + 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]); } function useDisabledLocation() { const { locationType } = useSelectLocationContext(); const relaySettings = useNormalRelaySettings(); - 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 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; + 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 @@ -182,15 +228,17 @@ function useSelectedLocation() { const relaySettings = useNormalRelaySettings(); const bridgeSettings = useNormalBridgeSettings(); - 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; - } + 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]); } export function useOnSelectLocation() { diff --git a/gui/src/renderer/lib/filter-locations.ts b/gui/src/renderer/lib/filter-locations.ts index c1ea17106f..5352df41bd 100644 --- a/gui/src/renderer/lib/filter-locations.ts +++ b/gui/src/renderer/lib/filter-locations.ts @@ -12,25 +12,30 @@ export enum EndpointType { exit, } -export function filterLocations( +export function filterLocationsByEndPointType( locations: IRelayLocationRedux[], endpointType: EndpointType, relaySettings?: NormalRelaySettingsRedux, +): IRelayLocationRedux[] { + return filterLocationsImpl(locations, getTunnelProtocolFilter(endpointType, relaySettings)); +} + +export function filterLocations( + locations: IRelayLocationRedux[], ownership?: Ownership, providers?: Array<string>, ): IRelayLocationRedux[] { - const byTunnelProtocol = filterByTunnelProtocol(locations, endpointType, relaySettings); - const byOwnership = filterByOwnership(byTunnelProtocol, ownership ?? relaySettings?.ownership); - const byProvider = filterByProvider(byOwnership, providers ?? relaySettings?.providers); + const filters = [getOwnershipFilter(ownership), getProviderFilter(providers)]; - return byProvider; + return filters.some((filter) => filter !== undefined) + ? filterLocationsImpl(locations, (relay) => filters.every((filter) => filter?.(relay) ?? true)) + : locations; } -function filterByTunnelProtocol( - locations: IRelayLocationRedux[], +function getTunnelProtocolFilter( endpointType: EndpointType, relaySettings?: NormalRelaySettingsRedux, -) { +): (relay: IRelayLocationRelayRedux) => boolean { const tunnelProtocol = relaySettings?.tunnelProtocol ?? 'any'; const endpointTypes: Array<RelayEndpointType> = []; if (endpointType !== EndpointType.exit && tunnelProtocol === 'openvpn') { @@ -44,28 +49,26 @@ function filterByTunnelProtocol( endpointTypes.push(tunnelProtocol); } - return filterLocationsImpl(locations, (relay) => endpointTypes.includes(relay.endpointType)); + return (relay) => endpointTypes.includes(relay.endpointType); } -function filterByOwnership( - locations: IRelayLocationRedux[], +function getOwnershipFilter( ownership?: Ownership, -): IRelayLocationRedux[] { +): ((relay: IRelayLocationRelayRedux) => boolean) | undefined { if (ownership === undefined || ownership === Ownership.any) { - return locations; + return undefined; } const expectOwned = ownership === Ownership.mullvadOwned; - return filterLocationsImpl(locations, (relay) => relay.owned === expectOwned); + return (relay) => relay.owned === expectOwned; } -function filterByProvider( - locations: IRelayLocationRedux[], +function getProviderFilter( providers?: string[], -): IRelayLocationRedux[] { +): ((relay: IRelayLocationRelayRedux) => boolean) | undefined { return providers === undefined || providers.length === 0 - ? locations - : filterLocationsImpl(locations, (relay) => providers.includes(relay.provider)); + ? undefined + : (relay) => providers.includes(relay.provider); } function filterLocationsImpl( @@ -89,7 +92,7 @@ export function searchForLocations( return countries.reduce((countries, country) => { const matchingCities = searchCities(country.cities, searchTerm); const expanded = matchingCities.length > 0; - const match = search(country.code, searchTerm) || search(country.name, searchTerm); + 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>); @@ -102,7 +105,7 @@ function searchCities( return cities.reduce((cities, city) => { const matchingRelays = city.relays.filter((relay) => search(searchTerm, relay.hostname)); const expanded = matchingRelays.length > 0; - const match = search(city.code, searchTerm) || search(city.name, searchTerm); + 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>); @@ -118,8 +121,11 @@ export function getLocationsExpandedBySearch( country.code, searchTerm, ); + const cityMatches = country.cities.some( + (city) => search(searchTerm, city.code) || search(searchTerm, city.name), + ); const location = { country: country.code }; - const expanded = cityLocations.length > 0; + const expanded = cityMatches || cityLocations.length > 0; return expanded ? [...locations, ...cityLocations, location] : locations; }, [] as Array<RelayLocation>); } diff --git a/gui/src/renderer/lib/utilityHooks.ts b/gui/src/renderer/lib/utilityHooks.ts index 378a6d5ae5..4c66937393 100644 --- a/gui/src/renderer/lib/utilityHooks.ts +++ b/gui/src/renderer/lib/utilityHooks.ts @@ -65,3 +65,28 @@ export function useNormalBridgeSettings() { const bridgeSettings = useSelector((state) => state.settings.bridgeSettings); return 'normal' in bridgeSettings ? bridgeSettings.normal : undefined; } + +const sharedMemoData: Record< + string, + { value: unknown; dependencies: Array<unknown> | undefined } +> = {}; +export function useSharedMemo<T>( + key: string, + factory: () => T, + dependencies: Array<unknown> | undefined, +): T { + const data = sharedMemoData[key]; + if ( + data === undefined || + data.dependencies === undefined || + dependencies === undefined || + data.dependencies.length !== dependencies.length || + data.dependencies.some((item, i) => item !== dependencies[i]) + ) { + const value = factory(); + sharedMemoData[key] = { value, dependencies }; + return value; + } else { + return data.value as T; + } +} |
