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:
parent
930d9cf152
commit
c907fde127
@ -25,6 +25,15 @@ function defaultT (keyOrText: string, text?: string | TOptions, options?: TOptio
|
||||
export function createWsEndpoints (t: TFunction = defaultT, firstOnly = false, withSort = true): LinkOption[] {
|
||||
return [
|
||||
...createCustom(t),
|
||||
{
|
||||
isDisabled: false,
|
||||
isHeader: true,
|
||||
isSpaced: true,
|
||||
text: t('rpc.header.favorite', 'Favorite chains', { ns: 'apps-config' }),
|
||||
textBy: '',
|
||||
ui: {},
|
||||
value: ''
|
||||
},
|
||||
{
|
||||
isDisabled: false,
|
||||
isHeader: true,
|
||||
@ -38,6 +47,7 @@ export function createWsEndpoints (t: TFunction = defaultT, firstOnly = false, w
|
||||
{
|
||||
isDisabled: false,
|
||||
isHeader: true,
|
||||
isSpaced: true,
|
||||
text: t('rpc.header.kusama.relay', 'Kusama & parachains', { ns: 'apps-config' }),
|
||||
textBy: '',
|
||||
ui: {},
|
||||
@ -77,6 +87,7 @@ export function createWsEndpoints (t: TFunction = defaultT, firstOnly = false, w
|
||||
{
|
||||
isDisabled: false,
|
||||
isHeader: true,
|
||||
isSpaced: true,
|
||||
text: t('rpc.header.test', 'Test networks', { ns: 'apps-config' }),
|
||||
textBy: '',
|
||||
ui: {},
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
"rpc.dev.custom.own": "Custom",
|
||||
"rpc.dev.local": "Local Node",
|
||||
"rpc.header.dev": "Development",
|
||||
"rpc.header.favorite": "Favorite chains",
|
||||
"rpc.header.kusama.relay": "Kusama & parachains",
|
||||
"rpc.header.live": "Live networks",
|
||||
"rpc.header.paseo.relay": "Test Paseo & parachains",
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
// Copyright 2017-2025 @polkadot/apps authors & contributors
|
||||
// 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 { Icon, styled } from '@polkadot/react-components';
|
||||
|
||||
import Network from './Network.js';
|
||||
import { isFavoriteChain } from './utils.js';
|
||||
|
||||
interface Props {
|
||||
affinities: Record<string, string>;
|
||||
@ -16,12 +17,14 @@ interface Props {
|
||||
className?: string;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
favoriteChains: IFavoriteChainsStorage,
|
||||
toggleFavoriteChain: (chainInfo: IFavoriteChainProps) => void;
|
||||
setApiUrl: (network: string, apiUrl: string) => void;
|
||||
setGroup: (groupIndex: number) => void;
|
||||
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(
|
||||
() => setGroup(isSelected ? -1 : index),
|
||||
[index, isSelected, setGroup]
|
||||
@ -32,6 +35,10 @@ function GroupDisplay ({ affinities, apiUrl, children, className = '', index, is
|
||||
[networks]
|
||||
);
|
||||
|
||||
if (header?.toString().includes('Favorite') && Object.keys(favoriteChains).length === 0) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledDiv className={`${className}${isSelected ? ' isSelected' : ''}`}>
|
||||
<div
|
||||
@ -48,8 +55,14 @@ function GroupDisplay ({ affinities, apiUrl, children, className = '', index, is
|
||||
<Network
|
||||
affinity={affinities[network.name]}
|
||||
apiUrl={apiUrl}
|
||||
isFavorite={isFavoriteChain(favoriteChains, {
|
||||
chainName: network.name,
|
||||
paraId: network.paraId,
|
||||
relay: network.nameRelay
|
||||
})}
|
||||
key={index}
|
||||
setApiUrl={setApiUrl}
|
||||
toggleFavoriteChain={toggleFavoriteChain}
|
||||
value={network}
|
||||
/>
|
||||
))}
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
// Copyright 2017-2025 @polkadot/apps authors & contributors
|
||||
// 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 { ChainImg, styled } from '@polkadot/react-components';
|
||||
import { ChainImg, Icon, styled } from '@polkadot/react-components';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import Url from './Url.js';
|
||||
@ -16,9 +16,11 @@ interface Props {
|
||||
className?: string;
|
||||
setApiUrl: (network: string, apiUrl: string) => void;
|
||||
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 isSelected = useMemo(
|
||||
() => providers.some(({ url }) => url === apiUrl),
|
||||
@ -39,33 +41,46 @@ function NetworkDisplay ({ apiUrl, className = '', setApiUrl, value: { isChild,
|
||||
[name, setApiUrl]
|
||||
);
|
||||
|
||||
const _toggleFavoriteChain = useCallback((e: React.MouseEvent<SVGSVGElement, MouseEvent>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleFavoriteChain({ chainName: name, paraId, relay });
|
||||
}, [name, paraId, relay, toggleFavoriteChain]);
|
||||
|
||||
return (
|
||||
<StyledDiv className={`${className}${isSelected ? ' isSelected highlight--border' : ''}${isUnreachable ? ' isUnreachable' : ''}`}>
|
||||
<div
|
||||
className={`endpointSection${isChild ? ' isChild' : ''}`}
|
||||
className={`markFavoriteSection${isChild ? ' isChild' : ''}`}
|
||||
onClick={isUnreachable ? undefined : _selectUrl}
|
||||
>
|
||||
<ChainImg
|
||||
className='endpointIcon'
|
||||
isInline
|
||||
logo={ui.logo || 'empty'}
|
||||
withoutHl
|
||||
/>
|
||||
<div className='endpointValue'>
|
||||
<div>{name}</div>
|
||||
{isSelected && (isRelay || !!paraId) && (
|
||||
<div className='endpointExtra'>
|
||||
{isRelay
|
||||
? t('Relay chain')
|
||||
: paraId && paraId < 1000
|
||||
? t('{{relay}} System', { replace: { relay } })
|
||||
: paraId && paraId < 2000
|
||||
? t('{{relay}} Common', { replace: { relay } })
|
||||
: t('{{relay}} Parachain', { replace: { relay } })
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
<div className='endpointSection'>
|
||||
<ChainImg
|
||||
className='endpointIcon'
|
||||
isInline
|
||||
logo={ui.logo || 'empty'}
|
||||
withoutHl
|
||||
/>
|
||||
<div className='endpointValue'>
|
||||
<div>{name}</div>
|
||||
{isSelected && (isRelay || !!paraId) && (
|
||||
<div className='endpointExtra'>
|
||||
{isRelay
|
||||
? t('Relay chain')
|
||||
: paraId && paraId < 1000
|
||||
? t('{{relay}} System', { replace: { relay } })
|
||||
: paraId && paraId < 2000
|
||||
? t('{{relay}} Common', { replace: { relay } })
|
||||
: t('{{relay}} Parachain', { replace: { relay } })
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Icon
|
||||
className={isFavorite ? 'isFavorite' : ''}
|
||||
icon='star'
|
||||
onClick={_toggleFavoriteChain}
|
||||
/>
|
||||
</div>
|
||||
{isSelected && providers.map(({ name, url }): React.ReactNode => (
|
||||
<Url
|
||||
@ -92,6 +107,42 @@ const StyledDiv = styled.div`
|
||||
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,
|
||||
&:hover {
|
||||
background: var(--bg-table);
|
||||
@ -107,10 +158,6 @@ const StyledDiv = styled.div`
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
&.isChild .endpointIcon {
|
||||
margin-left: 1.25rem;
|
||||
}
|
||||
|
||||
&+.endpointProvider {
|
||||
margin-top: -0.125rem;
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
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
|
||||
import punycode from 'punycode/';
|
||||
@ -17,6 +17,7 @@ import { isAscii } from '@polkadot/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import GroupDisplay from './Group.js';
|
||||
import { getFavoriteChains, isFavoriteChain, toggleFavoriteChain } from './utils.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
@ -43,13 +44,47 @@ function isValidUrl (url: string): boolean {
|
||||
}
|
||||
|
||||
function combineEndpoints (endpoints: LinkOption[]): Group[] {
|
||||
const favoriteChains = getFavoriteChains();
|
||||
let favoriteGroupIndex = -1;
|
||||
|
||||
return endpoints.reduce((result: Group[], e): Group[] => {
|
||||
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: [] });
|
||||
|
||||
if (isFavoriteHeader) {
|
||||
favoriteGroupIndex = result.length - 1;
|
||||
}
|
||||
} else {
|
||||
const prev = result[result.length - 1];
|
||||
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) {
|
||||
prev.networks[prev.networks.length - 1].providers.push(prov);
|
||||
} else if (!e.isUnreachable) {
|
||||
@ -155,6 +190,7 @@ function Endpoints ({ className = '', offset, onClose }: Props): React.ReactElem
|
||||
const { t } = useTranslation();
|
||||
const linkOptions = createWsEndpoints(t);
|
||||
const { 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));
|
||||
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(
|
||||
(): void => {
|
||||
if (!isSavedCustomEndpoint) {
|
||||
@ -325,11 +367,13 @@ function Endpoints ({ className = '', offset, onClose }: Props): React.ReactElem
|
||||
<GroupDisplay
|
||||
affinities={affinities}
|
||||
apiUrl={apiUrl}
|
||||
favoriteChains={favoriteChains}
|
||||
index={index}
|
||||
isSelected={groupIndex === index}
|
||||
key={index}
|
||||
setApiUrl={_setApiUrl}
|
||||
setGroup={_changeGroup}
|
||||
toggleFavoriteChain={_toggleFavoriteChain}
|
||||
value={group}
|
||||
>
|
||||
{group.isDevelopment && (
|
||||
|
||||
@ -3,6 +3,14 @@
|
||||
|
||||
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 {
|
||||
isChild?: boolean;
|
||||
isLightClient?: boolean;
|
||||
|
||||
114
packages/apps/src/Endpoints/utils.ts
Normal file
114
packages/apps/src/Endpoints/utils.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@ -16,7 +16,7 @@ interface Props {
|
||||
icon: IconName;
|
||||
isPadded?: boolean;
|
||||
isSpinning?: boolean;
|
||||
onClick?: () => void;
|
||||
onClick?: (e: React.MouseEvent<SVGSVGElement, MouseEvent>) => void;
|
||||
size?: '1x' | '2x';
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user