diff --git a/packages/apps-config/src/endpoints/index.ts b/packages/apps-config/src/endpoints/index.ts index 669ffaafd8..b7b9fbdd70 100644 --- a/packages/apps-config/src/endpoints/index.ts +++ b/packages/apps-config/src/endpoints/index.ts @@ -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: {}, diff --git a/packages/apps/public/locales/en/apps-config.json b/packages/apps/public/locales/en/apps-config.json index 6ff43855e9..6374393310 100644 --- a/packages/apps/public/locales/en/apps-config.json +++ b/packages/apps/public/locales/en/apps-config.json @@ -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", diff --git a/packages/apps/src/Endpoints/Group.tsx b/packages/apps/src/Endpoints/Group.tsx index 547709a4af..8a59ad2c59 100644 --- a/packages/apps/src/Endpoints/Group.tsx +++ b/packages/apps/src/Endpoints/Group.tsx @@ -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; @@ -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 { +function GroupDisplay ({ affinities, apiUrl, children, className = '', favoriteChains, index, isSelected, setApiUrl, setGroup, toggleFavoriteChain, value: { header, isSpaced, networks } }: Props): React.ReactElement { 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 (
))} diff --git a/packages/apps/src/Endpoints/Network.tsx b/packages/apps/src/Endpoints/Network.tsx index 685e4adfa1..cd7190a153 100644 --- a/packages/apps/src/Endpoints/Network.tsx +++ b/packages/apps/src/Endpoints/Network.tsx @@ -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 { +function NetworkDisplay ({ apiUrl, className = '', isFavorite, setApiUrl, toggleFavoriteChain, value: { isChild, isRelay, isUnreachable, name, nameRelay: relay, paraId, providers, ui } }: Props): React.ReactElement { 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) => { + e.preventDefault(); + e.stopPropagation(); + toggleFavoriteChain({ chainName: name, paraId, relay }); + }, [name, paraId, relay, toggleFavoriteChain]); + return (
- -
-
{name}
- {isSelected && (isRelay || !!paraId) && ( -
- {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 } }) - } -
- )} +
+ +
+
{name}
+ {isSelected && (isRelay || !!paraId) && ( +
+ {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 } }) + } +
+ )} +
+
{isSelected && providers.map(({ name, url }): React.ReactNode => ( { 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(() => extractUrlState(settings.get().apiUrl, groups)); const [storedCustomEndpoints, setStoredCustomEndpoints] = useState(() => 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 {group.isDevelopment && ( diff --git a/packages/apps/src/Endpoints/types.ts b/packages/apps/src/Endpoints/types.ts index fa8d789626..303871211c 100644 --- a/packages/apps/src/Endpoints/types.ts +++ b/packages/apps/src/Endpoints/types.ts @@ -3,6 +3,14 @@ import type React from 'react'; +export interface IFavoriteChainProps { + chainName: string; + relay?: string; + paraId?: number; +} + +export type IFavoriteChainsStorage = Record + export interface Network { isChild?: boolean; isLightClient?: boolean; diff --git a/packages/apps/src/Endpoints/utils.ts b/packages/apps/src/Endpoints/utils.ts new file mode 100644 index 0000000000..e2c81dcef6 --- /dev/null +++ b/packages/apps/src/Endpoints/utils.ts @@ -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; + } +}; diff --git a/packages/react-components/src/Icon.tsx b/packages/react-components/src/Icon.tsx index 0d64b24cff..391b7bfe5a 100644 --- a/packages/react-components/src/Icon.tsx +++ b/packages/react-components/src/Icon.tsx @@ -16,7 +16,7 @@ interface Props { icon: IconName; isPadded?: boolean; isSpinning?: boolean; - onClick?: () => void; + onClick?: (e: React.MouseEvent) => void; size?: '1x' | '2x'; tooltip?: string; }