From c0553c2c9b466a37b9d55811dbce1b8fbb6dafd3 Mon Sep 17 00:00:00 2001 From: Ross Bulat Date: Thu, 26 Oct 2023 13:36:17 +0800 Subject: [PATCH] feat: allow selection of RPC providers (#1557) --- src/config/networks.ts | 38 +++++++++ src/contexts/Api/defaults.ts | 2 + src/contexts/Api/index.tsx | 100 +++++++++++++++-------- src/contexts/Api/types.ts | 2 + src/contexts/UI/defaults.ts | 1 + src/contexts/UI/index.tsx | 60 +++++++------- src/contexts/UI/types.ts | 1 + src/library/Prompt/Wrappers.ts | 23 ++++++ src/modals/Networks/ProvidersPrompt.tsx | 46 +++++++++++ src/modals/Networks/Wrapper.ts | 26 +++++- src/modals/Networks/index.tsx | 101 ++++++++++++++---------- src/types/index.ts | 2 + 12 files changed, 295 insertions(+), 107 deletions(-) create mode 100644 src/modals/Networks/ProvidersPrompt.tsx diff --git a/src/config/networks.ts b/src/config/networks.ts index 167ab8898e..06711ae33e 100644 --- a/src/config/networks.ts +++ b/src/config/networks.ts @@ -24,6 +24,19 @@ export const NetworkList: Networks = { endpoints: { rpc: 'wss://apps-rpc.polkadot.io', lightClient: WellKnownChain.polkadot, + defaultRpcEndpoint: 'Automata 1RPC', + rpcEndpoints: { + 'Automata 1RPC': 'wss://1rpc.io/dot', + Dwellir: 'wss://polkadot-rpc.dwellir.com', + 'Dwellir Tunisia': 'wss://polkadot-rpc-tn.dwellir.com', + 'IBP-GeoDNS1': 'wss://rpc.ibp.network/polkadot', + 'IBP-GeoDNS2': 'wss://rpc.dotters.network/polkadot', + LuckyFriday: 'wss://rpc-polkadot.luckyfriday.io', + OnFinality: 'wss://polkadot.api.onfinality.io/public-ws', + RadiumBlock: 'wss://polkadot.public.curie.radiumblock.co/ws', + Stakeworld: 'wss://dot-rpc.stakeworld.io', + Parity: 'wss://apps-rpc.polkadot.io', + }, }, namespace: '91b171bb158e2d3848fa23a9f1c25182', colors: { @@ -79,6 +92,19 @@ export const NetworkList: Networks = { endpoints: { rpc: 'wss://kusama-rpc.polkadot.io', lightClient: WellKnownChain.ksmcc3, + defaultRpcEndpoint: 'Automata 1RPC', + rpcEndpoints: { + 'Automata 1RPC': 'wss://1rpc.io/ksm', + Dwellir: 'wss://kusama-rpc.dwellir.com', + 'Dwellir Tunisia': 'wss://kusama-rpc-tn.dwellir.com', + 'IBP-GeoDNS1': 'wss://rpc.ibp.network/kusama', + 'IBP-GeoDNS2': 'wss://rpc.dotters.network/kusama', + LuckyFriday: 'wss://rpc-kusama.luckyfriday.io', + OnFinality: 'wss://kusama.api.onfinality.io/public-ws', + RadiumBlock: 'wss://kusama.public.curie.radiumblock.co/ws', + Stakeworld: 'wss://ksm-rpc.stakeworld.io', + Parity: 'wss://kusama-rpc.polkadot.io', + }, }, namespace: 'b0a8d493285c2df73290dfb7e61f870f', colors: { @@ -136,6 +162,18 @@ export const NetworkList: Networks = { endpoints: { rpc: 'wss://westend-rpc.polkadot.io', lightClient: WellKnownChain.westend2, + defaultRpcEndpoint: 'OnFinality', + rpcEndpoints: { + Dwellir: 'wss://westend-rpc.dwellir.com', + 'Dwellir Tunisia': 'wss://westend-rpc-tn.dwellir.com', + 'IBP-GeoDNS1': 'wss://rpc.ibp.network/westend', + 'IBP-GeoDNS2': 'wss://rpc.dotters.network/westend', + LuckyFriday: 'wss://rpc-westend.luckyfriday.io', + OnFinality: 'wss://westend.api.onfinality.io/public-ws', + RadiumBlock: 'wss://westend.public.curie.radiumblock.co/ws', + Stakeworld: 'wss://wnd-rpc.stakeworld.io', + Parity: 'wss://westend-rpc.polkadot.io', + }, }, namespace: 'e143f23803ac50e8f6f8e62695d1ce9e', colors: { diff --git a/src/contexts/Api/defaults.ts b/src/contexts/Api/defaults.ts index a452f4bc9f..2e7afaa837 100644 --- a/src/contexts/Api/defaults.ts +++ b/src/contexts/Api/defaults.ts @@ -28,4 +28,6 @@ export const defaultApiContext: APIContextInterface = { apiStatus: 'disconnected', isLightClient: false, setIsLightClient: () => {}, + rpcEndpoint: '', + setRpcEndpoint: (key) => {}, }; diff --git a/src/contexts/Api/index.tsx b/src/contexts/Api/index.tsx index 0ae299b93d..d9f17fdc47 100644 --- a/src/contexts/Api/index.tsx +++ b/src/contexts/Api/index.tsx @@ -23,7 +23,7 @@ import type { APIProviderProps, ApiStatus, } from 'contexts/Api/types'; -import type { AnyApi, NetworkName } from 'types'; +import type { AnyApi } from 'types'; import { useEffectIgnoreInitial } from '@polkadot-cloud/react/hooks'; import * as defaults from './defaults'; @@ -36,6 +36,22 @@ export const APIProvider = ({ children, network }: APIProviderProps) => { // Store chain state. const [chainState, setchainState] = useState(undefined); + // Store the active RPC provider. + const initialRpcEndpoint = () => { + const local = localStorage.getItem(`${network}_rpc_endpoint`); + if (local) + if (NetworkList[network].endpoints.rpcEndpoints[local]) { + return local; + } else { + localStorage.removeItem(`${network}_rpc_endpoint`); + } + + return NetworkList[network].endpoints.defaultRpcEndpoint; + }; + const [rpcEndpoint, setRpcEndpointState] = useState( + initialRpcEndpoint() + ); + // Store whether in light client mode. const [isLightClient, setIsLightClient] = useState( !!localStorage.getItem('light_client') @@ -50,12 +66,13 @@ export const APIProvider = ({ children, network }: APIProviderProps) => { // Store API connection status. const [apiStatus, setApiStatus] = useState('disconnected'); - // Handle an initial RPC connection. - useEffect(() => { - if (!provider && !isLightClient) { - connectProvider(network); - } - }); + // Set RPC provider with local storage and validity checks. + const setRpcEndpoint = (key: string) => { + if (!NetworkList[network].endpoints.rpcEndpoints[key]) return; + localStorage.setItem(`${network}_rpc_endpoint`, key); + + setRpcEndpointState(key); + }; // Handle light client connection. const handleLightClientConnection = async (Sc: AnyApi) => { @@ -63,7 +80,7 @@ export const APIProvider = ({ children, network }: APIProviderProps) => { Sc, NetworkList[network].endpoints.lightClient ); - connectProvider(network, newProvider); + connectProvider(newProvider); }; // Handle a switch in API. @@ -101,32 +118,10 @@ export const APIProvider = ({ children, network }: APIProviderProps) => { } else { // if not light client, directly connect. setApiStatus('connecting'); - connectProvider(network); + connectProvider(); } }; - // Trigger API connection handler on network or light client change. - useEffect(() => { - handleConnectApi(); - - return () => { - cancelFn?.(); - }; - }, [isLightClient, network]); - - // Initialise provider event handlers when provider is set. - useEffectIgnoreInitial(() => { - if (provider) { - provider.on('connected', () => { - setApiStatus('connected'); - }); - provider.on('error', () => { - setApiStatus('disconnected'); - }); - getChainState(); - } - }, [provider]); - // Fetch chain state. Called once `provider` has been initialised. const getChainState = async () => { if (!provider) return; @@ -243,25 +238,60 @@ export const APIProvider = ({ children, network }: APIProviderProps) => { }; // connect function sets provider and updates active network. - const connectProvider = async (name: NetworkName, lc?: ScProvider) => { - const { endpoints } = NetworkList[name]; - const newProvider = lc || new WsProvider(endpoints.rpc); + const connectProvider = async (lc?: ScProvider) => { + const newProvider = + lc || new WsProvider(NetworkList[network].endpoints.rpc); if (lc) { await newProvider.connect(); } setProvider(newProvider); }; + // Handle an initial RPC connection. + useEffect(() => { + if (!provider && !isLightClient) { + connectProvider(); + } + }); + + // if RPC endpoint changes, and not on light client, re-connect. + useEffectIgnoreInitial(() => { + if (!isLightClient) handleConnectApi(); + }, [rpcEndpoint]); + + // Trigger API connection handler on network or light client change. + useEffect(() => { + handleConnectApi(); + return () => { + cancelFn?.(); + }; + }, [isLightClient, network]); + + // Initialise provider event handlers when provider is set. + useEffectIgnoreInitial(() => { + if (provider) { + provider.on('connected', () => { + setApiStatus('connected'); + }); + provider.on('error', () => { + setApiStatus('disconnected'); + }); + getChainState(); + } + }, [provider]); + return ( {children} diff --git a/src/contexts/Api/types.ts b/src/contexts/Api/types.ts index 1d4aa8a95f..e5e23c4792 100644 --- a/src/contexts/Api/types.ts +++ b/src/contexts/Api/types.ts @@ -48,4 +48,6 @@ export interface APIContextInterface { apiStatus: ApiStatus; isLightClient: boolean; setIsLightClient: (isLightClient: boolean) => void; + rpcEndpoint: string; + setRpcEndpoint: (key: string) => void; } diff --git a/src/contexts/UI/defaults.ts b/src/contexts/UI/defaults.ts index 45b0008f13..02447cd199 100644 --- a/src/contexts/UI/defaults.ts +++ b/src/contexts/UI/defaults.ts @@ -15,4 +15,5 @@ export const defaultUIContext: UIContextInterface = { isSyncing: false, isNetworkSyncing: false, isPoolSyncing: false, + isBraveBrowser: false, }; diff --git a/src/contexts/UI/index.tsx b/src/contexts/UI/index.tsx index 6050f890cb..be3e164e16 100644 --- a/src/contexts/UI/index.tsx +++ b/src/contexts/UI/index.tsx @@ -10,6 +10,7 @@ import type { ImportedAccount } from '@polkadot-cloud/react/types'; import { useActivePools } from 'contexts/Pools/ActivePools'; import { useEffectIgnoreInitial } from '@polkadot-cloud/react/hooks'; import { useImportedAccounts } from 'contexts/Connect/ImportedAccounts'; +import type { AnyJson } from 'types'; import { useApi } from '../Api'; import { useNetworkMetrics } from '../NetworkMetrics'; import { useStaking } from '../Staking'; @@ -24,19 +25,28 @@ export const UIProvider = ({ children }: { children: React.ReactNode }) => { const { synced: activePoolsSynced } = useActivePools(); const { accounts: connectAccounts } = useImportedAccounts(); - // set whether the network has been synced. - const [isNetworkSyncing, setIsNetworkSyncing] = useState(false); + // Set whether the network has been synced. + const [isNetworkSyncing, setIsNetworkSyncing] = useState(false); - // set whether pools are being synced. - const [isPoolSyncing, setIsPoolSyncing] = useState(false); + // Set whether pools are being synced. + const [isPoolSyncing, setIsPoolSyncing] = useState(false); - // set whether app is syncing. Includes workers (active nominations). - const [isSyncing, setIsSyncing] = useState(false); + // Set whether app is syncing. Includes workers (active nominations). + const [isSyncing, setIsSyncing] = useState(false); - // side menu control - const [sideMenuOpen, setSideMenuOpen] = useState(false); + // Side whether the side menu is open. + const [sideMenuOpen, setSideMenu] = useState(false); - // get side menu minimised state from local storage, default to false. + // Store whether in Brave browser. Used for light client warning. + const [isBraveBrowser, setIsBraveBrowser] = useState(false); + + // Store referneces for main app conainers. + const [containerRefs, setContainerRefsState] = useState({}); + const setContainerRefs = (v: any) => { + setContainerRefsState(v); + }; + + // Get side menu minimised state from local storage, default to false. const [userSideMenuMinimised, setUserSideMenuMinimisedState] = useState( localStorageOrDefault('side_menu_minimised', false, true) as boolean ); @@ -46,14 +56,14 @@ export const UIProvider = ({ children }: { children: React.ReactNode }) => { setStateWithRef(v, setUserSideMenuMinimisedState, userSideMenuMinimisedRef); }; - // automatic side menu minimised + // Automatic side menu minimised. const [sideMenuMinimised, setSideMenuMinimised] = useState( window.innerWidth <= SideMenuStickyThreshold ? true : userSideMenuMinimisedRef.current ); - // resize side menu callback + // Resize side menu callback. const resizeCallback = () => { if (window.innerWidth <= SideMenuStickyThreshold) { setSideMenuMinimised(false); @@ -62,20 +72,26 @@ export const UIProvider = ({ children }: { children: React.ReactNode }) => { } }; - // resize event listener + // Resize event listener. useEffect(() => { + (window.navigator as AnyJson)?.brave + ?.isBrave() + .then(async (isBrave: boolean) => { + setIsBraveBrowser(isBrave); + }); + window.addEventListener('resize', resizeCallback); return () => { window.removeEventListener('resize', resizeCallback); }; }, []); - // re-configure minimised on user change + // Re-configure minimised on user change. useEffectIgnoreInitial(() => { resizeCallback(); }, [userSideMenuMinimised]); - // app syncing updates + // App syncing updates. useEffect(() => { let syncing = false; let networkSyncing = false; @@ -121,22 +137,11 @@ export const UIProvider = ({ children }: { children: React.ReactNode }) => { setIsPoolSyncing(poolSyncing); // eraStakers total active nominators has synced - if (!eraStakers.totalActiveNominators) { - syncing = true; - } + if (!eraStakers.totalActiveNominators) syncing = true; setIsSyncing(syncing); }, [isReady, staking, metrics, balances, eraStakers, activePoolsSynced]); - const setSideMenu = (v: boolean) => { - setSideMenuOpen(v); - }; - - const [containerRefs, setContainerRefsState] = useState({}); - const setContainerRefs = (v: any) => { - setContainerRefsState(v); - }; - return ( { setUserSideMenuMinimised, setContainerRefs, sideMenuOpen, - userSideMenuMinimised: userSideMenuMinimisedRef.current, sideMenuMinimised, isSyncing, isNetworkSyncing, isPoolSyncing, containerRefs, + isBraveBrowser, + userSideMenuMinimised: userSideMenuMinimisedRef.current, }} > {children} diff --git a/src/contexts/UI/types.ts b/src/contexts/UI/types.ts index 2615dd0c85..300daa25b9 100644 --- a/src/contexts/UI/types.ts +++ b/src/contexts/UI/types.ts @@ -12,4 +12,5 @@ export interface UIContextInterface { isSyncing: boolean; isNetworkSyncing: boolean; isPoolSyncing: boolean; + isBraveBrowser: boolean; } diff --git a/src/library/Prompt/Wrappers.ts b/src/library/Prompt/Wrappers.ts index ffa6d72172..f73c3466f9 100644 --- a/src/library/Prompt/Wrappers.ts +++ b/src/library/Prompt/Wrappers.ts @@ -176,3 +176,26 @@ export const PromptListItem = styled.div` opacity: var(--opacity-disabled); } `; + +export const PromptSelectItem = styled.button` + border-bottom: 1px solid var(--border-primary-color); + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 1rem 0.5rem; + border-radius: 0.25rem; + width: 100%; + + > h4 { + margin-top: 0.3rem; + } + &:hover { + background: var(--button-hover-background); + } + &.inactive { + h3, + h4 { + color: var(--accent-color-primary); + } + } +`; diff --git a/src/modals/Networks/ProvidersPrompt.tsx b/src/modals/Networks/ProvidersPrompt.tsx new file mode 100644 index 0000000000..421373aee2 --- /dev/null +++ b/src/modals/Networks/ProvidersPrompt.tsx @@ -0,0 +1,46 @@ +import { useApi } from 'contexts/Api'; +import { Title } from 'library/Prompt/Title'; +import { useTranslation } from 'react-i18next'; +import { PromptSelectItem } from 'library/Prompt/Wrappers'; +import { useNetwork } from 'contexts/Network'; +import { NetworkList } from 'config/networks'; +import { usePrompt } from 'contexts/Prompt'; + +export const ProvidersPrompt = () => { + const { t } = useTranslation('modals'); + const { network } = useNetwork(); + const { closePrompt } = usePrompt(); + const { rpcEndpoint, setRpcEndpoint } = useApi(); + + const rpcProviders = NetworkList[network].endpoints.rpcEndpoints; + return ( + <> + + <div className="padded"> + <h4 className="subheading"> + Select an RPC provider to change the node Staking Dashboard connects + to. + </h4> + {Object.entries(rpcProviders)?.map(([key, url], i) => { + const isDisabled = rpcEndpoint === key; + + return ( + <PromptSelectItem + key={`favorite_${i}`} + className={isDisabled ? 'inactive' : undefined} + onClick={() => { + closePrompt(); + setRpcEndpoint(key); + }} + > + <h3> + {key} {isDisabled && ` [Active]`} + </h3> + <h4>{url}</h4> + </PromptSelectItem> + ); + })} + </div> + </> + ); +}; diff --git a/src/modals/Networks/Wrapper.ts b/src/modals/Networks/Wrapper.ts index 75c8e477e7..0c3826700b 100644 --- a/src/modals/Networks/Wrapper.ts +++ b/src/modals/Networks/Wrapper.ts @@ -1,6 +1,7 @@ // Copyright 2023 @paritytech/polkadot-staking-dashboard authors & contributors // SPDX-License-Identifier: GPL-3.0-only +import { SectionFullWidthThreshold } from 'consts'; import styled from 'styled-components'; export const Wrapper = styled.div` @@ -133,19 +134,37 @@ export const BraveWarning = styled.div` export const ConnectionsWrapper = styled.div` display: flex; flex-flow: row wrap; - align-items: center; + align-items: flex-start; margin-top: 1rem; margin-bottom: 1.5rem; + + > div { + flex-basis: 50%; + display: flex; + flex-direction: column; + align-items: flex-start; + + &:first-child { + padding-right: 1rem; + } + + @media (max-width: ${SectionFullWidthThreshold - 400}px) { + flex-basis: 100%; + &:first-child { + padding-right: 0; + } + } + } `; export const ConnectionButton = styled.button<{ $connected: boolean }>` background: var(--button-primary-background); border: 1px solid var(--status-success-color-transparent); position: relative; - padding: 0.75rem 0.75rem; + padding: 1rem 0.75rem; margin-bottom: 1rem; margin-right: 0.5rem; - border-radius: 0.5rem; + border-radius: 0.75rem; ${(props) => props.$connected !== true && ` @@ -154,6 +173,7 @@ export const ConnectionButton = styled.button<{ $connected: boolean }>` display: inline-flex; flex-flow: row wrap; align-items: center; + width: 100%; &:hover { background: var(--button-hover-background); diff --git a/src/modals/Networks/index.tsx b/src/modals/Networks/index.tsx index 4489b092c5..9e99a20ee5 100644 --- a/src/modals/Networks/index.tsx +++ b/src/modals/Networks/index.tsx @@ -3,16 +3,18 @@ import { faChevronRight, faGlobe } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { ModalPadding } from '@polkadot-cloud/react'; +import { ButtonTertiary, ModalPadding } from '@polkadot-cloud/react'; import { capitalizeFirstLetter } from '@polkadot-cloud/utils'; -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { NetworkList } from 'config/networks'; import { useApi } from 'contexts/Api'; import { Title } from 'library/Modal/Title'; -import type { AnyJson, NetworkName } from 'types'; +import type { NetworkName } from 'types'; import { useOverlay } from '@polkadot-cloud/react/hooks'; import { useNetwork } from 'contexts/Network'; +import { useUi } from 'contexts/UI'; +import { usePrompt } from 'contexts/Prompt'; import BraveIconSVG from '../../img/brave-logo.svg?react'; import { BraveWarning, @@ -21,24 +23,19 @@ import { ContentWrapper, NetworkButton, } from './Wrapper'; +import { ProvidersPrompt } from './ProvidersPrompt'; export const Networks = () => { const { t } = useTranslation('modals'); + const { isBraveBrowser } = useUi(); + const { openPromptWith } = usePrompt(); const { network, switchNetwork } = useNetwork(); - const { isLightClient, setIsLightClient } = useApi(); const { setModalStatus, setModalResize } = useOverlay().modal; - const networkKey: string = network; + const { isLightClient, setIsLightClient, rpcEndpoint } = useApi(); + const networkKey = network; - const [braveBrowser, setBraveBrowser] = useState<boolean>(false); - - useEffect(() => { - const navigator: AnyJson = window.navigator; - navigator?.brave?.isBrave().then(async (isBrave: boolean) => { - setBraveBrowser(isBrave); - }); - }); - - useEffect(() => setModalResize(), [braveBrowser]); + // Likely never going to happen; here just to be safe. + useEffect(() => setModalResize(), [isBraveBrowser]); return ( <> @@ -88,35 +85,55 @@ export const Networks = () => { </div> <h4>{t('connectionType')}</h4> <ConnectionsWrapper> - <ConnectionButton - $connected={!isLightClient} - disabled={!isLightClient} - type="button" - onClick={() => { - setIsLightClient(false); - switchNetwork(networkKey as NetworkName); - setModalStatus('closing'); - }} - > - <h3>RPC</h3> - {!isLightClient && <h4 className="selected">{t('selected')}</h4>} - </ConnectionButton> - <ConnectionButton - $connected={isLightClient} - className="off" - type="button" - onClick={() => { - setIsLightClient(true); - switchNetwork(networkKey as NetworkName); - setModalStatus('closing'); - }} - > - <h3>{t('lightClient')}</h3> - {isLightClient && <h4 className="selected">{t('selected')}</h4>} - </ConnectionButton> + <div> + <ConnectionButton + $connected={!isLightClient} + disabled={!isLightClient} + type="button" + onClick={() => { + setIsLightClient(false); + switchNetwork(networkKey as NetworkName); + setModalStatus('closing'); + }} + > + <h3>RPC</h3> + {!isLightClient && ( + <h4 className="selected">{t('selected')}</h4> + )} + </ConnectionButton> + <div + style={{ + padding: '0 0.25rem', + display: 'flex', + alignItems: 'center', + }} + > + Provider:{' '} + <ButtonTertiary + text={rpcEndpoint} + onClick={() => openPromptWith(<ProvidersPrompt />)} + marginLeft + /> + </div> + </div> + <div> + <ConnectionButton + $connected={isLightClient} + className="off" + type="button" + onClick={() => { + setIsLightClient(true); + switchNetwork(networkKey as NetworkName); + setModalStatus('closing'); + }} + > + <h3>{t('lightClient')}</h3> + {isLightClient && <h4 className="selected">{t('selected')}</h4>} + </ConnectionButton> + </div> </ConnectionsWrapper> - {braveBrowser ? ( + {isBraveBrowser ? ( <BraveWarning> <BraveIconSVG /> <div className="brave-text"> diff --git a/src/types/index.ts b/src/types/index.ts index 20d3bf3ef2..353d9d46d8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -20,6 +20,8 @@ export interface Network { endpoints: { rpc: string; lightClient: AnyApi; + defaultRpcEndpoint: string; + rpcEndpoints: Record<string, string>; }; namespace: string; // eslint-disable-next-line @typescript-eslint/no-unused-vars