Mark Chains as Favorites (#11718)

* 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
This commit is contained in:
Arjun Porwal 2025-08-01 12:22:40 +05:30 committed by GitHub
parent 930d9cf152
commit c907fde127
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 270 additions and 32 deletions

View File

@ -25,6 +25,15 @@ function defaultT (keyOrText: string, text?: string | TOptions, options?: TOptio
export function createWsEndpoints (t: TFunction = defaultT, firstOnly = false, withSort = true): LinkOption[] { export function createWsEndpoints (t: TFunction = defaultT, firstOnly = false, withSort = true): LinkOption[] {
return [ return [
...createCustom(t), ...createCustom(t),
{
isDisabled: false,
isHeader: true,
isSpaced: true,
text: t('rpc.header.favorite', 'Favorite chains', { ns: 'apps-config' }),
textBy: '',
ui: {},
value: ''
},
{ {
isDisabled: false, isDisabled: false,
isHeader: true, isHeader: true,
@ -38,6 +47,7 @@ export function createWsEndpoints (t: TFunction = defaultT, firstOnly = false, w
{ {
isDisabled: false, isDisabled: false,
isHeader: true, isHeader: true,
isSpaced: true,
text: t('rpc.header.kusama.relay', 'Kusama & parachains', { ns: 'apps-config' }), text: t('rpc.header.kusama.relay', 'Kusama & parachains', { ns: 'apps-config' }),
textBy: '', textBy: '',
ui: {}, ui: {},
@ -77,6 +87,7 @@ export function createWsEndpoints (t: TFunction = defaultT, firstOnly = false, w
{ {
isDisabled: false, isDisabled: false,
isHeader: true, isHeader: true,
isSpaced: true,
text: t('rpc.header.test', 'Test networks', { ns: 'apps-config' }), text: t('rpc.header.test', 'Test networks', { ns: 'apps-config' }),
textBy: '', textBy: '',
ui: {}, ui: {},

View File

@ -6,6 +6,7 @@
"rpc.dev.custom.own": "Custom", "rpc.dev.custom.own": "Custom",
"rpc.dev.local": "Local Node", "rpc.dev.local": "Local Node",
"rpc.header.dev": "Development", "rpc.header.dev": "Development",
"rpc.header.favorite": "Favorite chains",
"rpc.header.kusama.relay": "Kusama & parachains", "rpc.header.kusama.relay": "Kusama & parachains",
"rpc.header.live": "Live networks", "rpc.header.live": "Live networks",
"rpc.header.paseo.relay": "Test Paseo & parachains", "rpc.header.paseo.relay": "Test Paseo & parachains",

View File

@ -1,13 +1,14 @@
// Copyright 2017-2025 @polkadot/apps authors & contributors // Copyright 2017-2025 @polkadot/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import type { Group } from './types.js'; import type { Group, IFavoriteChainProps, IFavoriteChainsStorage } from './types.js';
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { Icon, styled } from '@polkadot/react-components'; import { Icon, styled } from '@polkadot/react-components';
import Network from './Network.js'; import Network from './Network.js';
import { isFavoriteChain } from './utils.js';
interface Props { interface Props {
affinities: Record<string, string>; affinities: Record<string, string>;
@ -16,12 +17,14 @@ interface Props {
className?: string; className?: string;
index: number; index: number;
isSelected: boolean; isSelected: boolean;
favoriteChains: IFavoriteChainsStorage,
toggleFavoriteChain: (chainInfo: IFavoriteChainProps) => void;
setApiUrl: (network: string, apiUrl: string) => void; setApiUrl: (network: string, apiUrl: string) => void;
setGroup: (groupIndex: number) => void; setGroup: (groupIndex: number) => void;
value: Group; value: Group;
} }
function GroupDisplay ({ affinities, apiUrl, children, className = '', index, isSelected, setApiUrl, setGroup, value: { header, isSpaced, networks } }: Props): React.ReactElement<Props> { function GroupDisplay ({ affinities, apiUrl, children, className = '', favoriteChains, index, isSelected, setApiUrl, setGroup, toggleFavoriteChain, value: { header, isSpaced, networks } }: Props): React.ReactElement<Props> {
const _setGroup = useCallback( const _setGroup = useCallback(
() => setGroup(isSelected ? -1 : index), () => setGroup(isSelected ? -1 : index),
[index, isSelected, setGroup] [index, isSelected, setGroup]
@ -32,6 +35,10 @@ function GroupDisplay ({ affinities, apiUrl, children, className = '', index, is
[networks] [networks]
); );
if (header?.toString().includes('Favorite') && Object.keys(favoriteChains).length === 0) {
return <></>;
}
return ( return (
<StyledDiv className={`${className}${isSelected ? ' isSelected' : ''}`}> <StyledDiv className={`${className}${isSelected ? ' isSelected' : ''}`}>
<div <div
@ -48,8 +55,14 @@ function GroupDisplay ({ affinities, apiUrl, children, className = '', index, is
<Network <Network
affinity={affinities[network.name]} affinity={affinities[network.name]}
apiUrl={apiUrl} apiUrl={apiUrl}
isFavorite={isFavoriteChain(favoriteChains, {
chainName: network.name,
paraId: network.paraId,
relay: network.nameRelay
})}
key={index} key={index}
setApiUrl={setApiUrl} setApiUrl={setApiUrl}
toggleFavoriteChain={toggleFavoriteChain}
value={network} value={network}
/> />
))} ))}

View File

@ -1,11 +1,11 @@
// Copyright 2017-2025 @polkadot/apps authors & contributors // Copyright 2017-2025 @polkadot/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import type { Network } from './types.js'; import type { IFavoriteChainProps, Network } from './types.js';
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { ChainImg, styled } from '@polkadot/react-components'; import { ChainImg, Icon, styled } from '@polkadot/react-components';
import { useTranslation } from '../translate.js'; import { useTranslation } from '../translate.js';
import Url from './Url.js'; import Url from './Url.js';
@ -16,9 +16,11 @@ interface Props {
className?: string; className?: string;
setApiUrl: (network: string, apiUrl: string) => void; setApiUrl: (network: string, apiUrl: string) => void;
value: Network; value: Network;
isFavorite: boolean;
toggleFavoriteChain: (chainInfo: IFavoriteChainProps) => void;
} }
function NetworkDisplay ({ apiUrl, className = '', setApiUrl, value: { isChild, isRelay, isUnreachable, name, nameRelay: relay, paraId, providers, ui } }: Props): React.ReactElement<Props> { function NetworkDisplay ({ apiUrl, className = '', isFavorite, setApiUrl, toggleFavoriteChain, value: { isChild, isRelay, isUnreachable, name, nameRelay: relay, paraId, providers, ui } }: Props): React.ReactElement<Props> {
const { t } = useTranslation(); const { t } = useTranslation();
const isSelected = useMemo( const isSelected = useMemo(
() => providers.some(({ url }) => url === apiUrl), () => providers.some(({ url }) => url === apiUrl),
@ -39,33 +41,46 @@ function NetworkDisplay ({ apiUrl, className = '', setApiUrl, value: { isChild,
[name, setApiUrl] [name, setApiUrl]
); );
const _toggleFavoriteChain = useCallback((e: React.MouseEvent<SVGSVGElement, MouseEvent>) => {
e.preventDefault();
e.stopPropagation();
toggleFavoriteChain({ chainName: name, paraId, relay });
}, [name, paraId, relay, toggleFavoriteChain]);
return ( return (
<StyledDiv className={`${className}${isSelected ? ' isSelected highlight--border' : ''}${isUnreachable ? ' isUnreachable' : ''}`}> <StyledDiv className={`${className}${isSelected ? ' isSelected highlight--border' : ''}${isUnreachable ? ' isUnreachable' : ''}`}>
<div <div
className={`endpointSection${isChild ? ' isChild' : ''}`} className={`markFavoriteSection${isChild ? ' isChild' : ''}`}
onClick={isUnreachable ? undefined : _selectUrl} onClick={isUnreachable ? undefined : _selectUrl}
> >
<ChainImg <div className='endpointSection'>
className='endpointIcon' <ChainImg
isInline className='endpointIcon'
logo={ui.logo || 'empty'} isInline
withoutHl logo={ui.logo || 'empty'}
/> withoutHl
<div className='endpointValue'> />
<div>{name}</div> <div className='endpointValue'>
{isSelected && (isRelay || !!paraId) && ( <div>{name}</div>
<div className='endpointExtra'> {isSelected && (isRelay || !!paraId) && (
{isRelay <div className='endpointExtra'>
? t('Relay chain') {isRelay
: paraId && paraId < 1000 ? t('Relay chain')
? t('{{relay}} System', { replace: { relay } }) : paraId && paraId < 1000
: paraId && paraId < 2000 ? t('{{relay}} System', { replace: { relay } })
? t('{{relay}} Common', { replace: { relay } }) : paraId && paraId < 2000
: t('{{relay}} Parachain', { replace: { relay } }) ? t('{{relay}} Common', { replace: { relay } })
} : t('{{relay}} Parachain', { replace: { relay } })
</div> }
)} </div>
)}
</div>
</div> </div>
<Icon
className={isFavorite ? 'isFavorite' : ''}
icon='star'
onClick={_toggleFavoriteChain}
/>
</div> </div>
{isSelected && providers.map(({ name, url }): React.ReactNode => ( {isSelected && providers.map(({ name, url }): React.ReactNode => (
<Url <Url
@ -92,6 +107,42 @@ const StyledDiv = styled.div`
opacity: var(--opacity-light); opacity: var(--opacity-light);
} }
&.isSelected {
.markFavoriteSection {
gap: 1rem;
padding-bottom: 1rem;
}
}
.markFavoriteSection {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
&:hover .ui--Icon {
opacity: 0.5;
}
.ui--Icon {
scale: 1.1;
opacity: 0;
transition: color 0.2s ease;
&:hover {
opacity: 0.5;
stroke: darkorange;
color: darkorange;
}
&.isFavorite {
opacity: 1;
stroke: darkorange;
color: darkorange;
}
}
}
&.isSelected, &.isSelected,
&:hover { &:hover {
background: var(--bg-table); background: var(--bg-table);
@ -107,10 +158,6 @@ const StyledDiv = styled.div`
margin-top: 1rem; margin-top: 1rem;
} }
&.isChild .endpointIcon {
margin-left: 1.25rem;
}
&+.endpointProvider { &+.endpointProvider {
margin-top: -0.125rem; margin-top: -0.125rem;
} }

View File

@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import type { LinkOption } from '@polkadot/apps-config/endpoints/types'; import type { LinkOption } from '@polkadot/apps-config/endpoints/types';
import type { Group } from './types.js'; import type { Group, IFavoriteChainProps } from './types.js';
// ok, this seems to be an eslint bug, this _is_ a package import // ok, this seems to be an eslint bug, this _is_ a package import
import punycode from 'punycode/'; import punycode from 'punycode/';
@ -17,6 +17,7 @@ import { isAscii } from '@polkadot/util';
import { useTranslation } from '../translate.js'; import { useTranslation } from '../translate.js';
import GroupDisplay from './Group.js'; import GroupDisplay from './Group.js';
import { getFavoriteChains, isFavoriteChain, toggleFavoriteChain } from './utils.js';
interface Props { interface Props {
className?: string; className?: string;
@ -43,13 +44,47 @@ function isValidUrl (url: string): boolean {
} }
function combineEndpoints (endpoints: LinkOption[]): Group[] { function combineEndpoints (endpoints: LinkOption[]): Group[] {
const favoriteChains = getFavoriteChains();
let favoriteGroupIndex = -1;
return endpoints.reduce((result: Group[], e): Group[] => { return endpoints.reduce((result: Group[], e): Group[] => {
if (e.isHeader) { if (e.isHeader) {
const isFavoriteHeader =
typeof e.text === 'string' && e.text.includes('Favorite chains');
result.push({ header: e.text, isDevelopment: e.isDevelopment, isSpaced: e.isSpaced, networks: [] }); result.push({ header: e.text, isDevelopment: e.isDevelopment, isSpaced: e.isSpaced, networks: [] });
if (isFavoriteHeader) {
favoriteGroupIndex = result.length - 1;
}
} else { } else {
const prev = result[result.length - 1]; const prev = result[result.length - 1];
const prov = { isLightClient: e.isLightClient, name: e.textBy, url: e.value }; const prov = { isLightClient: e.isLightClient, name: e.textBy, url: e.value };
const isFavorite = isFavoriteChain(favoriteChains,
{ chainName: e.text?.toString() ?? '',
paraId: e.paraId,
relay: e.textRelay?.toString() });
if (isFavorite && favoriteGroupIndex !== -1 && !e.isUnreachable) {
const favGroup = result[favoriteGroupIndex];
const lastFav = favGroup.networks[favGroup.networks.length - 1];
if (lastFav && lastFav.name === e.text && lastFav.nameRelay === e.textRelay && lastFav.paraId === e.paraId) {
lastFav.providers.push(prov);
} else {
favGroup.networks.push({
isChild: e.isChild,
isRelay: !!e.genesisHash,
name: e.text as string,
nameRelay: e.textRelay as string,
paraId: e.paraId,
providers: [prov],
ui: e.ui
});
}
}
if (prev.networks[prev.networks.length - 1] && e.text === prev.networks[prev.networks.length - 1].name) { if (prev.networks[prev.networks.length - 1] && e.text === prev.networks[prev.networks.length - 1].name) {
prev.networks[prev.networks.length - 1].providers.push(prov); prev.networks[prev.networks.length - 1].providers.push(prov);
} else if (!e.isUnreachable) { } else if (!e.isUnreachable) {
@ -155,6 +190,7 @@ function Endpoints ({ className = '', offset, onClose }: Props): React.ReactElem
const { t } = useTranslation(); const { t } = useTranslation();
const linkOptions = createWsEndpoints(t); const linkOptions = createWsEndpoints(t);
const { isLocalFork } = useApi(); const { isLocalFork } = useApi();
const [favoriteChains, setFavoriteChains] = useState(() => getFavoriteChains());
const [groups, setGroups] = useState(() => combineEndpoints(linkOptions)); const [groups, setGroups] = useState(() => combineEndpoints(linkOptions));
const [{ apiUrl, groupIndex, hasUrlChanged, isUrlValid }, setApiUrl] = useState<UrlState>(() => extractUrlState(settings.get().apiUrl, groups)); const [{ apiUrl, groupIndex, hasUrlChanged, isUrlValid }, setApiUrl] = useState<UrlState>(() => extractUrlState(settings.get().apiUrl, groups));
const [storedCustomEndpoints, setStoredCustomEndpoints] = useState<string[]>(() => getCustomEndpoints()); const [storedCustomEndpoints, setStoredCustomEndpoints] = useState<string[]>(() => getCustomEndpoints());
@ -198,6 +234,12 @@ function Endpoints ({ className = '', offset, onClose }: Props): React.ReactElem
[] []
); );
const _toggleFavoriteChain = useCallback((chainInfo: IFavoriteChainProps) => {
toggleFavoriteChain(chainInfo);
setFavoriteChains(getFavoriteChains());
setGroups(combineEndpoints(createWsEndpoints(t)));
}, [t]);
const _removeApiEndpoint = useCallback( const _removeApiEndpoint = useCallback(
(): void => { (): void => {
if (!isSavedCustomEndpoint) { if (!isSavedCustomEndpoint) {
@ -325,11 +367,13 @@ function Endpoints ({ className = '', offset, onClose }: Props): React.ReactElem
<GroupDisplay <GroupDisplay
affinities={affinities} affinities={affinities}
apiUrl={apiUrl} apiUrl={apiUrl}
favoriteChains={favoriteChains}
index={index} index={index}
isSelected={groupIndex === index} isSelected={groupIndex === index}
key={index} key={index}
setApiUrl={_setApiUrl} setApiUrl={_setApiUrl}
setGroup={_changeGroup} setGroup={_changeGroup}
toggleFavoriteChain={_toggleFavoriteChain}
value={group} value={group}
> >
{group.isDevelopment && ( {group.isDevelopment && (

View File

@ -3,6 +3,14 @@
import type React from 'react'; import type React from 'react';
export interface IFavoriteChainProps {
chainName: string;
relay?: string;
paraId?: number;
}
export type IFavoriteChainsStorage = Record<string, {relay: string, paraId: number}[]>
export interface Network { export interface Network {
isChild?: boolean; isChild?: boolean;
isLightClient?: boolean; isLightClient?: boolean;

View File

@ -0,0 +1,114 @@
// Copyright 2017-2025 @polkadot/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { IFavoriteChainProps, IFavoriteChainsStorage } from './types.js';
export const FAVORITE_CHAINS_KEY = 'polkadot-app-favorite-chains';
export const toggleFavoriteChain = (
chainInfo: IFavoriteChainProps
) => {
try {
const chainName = chainInfo.chainName;
const meta = { paraId: chainInfo.paraId ?? -1, relay: chainInfo.relay ?? 'Unknown' };
const favoriteChains = getFavoriteChains();
const existingEntries = favoriteChains[chainName] ?? [];
const alreadyExists = existingEntries.some(
(entry) => entry.relay === meta.relay && entry.paraId === meta.paraId
);
let updatedEntries: IFavoriteChainsStorage[string];
if (alreadyExists) {
// Remove the matching entry
updatedEntries = existingEntries.filter(
(entry) => entry.relay !== meta.relay || entry.paraId !== meta.paraId
);
} else {
// Add new entry
updatedEntries = [...existingEntries, meta];
}
const updatedChains: IFavoriteChainsStorage = { ...favoriteChains };
if (updatedEntries.length === 0) {
delete updatedChains[chainName];
} else {
updatedChains[chainName] = updatedEntries;
}
localStorage.setItem(FAVORITE_CHAINS_KEY, JSON.stringify(updatedChains));
} catch {}
};
export const getFavoriteChains = (): IFavoriteChainsStorage => {
try {
const favoriteChains = localStorage.getItem(FAVORITE_CHAINS_KEY);
if (!favoriteChains) {
return {};
}
const parsed: unknown = JSON.parse(favoriteChains);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('Invalid favorite chains format');
}
const result: IFavoriteChainsStorage = {};
for (const [key, value] of Object.entries(parsed)) {
if (!Array.isArray(value)) {
throw new Error(`Invalid value for key "${key}": not an array`);
}
const allValid = value.every(
(entry: IFavoriteChainsStorage[string][number]) =>
typeof entry === 'object' &&
entry !== null &&
typeof entry.relay === 'string' &&
typeof entry.paraId === 'number'
);
if (!allValid) {
throw new Error(`Invalid entries under key "${key}"`);
}
result[key] = value as IFavoriteChainsStorage[string];
}
return result;
} catch (e) {
console.error('Failed to parse favorite chains:', e);
localStorage.removeItem(FAVORITE_CHAINS_KEY);
return {};
}
};
export const isFavoriteChain = (
favoriteChains: IFavoriteChainsStorage,
chainInfo: IFavoriteChainProps
): boolean => {
try {
const chainName = chainInfo.chainName;
const meta = { paraId: chainInfo.paraId ?? -1, relay: chainInfo.relay ?? 'Unknown' };
const list = favoriteChains[chainName];
if (!Array.isArray(list)) {
return false;
}
return list.some(
(entry) =>
entry.relay === meta.relay && entry.paraId === meta.paraId
);
} catch (e) {
console.error('Failed to check favorite chain:', e);
return false;
}
};

View File

@ -16,7 +16,7 @@ interface Props {
icon: IconName; icon: IconName;
isPadded?: boolean; isPadded?: boolean;
isSpinning?: boolean; isSpinning?: boolean;
onClick?: () => void; onClick?: (e: React.MouseEvent<SVGSVGElement, MouseEvent>) => void;
size?: '1x' | '2x'; size?: '1x' | '2x';
tooltip?: string; tooltip?: string;
} }