diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2022-05-19 10:06:32 +0200 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2022-05-19 10:06:32 +0200 |
| commit | 9cca46231f623247fac50694a66fb5eedada15d8 (patch) | |
| tree | a62db50609fe2679d1f1105558a4712f8300693d /gui/src/renderer | |
| parent | 0211a43e2f9e1b7beccaefd9a2a09672f1877fd8 (diff) | |
| parent | a39213f52d76ddf08ed0a637a0fe831b3581466e (diff) | |
| download | mullvadvpn-9cca46231f623247fac50694a66fb5eedada15d8.tar.xz mullvadvpn-9cca46231f623247fac50694a66fb5eedada15d8.zip | |
Merge branch 'update-filters'
Diffstat (limited to 'gui/src/renderer')
| -rw-r--r-- | gui/src/renderer/app.tsx | 2 | ||||
| -rw-r--r-- | gui/src/renderer/components/AppRouter.tsx | 4 | ||||
| -rw-r--r-- | gui/src/renderer/components/Filter.tsx | 274 | ||||
| -rw-r--r-- | gui/src/renderer/components/FilterByProvider.tsx | 209 | ||||
| -rw-r--r-- | gui/src/renderer/components/SelectLocation.tsx | 156 | ||||
| -rw-r--r-- | gui/src/renderer/components/SelectLocationStyles.tsx | 37 | ||||
| -rw-r--r-- | gui/src/renderer/containers/SelectLocationPage.tsx | 75 | ||||
| -rw-r--r-- | gui/src/renderer/lib/routes.ts | 2 | ||||
| -rw-r--r-- | gui/src/renderer/redux/settings/reducers.ts | 4 |
9 files changed, 430 insertions, 333 deletions
diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx index 23abcd8627..f4e711f3ff 100644 --- a/gui/src/renderer/app.tsx +++ b/gui/src/renderer/app.tsx @@ -636,12 +636,14 @@ export default class AppRenderer { wireguardConstraints, tunnelProtocol, providers, + ownership, } = relaySettings.normal; actions.settings.updateRelay({ normal: { location: liftConstraint(location), providers, + ownership, openvpn: { port: liftConstraint(openvpnConstraints.port), protocol: liftConstraint(openvpnConstraints.protocol), diff --git a/gui/src/renderer/components/AppRouter.tsx b/gui/src/renderer/components/AppRouter.tsx index 278ad0aafc..db69cbb80f 100644 --- a/gui/src/renderer/components/AppRouter.tsx +++ b/gui/src/renderer/components/AppRouter.tsx @@ -22,7 +22,7 @@ import { VoucherInput, VoucherVerificationSuccess, } from './ExpiredAccountAddTime'; -import FilterByProvider from './FilterByProvider'; +import Filter from './Filter'; import Focus, { IFocusHandle } from './Focus'; import Launch from './Launch'; import MainView from './MainView'; @@ -96,7 +96,7 @@ class AppRouter extends React.Component<IHistoryProps & IAppContext, IAppRoutesS <Route exact path={RoutePath.splitTunneling} component={SplitTunnelingSettings} /> <Route exact path={RoutePath.support} component={SupportPage} /> <Route exact path={RoutePath.selectLocation} component={SelectLocationPage} /> - <Route exact path={RoutePath.filterByProvider} component={FilterByProvider} /> + <Route exact path={RoutePath.filter} component={Filter} /> </Switch> </TransitionView> </TransitionContainer> diff --git a/gui/src/renderer/components/Filter.tsx b/gui/src/renderer/components/Filter.tsx new file mode 100644 index 0000000000..a7b8272831 --- /dev/null +++ b/gui/src/renderer/components/Filter.tsx @@ -0,0 +1,274 @@ +import { useCallback, useMemo, useState } from 'react'; +import styled from 'styled-components'; + +import { colors } from '../../config.json'; +import { Ownership } from '../../shared/daemon-rpc-types'; +import { messages } from '../../shared/gettext'; +import { useAppContext } from '../context'; +import { useHistory } from '../lib/history'; +import { useBoolean } from '../lib/utilityHooks'; +import { IReduxState, useSelector } from '../redux/store'; +import Accordion from './Accordion'; +import * as AppButton from './AppButton'; +import { AriaInputGroup, AriaLabel } from './AriaGroup'; +import * as Cell from './cell'; +import Selector from './cell/Selector'; +import { normalText } from './common-styles'; +import ImageView from './ImageView'; +import { BackAction } from './KeyboardNavigation'; +import { Container, Layout } from './Layout'; +import { + NavigationBar, + NavigationContainer, + NavigationItems, + NavigationScrollbars, + TitleBarItem, +} from './NavigationBar'; + +const StyledContainer = styled(Container)({ + backgroundColor: colors.darkBlue, +}); + +const StyledNavigationScrollbars = styled(NavigationScrollbars)({ + backgroundColor: colors.darkBlue, + flex: 1, +}); + +const StyledFooter = styled.div({ + display: 'flex', + flexDirection: 'column', + padding: '18px 22px 22px', +}); + +export default function Filter() { + const history = useHistory(); + const { updateRelaySettings } = useAppContext(); + + const initialProviders = useSelector(providersSelector); + const [providers, setProviders] = useState<Record<string, boolean>>(initialProviders); + + const initialOwnership = useSelector((state) => + 'normal' in state.settings.relaySettings + ? state.settings.relaySettings.normal.ownership + : Ownership.any, + ); + const [ownership, setOwnership] = useState<Ownership>(initialOwnership); + + const onApply = useCallback(async () => { + // If all providers are selected it's represented as an empty array. + const selectedProviders = Object.values(providers).every((provider) => provider) + ? [] + : Object.entries(providers) + .filter(([, selected]) => selected) + .map(([name]) => name); + + await updateRelaySettings({ normal: { providers: selectedProviders, ownership } }); + history.pop(); + }, [providers, ownership, history, updateRelaySettings]); + + return ( + <BackAction action={history.pop}> + <Layout> + <StyledContainer> + <NavigationContainer> + <NavigationBar alwaysDisplayBarTitle={true}> + <NavigationItems> + <TitleBarItem> + { + // TRANSLATORS: Title label in navigation bar + messages.pgettext('filter-nav', 'Filter') + } + </TitleBarItem> + </NavigationItems> + </NavigationBar> + <StyledNavigationScrollbars> + <FilterByOwnership ownership={ownership} setOwnership={setOwnership} /> + <FilterByProvider providers={providers} setProviders={setProviders} /> + </StyledNavigationScrollbars> + <StyledFooter> + <AppButton.GreenButton + disabled={Object.values(providers).every((provider) => !provider)} + onClick={onApply}> + {messages.gettext('Apply')} + </AppButton.GreenButton> + </StyledFooter> + </NavigationContainer> + </StyledContainer> + </Layout> + </BackAction> + ); +} + +function providersSelector(state: IReduxState): Record<string, boolean> { + const providerConstraint = + 'normal' in state.settings.relaySettings ? state.settings.relaySettings.normal.providers : []; + + const relays = state.settings.relayLocations.concat( + state.settings.bridgeState === 'on' ? state.settings.bridgeLocations : [], + ); + const providers = relays.flatMap((country) => + country.cities.flatMap((city) => city.relays.map((relay) => relay.provider)), + ); + const uniqueProviders = removeDuplicates(providers).sort((a, b) => a.localeCompare(b)); + + // Empty containt array means that all providers are selected. No selection isn't possible. + return Object.fromEntries( + uniqueProviders.map((provider) => [ + provider, + providerConstraint.length === 0 || providerConstraint.includes(provider), + ]), + ); +} + +const StyledSelector = (styled(Selector)({ + marginBottom: 0, +}) as unknown) as new <T>() => Selector<T>; + +interface IFilterByOwnershipProps { + ownership: Ownership; + setOwnership: (ownership: Ownership) => void; +} + +function FilterByOwnership(props: IFilterByOwnershipProps) { + const [expanded, , , toggleExpanded] = useBoolean(false); + + const values = useMemo( + () => [ + { + label: messages.gettext('Any'), + value: Ownership.any, + }, + { + label: messages.pgettext('filter-view', 'Mullvad owned only'), + value: Ownership.mullvadOwned, + }, + { + label: messages.pgettext('filter-view', 'Rented only'), + value: Ownership.rented, + }, + ], + [], + ); + + return ( + <AriaInputGroup> + <Cell.CellButton onClick={toggleExpanded}> + <AriaLabel> + <Cell.Label>{messages.pgettext('filter-view', 'Ownership')}</Cell.Label> + </AriaLabel> + <ImageView + tintColor={colors.white80} + source={expanded ? 'icon-chevron-up' : 'icon-chevron-down'} + height={24} + /> + </Cell.CellButton> + + <Accordion expanded={expanded}> + <StyledSelector values={values} value={props.ownership} onSelect={props.setOwnership} /> + </Accordion> + </AriaInputGroup> + ); +} + +interface IFilterByProviderProps { + providers: Record<string, boolean>; + setProviders: (providers: (previous: Record<string, boolean>) => Record<string, boolean>) => void; +} + +function FilterByProvider(props: IFilterByProviderProps) { + const [expanded, , , toggleExpanded] = useBoolean(false); + + const onToggle = useCallback( + (provider: string) => + props.setProviders((providers) => ({ ...providers, [provider]: !providers[provider] })), + [props.setProviders], + ); + + const toggleAll = useCallback(() => { + props.setProviders((providers) => { + const shouldSelect = !Object.values(providers).every((value) => value); + return Object.fromEntries(Object.keys(providers).map((provider) => [provider, shouldSelect])); + }); + }, []); + + return ( + <> + <Cell.CellButton onClick={toggleExpanded}> + <Cell.Label>{messages.pgettext('filter-view', 'Providers')}</Cell.Label> + <ImageView + tintColor={colors.white80} + source={expanded ? 'icon-chevron-up' : 'icon-chevron-down'} + height={24} + /> + </Cell.CellButton> + <Accordion expanded={expanded}> + <CheckboxRow + label={messages.pgettext('filter-view', 'All providers')} + bold + checked={Object.values(props.providers).every((value) => value)} + onChange={toggleAll} + /> + {Object.entries(props.providers).map(([provider, checked]) => ( + <CheckboxRow key={provider} label={provider} checked={checked} onChange={onToggle} /> + ))} + </Accordion> + </> + ); +} + +interface IStyledRowTitleProps { + bold?: boolean; +} + +const StyledRow = styled.div({ + display: 'flex', + height: '44px', + alignItems: 'center', + padding: '0 22px', + marginBottom: '1px', + backgroundColor: colors.blue, +}); + +const StyledCheckbox = styled.div({ + width: '24px', + height: '24px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.white, + borderRadius: '4px', +}); + +const StyledRowTitle = styled.label(normalText, (props: IStyledRowTitleProps) => ({ + fontWeight: props.bold ? 600 : 400, + color: colors.white, + marginLeft: '22px', +})); + +interface ICheckboxRowProps extends IStyledRowTitleProps { + label: string; + checked: boolean; + onChange: (provider: string) => void; +} + +function CheckboxRow(props: ICheckboxRowProps) { + const onToggle = useCallback(() => props.onChange(props.label), [props.onChange, props.label]); + + return ( + <StyledRow onClick={onToggle}> + <StyledCheckbox role="checkbox" aria-label={props.label} aria-checked={props.checked}> + {props.checked && <ImageView source="icon-tick" width={18} tintColor={colors.green} />} + </StyledCheckbox> + <StyledRowTitle aria-hidden bold={props.bold}> + {props.label} + </StyledRowTitle> + </StyledRow> + ); +} + +function removeDuplicates(list: string[]): string[] { + return list.reduce( + (result, current) => (result.includes(current) ? result : [...result, current]), + [] as string[], + ); +} diff --git a/gui/src/renderer/components/FilterByProvider.tsx b/gui/src/renderer/components/FilterByProvider.tsx deleted file mode 100644 index 335d8597d8..0000000000 --- a/gui/src/renderer/components/FilterByProvider.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import { useCallback, useMemo, useState } from 'react'; -import styled from 'styled-components'; - -import { colors } from '../../config.json'; -import { messages } from '../../shared/gettext'; -import { useAppContext } from '../context'; -import { useHistory } from '../lib/history'; -import { useSelector } from '../redux/store'; -import * as AppButton from './AppButton'; -import { normalText } from './common-styles'; -import ImageView from './ImageView'; -import { BackAction } from './KeyboardNavigation'; -import { Container, Layout } from './Layout'; -import { - NavigationBar, - NavigationContainer, - NavigationItems, - NavigationScrollbars, - TitleBarItem, -} from './NavigationBar'; - -const StyledContainer = styled(Container)({ - backgroundColor: colors.darkBlue, -}); - -const StyledNavigationScrollbars = styled(NavigationScrollbars)({ - backgroundColor: colors.darkBlue, - flex: 1, -}); - -const StyledFooter = styled.div({ - display: 'flex', - flexDirection: 'column', - padding: '18px 22px 22px', -}); - -enum Selection { - all, - some, - none, -} - -export default function FilterByProvider() { - const history = useHistory(); - const { updateRelaySettings } = useAppContext(); - - const serverList = useSelector((state) => - state.settings.relayLocations.concat( - state.settings.bridgeState === 'on' ? state.settings.bridgeLocations : [], - ), - ); - const providerConstraint = useSelector((state) => { - if ('normal' in state.settings.relaySettings) { - return state.settings.relaySettings.normal.providers; - } else { - return []; - } - }); - - const [providers, setProviders] = useState(() => { - const providers = serverList.flatMap((country) => - country.cities.flatMap((city) => city.relays.map((relay) => relay.provider)), - ); - const uniqueProviders = removeDuplicates(providers).sort((a, b) => a.localeCompare(b)); - - return Object.fromEntries( - uniqueProviders.map((provider) => [ - provider, - providerConstraint.length === 0 || providerConstraint.includes(provider), - ]), - ); - }); - - const selectionStatus = useMemo(() => { - if (Object.values(providers).every((value) => value)) { - return Selection.all; - } else if (Object.values(providers).every((value) => !value)) { - return Selection.none; - } else { - return Selection.some; - } - }, [providers]); - - const onCheck = useCallback((provider: string) => { - setProviders((providers) => ({ ...providers, [provider]: !providers[provider] })); - }, []); - - const toggleAll = useCallback(() => { - setProviders((providers) => - Object.fromEntries( - Object.keys(providers).map((provider) => [provider, selectionStatus !== Selection.all]), - ), - ); - }, [selectionStatus]); - - const onApply = useCallback(async () => { - const selectedProviders = - selectionStatus === Selection.all - ? [] - : Object.entries(providers) - .filter(([, selected]) => selected) - .map(([provider]) => provider); - - await updateRelaySettings({ normal: { providers: selectedProviders } }); - - history.pop(); - }, [providers, history, updateRelaySettings, selectionStatus]); - - return ( - <BackAction action={history.pop}> - <Layout> - <StyledContainer> - <NavigationContainer> - <NavigationBar alwaysDisplayBarTitle={true}> - <NavigationItems> - <TitleBarItem> - { - // TRANSLATORS: Title label in navigation bar - messages.pgettext('filter-by-provider-nav', 'Filter by provider') - } - </TitleBarItem> - </NavigationItems> - </NavigationBar> - <StyledNavigationScrollbars> - <ProviderRow - provider={messages.pgettext('filter-by-provider-view', 'All providers')} - bold - checked={selectionStatus === Selection.all} - onCheck={toggleAll} - /> - {Object.entries(providers).map(([provider, checked]) => ( - <ProviderRow - key={provider} - provider={provider} - checked={checked} - onCheck={onCheck} - /> - ))} - </StyledNavigationScrollbars> - <StyledFooter> - <AppButton.GreenButton - disabled={selectionStatus === Selection.none} - onClick={onApply}> - {messages.gettext('Apply')} - </AppButton.GreenButton> - </StyledFooter> - </NavigationContainer> - </StyledContainer> - </Layout> - </BackAction> - ); -} - -interface IStyledRowTitleProps { - bold?: boolean; -} - -const StyledRow = styled.div({ - display: 'flex', - height: '44px', - alignItems: 'center', - padding: '0 22px', - marginBottom: '1px', - backgroundColor: colors.blue, -}); - -const StyledCheckbox = styled.div({ - width: '24px', - height: '24px', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.white, - borderRadius: '4px', -}); - -const StyledRowTitle = styled.label(normalText, (props: IStyledRowTitleProps) => ({ - fontWeight: props.bold ? 600 : 400, - color: colors.white, - marginLeft: '22px', -})); - -interface IProviderRowProps extends IStyledRowTitleProps { - provider: string; - checked: boolean; - onCheck: (provider: string) => void; -} - -function ProviderRow(props: IProviderRowProps) { - const onCheck = useCallback(() => props.onCheck(props.provider), [props.onCheck, props.provider]); - - return ( - <StyledRow onClick={onCheck}> - <StyledCheckbox role="checkbox" aria-label={props.provider} aria-checked={props.checked}> - {props.checked && <ImageView source="icon-tick" width={18} tintColor={colors.green} />} - </StyledCheckbox> - <StyledRowTitle aria-hidden bold={props.bold}> - {props.provider} - </StyledRowTitle> - </StyledRow> - ); -} - -function removeDuplicates(list: string[]): string[] { - return list.reduce( - (result, current) => (result.includes(current) ? result : [...result, current]), - [] as string[], - ); -} diff --git a/gui/src/renderer/components/SelectLocation.tsx b/gui/src/renderer/components/SelectLocation.tsx index 3d64906a78..34059f3e85 100644 --- a/gui/src/renderer/components/SelectLocation.tsx +++ b/gui/src/renderer/components/SelectLocation.tsx @@ -2,7 +2,12 @@ import React from 'react'; import { sprintf } from 'sprintf-js'; import { colors } from '../../config.json'; -import { LiftedConstraint, RelayLocation, TunnelProtocol } from '../../shared/daemon-rpc-types'; +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'; @@ -25,16 +30,13 @@ import { } from './NavigationBar'; import { ScopeBarItem } from './ScopeBar'; import { - StyledClearProvidersButton, + StyledClearFilterButton, StyledContainer, StyledContent, - StyledFilterByProviderButton, - StyledFilterContainer, + StyledFilter, StyledFilterIconButton, - StyledFilterMenu, + StyledFilterRow, StyledNavigationBarAttachment, - StyledProviderCountRow, - StyledProvidersCount, StyledScopeBar, StyledSettingsHeader, } from './SelectLocationStyles'; @@ -50,13 +52,15 @@ interface IProps { allowEntrySelection: boolean; tunnelProtocol: LiftedConstraint<TunnelProtocol>; providers: string[]; + ownership: Ownership; onClose: () => void; - onViewFilterByProvider: () => void; + onViewFilter: () => void; onSelectExitLocation: (location: RelayLocation) => void; onSelectEntryLocation: (location: RelayLocation) => void; onSelectBridgeLocation: (location: RelayLocation) => void; onSelectClosestToExit: () => void; onClearProviders: () => void; + onClearOwnership: () => void; } enum LocationScope { @@ -65,7 +69,6 @@ enum LocationScope { } interface IState { - showFilterMenu: boolean; headingHeight: number; locationScope: LocationScope; } @@ -76,7 +79,7 @@ interface ISelectLocationSnapshot { } export default class SelectLocation extends React.Component<IProps, IState> { - public state = { showFilterMenu: false, headingHeight: 0, locationScope: LocationScope.exit }; + public state = { headingHeight: 0, locationScope: LocationScope.exit }; private scrollView = React.createRef<CustomScrollbarsRef>(); private spacePreAllocationViewRef = React.createRef<SpacePreAllocationView>(); @@ -90,7 +93,6 @@ export default class SelectLocation extends React.Component<IProps, IState> { private snapshotByScope: Partial<Record<LocationScope, ISelectLocationSnapshot>> = {}; - private filterButtonRef = React.createRef<HTMLDivElement>(); private headerRef = React.createRef<HTMLHeadingElement>(); public componentDidMount() { @@ -132,9 +134,12 @@ export default class SelectLocation extends React.Component<IProps, IState> { } 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 onClick={this.onClickAnywhere}> + <Layout> <StyledContainer> <NavigationContainer> <NavigationBar> @@ -146,26 +151,17 @@ export default class SelectLocation extends React.Component<IProps, IState> { } </TitleBarItem> - <StyledFilterContainer ref={this.filterButtonRef}> - <StyledFilterIconButton - onClick={this.toggleFilterMenu} - aria-label={messages.gettext('Filter')}> - <ImageView - source="icon-filter-round" - tintColor={colors.white40} - tintHoverColor={colors.white60} - height={24} - width={24} - /> - </StyledFilterIconButton> - {this.state.showFilterMenu && ( - <StyledFilterMenu> - <StyledFilterByProviderButton onClick={this.props.onViewFilterByProvider}> - {messages.pgettext('select-location-view', 'Filter by provider')} - </StyledFilterByProviderButton> - </StyledFilterMenu> - )} - </StyledFilterContainer> + <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}> @@ -181,32 +177,52 @@ export default class SelectLocation extends React.Component<IProps, IState> { {this.renderHeaderSubtitle()} </StyledSettingsHeader> - {this.props.providers.length > 0 && ( - <StyledProviderCountRow> + {showFilters && ( + <StyledFilterRow> {messages.pgettext('select-location-view', 'Filtered:')} - <StyledProvidersCount> - {sprintf( - messages.pgettext( - 'select-location-view', - 'Providers: %(numberOfProviders)d', - ), - { - numberOfProviders: this.props.providers.length, - }, - )} - <StyledClearProvidersButton - aria-label={messages.gettext('Clear')} - onClick={this.props.onClearProviders}> - <ImageView - height={16} - width={16} - source="icon-close" - tintColor={colors.white60} - tintHoverColor={colors.white80} - /> - </StyledClearProvidersButton> - </StyledProvidersCount> - </StyledProviderCountRow> + + {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 @@ -242,6 +258,17 @@ export default class SelectLocation extends React.Component<IProps, IState> { } } + 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; @@ -413,21 +440,6 @@ export default class SelectLocation extends React.Component<IProps, IState> { this.spacePreAllocationViewRef.current?.allocate(expandedContentHeight); this.scrollView.current?.scrollIntoView(locationRect); }; - - private toggleFilterMenu = () => { - this.setState((state) => ({ - showFilterMenu: !state.showFilterMenu, - })); - }; - - private onClickAnywhere = (event: React.MouseEvent<HTMLDivElement>) => { - if ( - this.state.showFilterMenu && - !this.filterButtonRef.current?.contains(event.target as HTMLElement) - ) { - this.setState({ showFilterMenu: false }); - } - }; } interface ISpacePreAllocationView { diff --git a/gui/src/renderer/components/SelectLocationStyles.tsx b/gui/src/renderer/components/SelectLocationStyles.tsx index 4ace3b15cc..15211389f6 100644 --- a/gui/src/renderer/components/SelectLocationStyles.tsx +++ b/gui/src/renderer/components/SelectLocationStyles.tsx @@ -29,13 +29,8 @@ export const StyledNavigationBarAttachment = styled.div({}, (props: { top: numbe zIndex: 1, })); -export const StyledFilterContainer = styled.div({ - display: 'flex', - position: 'relative', - justifySelf: 'end', -}); - export const StyledFilterIconButton = styled.button({ + justifySelf: 'end', borderWidth: 0, padding: 0, margin: 0, @@ -43,43 +38,19 @@ export const StyledFilterIconButton = styled.button({ backgroundColor: 'transparent', }); -export const StyledFilterMenu = styled.div({ - position: 'absolute', - top: 'calc(100% + 4px)', - right: '0', - borderRadius: '4px', - backgroundColor: colors.darkBlue, - overflow: 'hidden', - zIndex: 2, -}); - -export const StyledFilterByProviderButton = styled.button(tinyText, { - borderWidth: 0, - margin: 0, - cursor: 'default', - color: colors.white, - padding: '7px 15px', - whiteSpace: 'nowrap', - borderRadius: 0, - backgroundColor: colors.blue, - ':hover': { - backgroundColor: colors.blue80, - }, -}); - export const StyledSettingsHeader = styled(SettingsHeader)({ paddingLeft: '6px', paddingBottom: '11px', }); -export const StyledProviderCountRow = styled.div({ +export const StyledFilterRow = styled.div({ ...tinyText, color: colors.white, marginLeft: '6px', marginBottom: '8px', }); -export const StyledProvidersCount = styled.div({ +export const StyledFilter = styled.div({ ...tinyText, display: 'inline-flex', alignItems: 'center', @@ -90,7 +61,7 @@ export const StyledProvidersCount = styled.div({ color: colors.white, }); -export const StyledClearProvidersButton = styled.div({ +export const StyledClearFilterButton = styled.div({ display: 'inline-block', borderWidth: 0, padding: 0, diff --git a/gui/src/renderer/containers/SelectLocationPage.tsx b/gui/src/renderer/containers/SelectLocationPage.tsx index d14323457e..8d928421f4 100644 --- a/gui/src/renderer/containers/SelectLocationPage.tsx +++ b/gui/src/renderer/containers/SelectLocationPage.tsx @@ -1,7 +1,7 @@ import { connect } from 'react-redux'; import BridgeSettingsBuilder from '../../shared/bridge-settings-builder'; -import { LiftedConstraint, RelayLocation } from '../../shared/daemon-rpc-types'; +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'; @@ -44,17 +44,19 @@ const mapStateToProps = (state: IReduxState, props: IHistoryProps & IAppContext) ((tunnelProtocol === 'any' || tunnelProtocol === 'wireguard') && multihopEnabled); const providers = 'normal' in relaySettings ? relaySettings.normal.providers : []; + const ownership = 'normal' in relaySettings ? relaySettings.normal.ownership : Ownership.any; return { locale: state.userInterface.locale, selectedExitLocation, selectedEntryLocation, selectedBridgeLocation, - relayLocations: filterLocationsByProvider(state.settings.relayLocations, providers), - bridgeLocations: filterLocationsByProvider(state.settings.bridgeLocations, providers), + relayLocations: filterLocations(state.settings.relayLocations, providers, ownership), + bridgeLocations: filterLocations(state.settings.bridgeLocations, providers, ownership), allowEntrySelection, tunnelProtocol, providers, + ownership, onSelectEntryLocation: async (entryLocation: RelayLocation) => { // dismiss the view first @@ -76,7 +78,7 @@ const mapStateToProps = (state: IReduxState, props: IHistoryProps & IAppContext) const mapDispatchToProps = (_dispatch: ReduxDispatch, props: IHistoryProps & IAppContext) => { return { onClose: () => props.history.dismiss(), - onViewFilterByProvider: () => props.history.push(RoutePath.filterByProvider), + onViewFilter: () => props.history.push(RoutePath.filter), onSelectExitLocation: async (relayLocation: RelayLocation) => { // dismiss the view first props.history.dismiss(); @@ -118,26 +120,67 @@ const mapDispatchToProps = (_dispatch: ReduxDispatch, props: IHistoryProps & IAp onClearProviders: async () => { await props.app.updateRelaySettings({ normal: { providers: [] } }); }, + onClearOwnership: async () => { + await props.app.updateRelaySettings({ normal: { ownership: Ownership.any } }); + }, }; }; +function filterLocations( + locations: IRelayLocationRedux[], + providers: string[], + ownership: Ownership, +): IRelayLocationRedux[] { + const locationsFilteredByOwnership = filterLocationsByOwnership(locations, ownership); + const locationsFilteredByProvider = filterLocationsByProvider( + locationsFilteredByOwnership, + providers, + ); + + return locationsFilteredByProvider; +} + +function filterLocationsByOwnership( + locations: IRelayLocationRedux[], + ownership: Ownership, +): IRelayLocationRedux[] { + if (ownership === Ownership.any) { + return locations; + } + + const expectOwned = ownership === Ownership.mullvadOwned; + return locations + .map((country) => ({ + ...country, + cities: country.cities + .map((city) => ({ + ...city, + relays: city.relays.filter((relay) => relay.owned === expectOwned), + })) + .filter((city) => city.relays.length > 0), + })) + .filter((country) => country.cities.length > 0); +} + function filterLocationsByProvider( locations: IRelayLocationRedux[], providers: string[], ): IRelayLocationRedux[] { - return providers.length === 0 - ? locations - : 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), + if (providers.length === 0) { + return locations; + } + + return locations + .map((country) => ({ + ...country, + cities: country.cities + .map((city) => ({ + ...city, + relays: city.relays.filter((relay) => providers.includes(relay.provider)), })) - .filter((country) => country.cities.length > 0); + .filter((city) => city.relays.length > 0), + })) + .filter((country) => country.cities.length > 0); } export default withAppContext( diff --git a/gui/src/renderer/lib/routes.ts b/gui/src/renderer/lib/routes.ts index 994a9f6124..641b48781d 100644 --- a/gui/src/renderer/lib/routes.ts +++ b/gui/src/renderer/lib/routes.ts @@ -22,7 +22,7 @@ export enum RoutePath { splitTunneling = '/settings/advanced/split-tunneling', support = '/settings/support', selectLocation = '/select-location', - filterByProvider = '/select-location/filter-by-provider', + filter = '/select-location/filter', } export const disableDismissForRoutes = [ diff --git a/gui/src/renderer/redux/settings/reducers.ts b/gui/src/renderer/redux/settings/reducers.ts index 1c759e1a52..8e4c922192 100644 --- a/gui/src/renderer/redux/settings/reducers.ts +++ b/gui/src/renderer/redux/settings/reducers.ts @@ -4,6 +4,7 @@ import { IDnsOptions, IpVersion, LiftedConstraint, + Ownership, ProxySettings, RelayLocation, RelayProtocol, @@ -18,6 +19,7 @@ export type RelaySettingsRedux = tunnelProtocol: LiftedConstraint<TunnelProtocol>; location: LiftedConstraint<RelayLocation>; providers: string[]; + ownership: Ownership; openvpn: { port: LiftedConstraint<number>; protocol: LiftedConstraint<RelayProtocol>; @@ -54,6 +56,7 @@ export interface IRelayLocationRelayRedux { ipv4AddrIn: string; includeInCountry: boolean; active: boolean; + owned: boolean; weight: number; } @@ -111,6 +114,7 @@ const initialState: ISettingsReduxState = { location: 'any', tunnelProtocol: 'any', providers: [], + ownership: Ownership.any, wireguard: { port: 'any', ipVersion: 'any', useMultihop: false, entryLocation: 'any' }, openvpn: { port: 'any', |
