From 5a7479afe47e305dcb2a986caed1ee8dc8e7e526 Mon Sep 17 00:00:00 2001 From: agentpietrucha Date: Mon, 1 Dec 2025 20:55:56 +0100 Subject: [PATCH 01/16] Fix scrolling to selected server --- .../src/contexts/node-list-state/provider.tsx | 4 +- nym-vpn-app/src/screens/node/Node.tsx | 22 +- .../src/screens/node/list/GatewayItem.tsx | 25 +- .../src/screens/node/list/NodeItem.tsx | 115 ++++++++++ .../src/screens/node/list/NodeList.tsx | 215 ++---------------- .../node/list/NodeListPanelContent.tsx | 21 ++ .../src/screens/node/list/RowHeader.tsx | 33 ++- 7 files changed, 226 insertions(+), 209 deletions(-) create mode 100644 nym-vpn-app/src/screens/node/list/NodeItem.tsx create mode 100644 nym-vpn-app/src/screens/node/list/NodeListPanelContent.tsx diff --git a/nym-vpn-app/src/contexts/node-list-state/provider.tsx b/nym-vpn-app/src/contexts/node-list-state/provider.tsx index 9bec125717..7bcf910071 100644 --- a/nym-vpn-app/src/contexts/node-list-state/provider.tsx +++ b/nym-vpn-app/src/contexts/node-list-state/provider.tsx @@ -29,9 +29,9 @@ function NodeListPrevStateProvider({ children }: NodeListPrevStateProps) { return; } if (hop === 'entry') { - setEntryExpanded([...entryExpanded, value]); + setEntryExpanded((prev) => [...prev, value]); } else { - setExitExpanded([...exitExpanded, value]); + setExitExpanded((prev) => [...prev, value]); } }, [entryExpanded, exitExpanded], diff --git a/nym-vpn-app/src/screens/node/Node.tsx b/nym-vpn-app/src/screens/node/Node.tsx index 1fbe16fd88..0d08f3253a 100644 --- a/nym-vpn-app/src/screens/node/Node.tsx +++ b/nym-vpn-app/src/screens/node/Node.tsx @@ -7,6 +7,7 @@ import { SelectedUiNode, UiGateway, useDialog, + useGateways, useMainDispatch, useMainState, useNodeList, @@ -62,6 +63,8 @@ function Node({ node }: { node: NodeHop }) { const deferredGateways = useDeferredValue(gateways); const searchRef = useRef(null); + const { lookupGw } = useGateways(); + useEffect(() => { if (searchRef.current) searchRef.current.focus(); }, []); @@ -74,16 +77,23 @@ function Node({ node }: { node: NodeHop }) { } if (isCountry(selectedNode)) { setFocused(node, { type: 'country', key: selectedNode.country.code }); - } - if (isRegion(selectedNode)) { + } else if (isRegion(selectedNode)) { const code = regionToCountryCode(selectedNode.region); if (code) { - addToExpanded(node, code); + addToExpanded(node, code.toUpperCase()); setFocused(node, { type: 'region', key: selectedNode.region }); } + } else if (isGateway(selectedNode)) { + setFocused(node, { type: 'gateway', key: selectedNode.gateway.id }); + const gw = lookupGw(selectedNode.gateway.id, node); + if (gw) { + addToExpanded(node, gw.country.code.toUpperCase()); + if (gw.country.code.toLowerCase() === 'us') { + addToExpanded(node, gw.location.region); + } + } } - // TODO handle US regions auto-expand and focus - }, [selectedNode, node, addToExpanded, setFocused, focused]); + }, [selectedNode, node, addToExpanded, setFocused, focused, lookupGw]); const handleSelect = async (selected: SelectedUiNode) => { const selectedNode = uiNodeToSelectedNode(selected); @@ -133,7 +143,7 @@ function Node({ node }: { node: NodeHop }) { data-testid="node-error-container" >

; gateway: UiGateway; onSelect: (gateway: UiGateway) => void; onNodeDetails: (node: UiGateway) => void; @@ -20,7 +20,6 @@ type GatewayRowProps = { }; const GatewayItem = ({ - ref, gateway, node, vpnMode, @@ -29,6 +28,10 @@ const GatewayItem = ({ quicLabel, inSearchResult, }: GatewayRowProps) => { + const { exit: exitNodeList, entry: entryNodeList } = useNodeListState(); + const focused = + node === 'entry' ? entryNodeList.focused : exitNodeList.focused; + const { isSelected } = gateway; const score = vpnMode === 'mixnet' ? gateway.mxScore : gateway.wgScore; const { getCountryName } = useLang(); @@ -54,9 +57,23 @@ const GatewayItem = ({ return gateway.location.city; }; + const scrollToGatewayRef = useCallback( + (htmlElement: HTMLDivElement) => { + if (!htmlElement) return; + if (focused?.type === 'gateway' && focused.key === gateway.id) { + htmlElement.scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center', + }); + } + }, + [focused, gateway.id], + ); + return (

void; + onGatewaySelect: (node: SelectedUiNode) => void; + onNodeDetails: (gateway: UiGateway) => void; +}) { + const { i18n, isSelected, gateways, country, regions } = node; + + return ( + <> + + handleLocationSelect(country, isSelected, gateways.length) + } + gwCount={gateways.length} + /> + + {country.code.toLowerCase() === 'us' ? ( + regions.map((region) => ( + ( + <> + { + handleLocationSelect( + region, + region.isSelected, + region.gateways.length, + ); + }} + gwCount={region.gateways.length} + sub + /> + + + {region.gateways.map((gateway) => ( + + ))} + + + + )} + > + )) + ) : ( + + {gateways.map((gateway) => ( + + ))} + + )} + + + ); +}); diff --git a/nym-vpn-app/src/screens/node/list/NodeList.tsx b/nym-vpn-app/src/screens/node/list/NodeList.tsx index 5508d4f017..1514a06ce7 100644 --- a/nym-vpn-app/src/screens/node/list/NodeList.tsx +++ b/nym-vpn-app/src/screens/node/list/NodeList.tsx @@ -1,8 +1,6 @@ -import { ReactNode, memo, useCallback, useEffect, useRef } from 'react'; +import { memo } from 'react'; import { dequal } from 'dequal'; import { Accordion } from '@base-ui-components/react'; -import { useTranslation } from 'react-i18next'; -import { motion } from 'motion/react'; import { Focused, SelectedKind, @@ -15,8 +13,7 @@ import { useNodeListState, } from '../../../contexts'; import { NodeHop, VpnMode } from '../../../types'; -import GatewayItem from './GatewayItem'; -import RowHeader from './RowHeader'; +import { NodeItem } from './NodeItem'; export type NodeListProps = { nodes: UiGatewaysByCountry[]; @@ -31,74 +28,18 @@ export type NodeListProps = { const NodeList = memo(function NodeList({ nodes, - gateways, onSelect, hop, vpnMode, onNodeDetails, expanded, - focused, }: NodeListProps) { const { backendFlags, quic } = useMainState(); const { setExpanded } = useNodeListState(); - const { t } = useTranslation('nodeLocation'); - const countriesRef = useRef>(null); - const regionsRef = useRef>(null); - const gatewaysRef = useRef>(null); const quicFilter = vpnMode === 'wg' && hop === 'entry' && backendFlags.quic && quic; - const getMap = (type: 'country' | 'region' | 'gateway') => { - if (type === 'country') { - if (!countriesRef.current) { - countriesRef.current = new Map(); - } - return countriesRef.current; - } - if (type === 'region') { - if (!regionsRef.current) { - regionsRef.current = new Map(); - } - return regionsRef.current; - } - if (type === 'gateway') { - if (!gatewaysRef.current) { - gatewaysRef.current = new Map(); - } - return gatewaysRef.current; - } - }; - - const setRef = ( - type: 'country' | 'region' | 'gateway', - key: string, - node: HTMLDivElement | null, - ) => { - if (!node) { - return; - } - const map = getMap(type); - map?.set(key, node); - - return () => { - map?.delete(key); - }; - }; - - const scrollToNode = useCallback( - (type: 'country' | 'region' | 'gateway', key: string) => { - const map = getMap(type); - const node = map?.get(key); - node?.scrollIntoView({ - behavior: 'smooth', - block: 'center', - inline: 'center', - }); - }, - [], - ); - const handleLocationSelect = ( location: UiCountry | UiRegion, isSelected: SelectedKind, @@ -118,36 +59,8 @@ const NodeList = memo(function NodeList({ setExpanded(hop, value); }; - useEffect(() => { - let timeoutId: NodeJS.Timeout; - if (focused) { - timeoutId = setTimeout(() => { - scrollToNode(focused.type, focused.key); - }, 100); - } - - return () => clearTimeout(timeoutId); - }, [focused, scrollToNode]); - - const PanelContent = ({ - children, - animate = false, - }: { - children: ReactNode; - animate?: boolean; - }) => ( - - {children} - - ); - return ( - <> +
- {nodes.map(({ i18n, isSelected, gateways, country, regions }) => ( + {nodes.map((node) => ( setRef('country', country.code, node)} - data-testid={`country-accordion-item-${country.code}`} - > - - handleLocationSelect(country, isSelected, gateways.length) - } - gwCount={gateways.length} - /> - - {country.code.toLowerCase() === 'us' ? ( - regions.map((region) => ( - setRef('region', region.name, node)} - > - { - handleLocationSelect( - region, - region.isSelected, - region.gateways.length, - ); - }} - gwCount={region.gateways.length} - sub - /> - - - {region.gateways.map((gateway) => ( - setRef('gateway', gateway.id, node)} - node={hop} - gateway={gateway} - onSelect={onSelect} - onNodeDetails={onNodeDetails} - vpnMode={vpnMode} - quicLabel={quicFilter} - /> - ))} - - - - )) - ) : ( - - {gateways.map((gateway) => ( - setRef('gateway', gateway.id, node)} - node={hop} - gateway={gateway} - onSelect={onSelect} - onNodeDetails={onNodeDetails} - vpnMode={vpnMode} - quicLabel={quicFilter} - /> - ))} - - )} - - - ))} - - {gateways.length > 0 && ( -
-

- {t('search-other-nodes')} -

- {gateways.map((gateway) => ( - - ( + - - ))} -
- )} - + )} + > + ))} + +
); }, arePropsEqual); diff --git a/nym-vpn-app/src/screens/node/list/NodeListPanelContent.tsx b/nym-vpn-app/src/screens/node/list/NodeListPanelContent.tsx new file mode 100644 index 0000000000..0befa1d0bc --- /dev/null +++ b/nym-vpn-app/src/screens/node/list/NodeListPanelContent.tsx @@ -0,0 +1,21 @@ +import { motion } from 'motion/react'; +import { ReactNode } from 'react'; + +export const PanelContent = ({ + children, + animate = false, +}: { + children: ReactNode; + animate?: boolean; +}) => { + return ( + + {children} + + ); +}; diff --git a/nym-vpn-app/src/screens/node/list/RowHeader.tsx b/nym-vpn-app/src/screens/node/list/RowHeader.tsx index 97c261297e..1c42c44bcd 100644 --- a/nym-vpn-app/src/screens/node/list/RowHeader.tsx +++ b/nym-vpn-app/src/screens/node/list/RowHeader.tsx @@ -1,6 +1,12 @@ +import { useCallback } from 'react'; import clsx from 'clsx'; import { Accordion } from '@base-ui-components/react'; -import { SelectedKind, UiCountry, UiRegion } from '../../../contexts'; +import { + SelectedKind, + UiCountry, + UiRegion, + useNodeListState, +} from '../../../contexts'; import LocationInfo from './LocationInfo'; import FoldButton from './FoldButton'; @@ -23,8 +29,33 @@ function RowHeader({ i18n, sub, }: RowHeaderProps) { + const { exit: exitNodeList, entry: entryNodeList } = useNodeListState(); + + const focused = + hop === 'entry' ? entryNodeList.focused : exitNodeList.focused; + + const scrollToRowRef = useCallback( + (htmlElement: HTMLDivElement) => { + if (!htmlElement) return; + const isFocused = + focused?.type === node.nodeType && + ((node.nodeType === 'country' && focused.key === node.code) || + (node.nodeType === 'region' && focused.key === node.name)); + + if (isFocused) { + htmlElement.scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center', + }); + } + }, + [focused, node], + ); + return (
Date: Tue, 2 Dec 2025 11:35:32 +0100 Subject: [PATCH 02/16] Fix selected node flicker --- nym-vpn-app/src/screens/home/HopSelect.tsx | 113 ++++-------------- .../src/screens/home/SelectedNodeDisplay.tsx | 79 ++++++++++++ 2 files changed, 104 insertions(+), 88 deletions(-) create mode 100644 nym-vpn-app/src/screens/home/SelectedNodeDisplay.tsx diff --git a/nym-vpn-app/src/screens/home/HopSelect.tsx b/nym-vpn-app/src/screens/home/HopSelect.tsx index 6b5fac6e27..f5745afb15 100644 --- a/nym-vpn-app/src/screens/home/HopSelect.tsx +++ b/nym-vpn-app/src/screens/home/HopSelect.tsx @@ -1,6 +1,5 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { AnimatePresence, motion } from 'motion/react'; import clsx from 'clsx'; import { useNavigate } from 'react-router'; import { Button } from '@headlessui/react'; @@ -12,13 +11,16 @@ import { isGateway, isRegion, } from '../../types'; -import { FlagIcon, MsIcon, countryCode } from '../../ui'; +import { MsIcon, countryCode } from '../../ui'; import { useLang } from '../../hooks'; import { useGateways, useMainState } from '../../contexts'; import { countriesWithRegions } from '../../constants'; -import { QuicTag } from '../node'; import { routes } from '../../router'; import { isBridgeMode, regionToCountryCode, useActionToast } from './util'; +import { + SelectedNodeDisplay, + SelectedNodeDisplayProps, +} from './SelectedNodeDisplay'; type HopSelectProps = { node: SelectedNode; @@ -28,15 +30,6 @@ type HopSelectProps = { disabled?: boolean; }; -type SelectedNodeProps = { - countryCode?: countryCode; - name: string; - subInfo?: string | null; - animate?: boolean; - quic?: boolean; - streamOptimized?: boolean; -}; - export default function HopSelect({ nodeHop, node, @@ -76,13 +69,19 @@ export default function HopSelect({ } }; - const nodeData = (selected: SelectedNode, gateway: Gateway | null) => { + const nodeData = ( + selected: SelectedNode, + gateway: Gateway | null, + ): SelectedNodeDisplayProps => { + console.log('nodeData', selected, gateway); if (selected === 'random') { return { name: t('fastest', { ns: 'common' }), animate: false, - quic: gateway?.quic, - streamOptimized: gateway?.asn?.type === 'residential', + showQuic: Boolean(quicTag && gateway?.quic), + showStreamOptimized: + nodeHop === 'exit' && gateway?.asn?.type === 'residential', + showFastest: node === 'random' && !gateway?.country?.code, }; } if (isCountry(selected)) { @@ -103,7 +102,7 @@ export default function HopSelect({ countryCode: string, gateway: Gateway | null, region?: string, - ): SelectedNodeProps => { + ): SelectedNodeDisplayProps => { let location = getCountryName(countryCode) || countryCode; let subInfo = null; if (region && region.length > 0) { @@ -125,15 +124,17 @@ export default function HopSelect({ name: location, subInfo, animate: true, - quic: gateway?.quic, - streamOptimized: gateway?.asn?.type === 'residential', + showQuic: Boolean(quicTag && gateway?.quic), + showStreamOptimized: + nodeHop === 'exit' && gateway?.asn?.type === 'residential', + showFastest: node === 'random' && !gateway?.country?.code, }; }; const getGatewayInfo = ( id: string, gateway: Gateway | null, - ): SelectedNodeProps => { + ): SelectedNodeDisplayProps => { if (!gateway) { return { name: id, @@ -157,77 +158,13 @@ export default function HopSelect({ countryCode: country.code.toLowerCase() as countryCode, name, subInfo: components.join(', '), - quic: gateway?.quic, - streamOptimized: gateway?.asn?.type === 'residential', + showQuic: Boolean(quicTag && gateway?.quic), + showStreamOptimized: + nodeHop === 'exit' && gateway?.asn?.type === 'residential', + showFastest: node === 'random' && !gateway?.country?.code, }; }; - const SelectedNode = ({ - countryCode, - name, - subInfo, - animate, - quic, - streamOptimized, - }: SelectedNodeProps) => { - const showQuic = quicTag && quic; - const showStreamOptimized = nodeHop === 'exit' && streamOptimized; - const showFastest = node === 'random' && !countryCode; - - return ( -
- {countryCode && } - {showFastest && ( - - )} -
-
- {name} -
- {animate ? ( - - {subInfo && ( - - {subInfo} - - )} - - ) : ( - <> - {subInfo && ( -
- {subInfo} -
- )} - - )} -
- {(showQuic || showStreamOptimized) && ( -
- {showStreamOptimized && ( - - )} - {showQuic && } -
- )} -
- ); - }; - const gateway = useMemo(() => { if (node === 'random') { return null; @@ -269,7 +206,7 @@ export default function HopSelect({ onClick={handleClick} onKeyDown={handleClick} > - + {isGateway(node) && ( - {isGateway(node) && ( + {!!gateway && (