Simplified Chain Endpoint Selection (#11742)

* feat: added star icon for chain

* feat: add favorite chains header and localization support

* fix: star ux flow enhanced

* feat: mark chains as favorite and store it

* feat: added transitions and shown already favorite chains

* fix: remove favorite chains key from localStorage on error

* feat: integrate favorite chains functionality in Group

* chore: fix spacing between group headers

* feat: enhance favorite chains functionality with structured storage and utility functions

* fix: prevent rendering empty component for groups with no favorite chains

* chore: translations

* chore: match favorite network with relay and paraId

* chore: remove logging

* chore: providers list removed

* feat: created dropdown for choosing provider

* feat: add alert for no available WebSocket providers in NetworkDisplay

* fix: update setApiUrl to preserve groupIndex

* feat: improved favorite chain header style

* feat: add getContrastingColor utility and update GroupDisplay styling
This commit is contained in:
Arjun Porwal 2025-08-11 09:15:58 +05:30 committed by GitHub
parent 21ded61c60
commit 27ce6e2703
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 84 additions and 64 deletions

View File

@ -8,7 +8,7 @@ import React, { useCallback, useMemo } from 'react';
import { Icon, styled } from '@polkadot/react-components';
import Network from './Network.js';
import { isFavoriteChain } from './utils.js';
import { getContrastingColor, isFavoriteChain } from './utils.js';
interface Props {
affinities: Record<string, string>;
@ -22,27 +22,33 @@ interface Props {
setApiUrl: (network: string, apiUrl: string) => void;
setGroup: (groupIndex: number) => void;
value: Group;
highlightColor: string;
}
function GroupDisplay ({ affinities, apiUrl, children, className = '', favoriteChains, index, isSelected, setApiUrl, setGroup, toggleFavoriteChain, value: { header, isSpaced, networks } }: Props): React.ReactElement<Props> {
function GroupDisplay ({ affinities, apiUrl, children, className = '', favoriteChains, highlightColor, index, isSelected, setApiUrl, setGroup, toggleFavoriteChain, value: { header, isSpaced, networks } }: Props): React.ReactElement<Props> {
const _setGroup = useCallback(
() => setGroup(isSelected ? -1 : index),
[index, isSelected, setGroup]
);
const isFavoriteHeader = useMemo(() => header?.toString().includes('Favorite'), [header]);
const filtered = useMemo(
() => networks.filter(({ isUnreachable }) => !isUnreachable),
[networks]
);
if (header?.toString().includes('Favorite') && Object.keys(favoriteChains).length === 0) {
if (isFavoriteHeader && Object.keys(favoriteChains).length === 0) {
return <></>;
}
return (
<StyledDiv className={`${className}${isSelected ? ' isSelected' : ''}`}>
<StyledDiv
className={`${className}${isSelected ? ' isSelected' : ''}`}
highlightColor={highlightColor}
>
<div
className={`groupHeader${isSpaced ? ' isSpaced' : ''}`}
className={`groupHeader${isSpaced ? ' isSpaced' : ''}${isFavoriteHeader ? ' isFavoriteHeader' : ''}`}
onClick={_setGroup}
>
<Icon icon={isSelected ? 'caret-up' : 'caret-down'} />
@ -74,7 +80,7 @@ function GroupDisplay ({ affinities, apiUrl, children, className = '', favoriteC
);
}
const StyledDiv = styled.div`
const StyledDiv = styled.div<{ highlightColor: string; }>`
.groupHeader {
border-radius: 0.25rem;
cursor: pointer;
@ -94,6 +100,23 @@ const StyledDiv = styled.div`
.ui--Icon {
margin-right: 0.5rem;
}
&.isFavoriteHeader {
&:hover {
background: linear-gradient(
135deg,
${(props) => props.highlightColor}f2 0%,
${(props) => props.highlightColor}99 100%
);
color: ${(props) => getContrastingColor(props.highlightColor)};
}
&::after {
content: '⭐';
margin-left: 8px;
font-size: 16px;
}
}
}
.groupNetworks {

View File

@ -5,10 +5,9 @@ import type { IFavoriteChainProps, Network } from './types.js';
import React, { useCallback, useMemo } from 'react';
import { ChainImg, Icon, styled } from '@polkadot/react-components';
import { ChainImg, Dropdown, Icon, styled } from '@polkadot/react-components';
import { useTranslation } from '../translate.js';
import Url from './Url.js';
interface Props {
affinity?: string; // unused - previous selection
@ -27,10 +26,23 @@ function NetworkDisplay ({ apiUrl, className = '', isFavorite, setApiUrl, toggle
[apiUrl, providers]
);
const providersOptions = useMemo(() => {
return providers.map(({ name, url }) => ({
text: name,
value: url
}));
}, [providers]);
const _selectUrl = useCallback(
() => {
const filteredProviders = providers.filter(({ url }) => !url.startsWith('light://'));
if (filteredProviders.length === 0) {
alert('No WebSocket (wss://) provider available');
return;
}
return setApiUrl(name, filteredProviders[Math.floor(Math.random() * filteredProviders.length)].url);
},
[name, providers, setApiUrl]
@ -82,15 +94,15 @@ function NetworkDisplay ({ apiUrl, className = '', isFavorite, setApiUrl, toggle
onClick={_toggleFavoriteChain}
/>
</div>
{isSelected && providers.map(({ name, url }): React.ReactNode => (
<Url
apiUrl={apiUrl}
key={url}
label={name}
setApiUrl={_setApiUrl}
url={url}
{isSelected &&
<Dropdown
className='isSmall'
onChange={_setApiUrl}
options={providersOptions}
value={apiUrl}
withLabel={false}
/>
))}
}
</StyledDiv>
);
}

View File

@ -1,45 +0,0 @@
// Copyright 2017-2025 @polkadot/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback } from 'react';
import { styled, Toggle } from '@polkadot/react-components';
interface Props {
apiUrl: string;
className?: string;
label: React.ReactNode;
setApiUrl: (apiUrl: string) => void;
url: string;
}
function Url ({ apiUrl, className, label, setApiUrl, url }: Props): React.ReactElement<Props> {
const _setApiUrl = useCallback(
() => setApiUrl(url),
[setApiUrl, url]
);
return (
<StyledToggle
className={className}
isRadio
label={label}
onChange={_setApiUrl}
value={apiUrl === url}
/>
);
}
const StyledToggle = styled(Toggle)`
padding: 0.25rem;
text-align: right;
> label {
max-width: 12.5rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
`;
export default React.memo(Url);

View File

@ -11,6 +11,7 @@ import store from 'store';
import { createWsEndpoints, CUSTOM_ENDPOINT_KEY } from '@polkadot/apps-config';
import { Button, Input, Sidebar, styled } from '@polkadot/react-components';
import { defaultHighlight } from '@polkadot/react-components/styles';
import { useApi } from '@polkadot/react-hooks';
import { settings } from '@polkadot/ui-settings';
import { isAscii } from '@polkadot/util';
@ -189,7 +190,7 @@ function isLocalForkDisabled (hasUrlChanged: boolean, apiUrl: string, isUrlValid
function Endpoints ({ className = '', offset, onClose }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const linkOptions = createWsEndpoints(t);
const { isLocalFork } = useApi();
const { apiEndpoint, isLocalFork } = useApi();
const [favoriteChains, setFavoriteChains] = useState(() => getFavoriteChains());
const [groups, setGroups] = useState(() => combineEndpoints(linkOptions));
const [{ apiUrl, groupIndex, hasUrlChanged, isUrlValid }, setApiUrl] = useState<UrlState>(() => extractUrlState(settings.get().apiUrl, groups));
@ -269,7 +270,7 @@ function Endpoints ({ className = '', offset, onClose }: Props): React.ReactElem
return newValue;
});
setApiUrl(extractUrlState(apiUrl, groups));
setApiUrl((state) => ({ ...extractUrlState(apiUrl, groups), groupIndex: state.groupIndex }));
},
[groups]
);
@ -280,7 +281,7 @@ function Endpoints ({ className = '', offset, onClose }: Props): React.ReactElem
apiUrl = punycode.toASCII(apiUrl);
}
setApiUrl(extractUrlState(apiUrl, groups));
setApiUrl((state) => ({ ...extractUrlState(apiUrl, groups), groupIndex: state.groupIndex }));
},
[groups]
);
@ -368,6 +369,7 @@ function Endpoints ({ className = '', offset, onClose }: Props): React.ReactElem
affinities={affinities}
apiUrl={apiUrl}
favoriteChains={favoriteChains}
highlightColor={apiEndpoint?.ui.color || defaultHighlight}
index={index}
isSelected={groupIndex === index}
key={index}

View File

@ -112,3 +112,31 @@ export const isFavoriteChain = (
return false;
}
};
export function getContrastingColor (hexColor: string): string {
if (typeof hexColor !== 'string') {
return '#000000';
}
let hex = hexColor.replace('#', '').trim();
if (hex.length === 3) {
hex = hex.split('').map((c) => c + c).join('');
}
if (hex.length !== 6 || /[^0-9a-f]/i.test(hex)) {
return '#000000';
}
try {
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? '#000000' : '#FFFFFF';
} catch {
return '#000000';
}
}